From 0af6d7f690f848517269e181d18a9a6229828e79 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Mon, 20 Jul 2026 17:54:31 +0100 Subject: [PATCH 01/84] fix(auth): clarify Resend webhook log messages Railway and similar log viewers emphasize message text, so the generic webhook message hid the event type and provider unless operators expanded structured properties.\n\nInclude the normalized event type and provider directly in each accepted webhook message while retaining the existing structured fields.\n\nCo-authored-by: OpenAI --- .changeset/resend-log-message-context.md | 9 +++++++++ .../auth-service/src/__tests__/resend-webhook.test.ts | 6 +++--- packages/auth-service/src/routes/resend-webhook.ts | 2 +- 3 files changed, 13 insertions(+), 4 deletions(-) create mode 100644 .changeset/resend-log-message-context.md diff --git a/.changeset/resend-log-message-context.md b/.changeset/resend-log-message-context.md new file mode 100644 index 00000000..31e00eba --- /dev/null +++ b/.changeset/resend-log-message-context.md @@ -0,0 +1,9 @@ +--- +'ePDS': patch +--- + +Resend webhook logs now identify the email event type and provider in their message text. + +**Affects:** Operators + +**Operators:** Log messages use text such as `Received email event delivered from resend`, making events legible in log viewers that de-emphasize structured fields. diff --git a/packages/auth-service/src/__tests__/resend-webhook.test.ts b/packages/auth-service/src/__tests__/resend-webhook.test.ts index d66b118d..81cb9ba7 100644 --- a/packages/auth-service/src/__tests__/resend-webhook.test.ts +++ b/packages/auth-service/src/__tests__/resend-webhook.test.ts @@ -119,7 +119,7 @@ describe('Resend webhook receiver', () => { email: 'person@example.com', subject: '[REDACTED] — Your sign-in code', }, - 'Received email event', + 'Received email event delivered from resend', ) }) @@ -137,7 +137,7 @@ describe('Resend webhook receiver', () => { messageId: 'resend-email-123', email: 'person@example.com', }), - 'Received email event', + `Received email event ${normalizedType} from resend`, ) }) @@ -222,7 +222,7 @@ describe('Resend webhook receiver', () => { eventType: normalizedType, messageId: 'resend-email-123', }), - 'Received email event', + `Received email event ${normalizedType} from resend`, ) }) diff --git a/packages/auth-service/src/routes/resend-webhook.ts b/packages/auth-service/src/routes/resend-webhook.ts index dffc42e3..9f181ca9 100644 --- a/packages/auth-service/src/routes/resend-webhook.ts +++ b/packages/auth-service/src/routes/resend-webhook.ts @@ -233,7 +233,7 @@ function logResendEvent( email: event.data.to[0], subject: redactOtpFromSubject(event.data.subject, otpLength, otpCharset), } - const message = 'Received email event' + const message = `Received email event ${fields.eventType} from ${fields.provider}` if ((WARNING_RESEND_EVENT_TYPES as readonly string[]).includes(event.type)) { logger.warn(fields, message) } else { From f2067a488a80840044ff00e5264521966c092303 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Mon, 20 Jul 2026 18:46:19 +0100 Subject: [PATCH 02/84] fix(auth): format Resend webhook event messages Quote the normalized event type and use provider metadata to preserve the lowercase structured ID while rendering the provider's human-readable name in log messages.\n\nCo-authored-by: OpenAI --- .changeset/resend-log-message-context.md | 2 +- .../src/__tests__/resend-webhook.test.ts | 6 +++--- packages/auth-service/src/routes/resend-webhook.ts | 13 +++++++------ 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/.changeset/resend-log-message-context.md b/.changeset/resend-log-message-context.md index 31e00eba..118462a3 100644 --- a/.changeset/resend-log-message-context.md +++ b/.changeset/resend-log-message-context.md @@ -6,4 +6,4 @@ Resend webhook logs now identify the email event type and provider in their mess **Affects:** Operators -**Operators:** Log messages use text such as `Received email event delivered from resend`, making events legible in log viewers that de-emphasize structured fields. +**Operators:** Log messages use text such as `Received email event 'delivered' from Resend`, making events legible in log viewers that de-emphasize structured fields. diff --git a/packages/auth-service/src/__tests__/resend-webhook.test.ts b/packages/auth-service/src/__tests__/resend-webhook.test.ts index 81cb9ba7..2f184a47 100644 --- a/packages/auth-service/src/__tests__/resend-webhook.test.ts +++ b/packages/auth-service/src/__tests__/resend-webhook.test.ts @@ -119,7 +119,7 @@ describe('Resend webhook receiver', () => { email: 'person@example.com', subject: '[REDACTED] — Your sign-in code', }, - 'Received email event delivered from resend', + "Received email event 'delivered' from Resend", ) }) @@ -137,7 +137,7 @@ describe('Resend webhook receiver', () => { messageId: 'resend-email-123', email: 'person@example.com', }), - `Received email event ${normalizedType} from resend`, + `Received email event '${normalizedType}' from Resend`, ) }) @@ -222,7 +222,7 @@ describe('Resend webhook receiver', () => { eventType: normalizedType, messageId: 'resend-email-123', }), - `Received email event ${normalizedType} from resend`, + `Received email event '${normalizedType}' from Resend`, ) }) diff --git a/packages/auth-service/src/routes/resend-webhook.ts b/packages/auth-service/src/routes/resend-webhook.ts index 9f181ca9..31c2df79 100644 --- a/packages/auth-service/src/routes/resend-webhook.ts +++ b/packages/auth-service/src/routes/resend-webhook.ts @@ -14,6 +14,7 @@ import { Webhook } from 'svix' import { createLogger } from '@certified-app/shared' const logger = createLogger('auth:email-webhook') +const PROVIDER = { id: 'resend', displayName: 'Resend' } as const export const RESEND_WEBHOOK_PATH = '/webhooks/resend' @@ -154,7 +155,7 @@ function verifyResendWebhook( payload = verifier.verify(req.body, headers) } catch (err) { logger.warn( - { err, provider: 'resend', eventId: headers['svix-id'] }, + { err, provider: PROVIDER.id, eventId: headers['svix-id'] }, 'Rejected email webhook with invalid signature', ) return { ok: false, error: 'Invalid webhook signature' } @@ -162,7 +163,7 @@ function verifyResendWebhook( if (!isResendEvent(payload)) { logger.warn( - { provider: 'resend', eventId: headers['svix-id'] }, + { provider: PROVIDER.id, eventId: headers['svix-id'] }, 'Rejected invalid email webhook payload', ) return { ok: false, error: 'Invalid webhook payload' } @@ -225,7 +226,7 @@ function logResendEvent( otpCharset: OtpCharset, ): void { const fields = { - provider: 'resend', + provider: PROVIDER.id, eventId: svixId, eventType: NORMALIZED_EVENT_TYPES[event.type], occurredAt: event.created_at, @@ -233,7 +234,7 @@ function logResendEvent( email: event.data.to[0], subject: redactOtpFromSubject(event.data.subject, otpLength, otpCharset), } - const message = `Received email event ${fields.eventType} from ${fields.provider}` + const message = `Received email event '${fields.eventType}' from ${PROVIDER.displayName}` if ((WARNING_RESEND_EVENT_TYPES as readonly string[]).includes(event.type)) { logger.warn(fields, message) } else { @@ -268,7 +269,7 @@ export function createResendWebhookRouter( if (!isLoggedResendEvent(result.event)) { logger.debug( { - provider: 'resend', + provider: PROVIDER.id, eventId, eventType: result.event.type.slice('email.'.length), }, @@ -281,7 +282,7 @@ export function createResendWebhookRouter( const eventFromAddress = parseSingleEmailAddress(result.event.data.from) if (eventFromAddress !== expectedFromAddress) { logger.debug( - { provider: 'resend', eventId }, + { provider: PROVIDER.id, eventId }, 'Ignored email webhook for another sender', ) res.status(200).json({ received: true, ignored: true }) From bbe63d9d6a059abd5c88b0b8907b08ceb7adc5cf Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Thu, 7 May 2026 06:52:04 +0000 Subject: [PATCH 03/84] fix(auth-service): make sign-in errors announce to screen readers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The error banners on the OAuth login, account-login, and recovery forms were plain divs / paragraphs. When an error appeared (or updated) screen-reader users got no announcement — the error silently changed and the user kept thinking the form had accepted their input. Add ARIA live-region semantics so updates announce naturally: - Main login flash banner (#error-msg): role=status aria-live=polite (the polite intent is correct — errors should announce on update without interrupting whatever the user was doing) - Server-rendered

banners on account-login and recovery: role=alert (these are static at render time, so the default assertive announcement on first render is appropriate) Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/auth-service/src/routes/account-login.ts | 4 ++-- packages/auth-service/src/routes/login-page.ts | 2 +- packages/auth-service/src/routes/recovery.ts | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/auth-service/src/routes/account-login.ts b/packages/auth-service/src/routes/account-login.ts index 7b044ab2..8d832555 100644 --- a/packages/auth-service/src/routes/account-login.ts +++ b/packages/auth-service/src/routes/account-login.ts @@ -151,7 +151,7 @@ function renderLoginForm(opts: { csrfToken: string; error?: string }): string {

Account Settings

Sign in to manage your account

- ${opts.error ? '

' + escapeHtml(opts.error) + '

' : ''} + ${opts.error ? '' : ''}
@@ -193,7 +193,7 @@ function renderOtpForm(opts: {

Enter your code

We sent a ${opts.otpLength}-${opts.otpCharset === 'alphanumeric' ? 'character' : 'digit'} code to ${escapeHtml(maskedEmail)}

- ${opts.error ? '

' + escapeHtml(opts.error) + '

' : ''} + ${opts.error ? '' : ''} diff --git a/packages/auth-service/src/routes/login-page.ts b/packages/auth-service/src/routes/login-page.ts index 376b6343..2942df44 100644 --- a/packages/auth-service/src/routes/login-page.ts +++ b/packages/auth-service/src/routes/login-page.ts @@ -654,7 +654,7 @@ export function renderLoginPage(opts: { ${logoHtml}

${opts.initialStep === 'otp' ? 'Enter your code' : 'Sign in'}

- + ${socialButtonsHtml} diff --git a/packages/auth-service/src/routes/recovery.ts b/packages/auth-service/src/routes/recovery.ts index ac6ae5df..3e6ee6da 100644 --- a/packages/auth-service/src/routes/recovery.ts +++ b/packages/auth-service/src/routes/recovery.ts @@ -340,7 +340,7 @@ export function renderRecoveryForm(opts: {

Account Recovery

Enter the backup email address associated with your account.

- ${opts.error ? '

' + escapeHtml(opts.error) + '

' : ''} + ${opts.error ? '' : ''} @@ -434,7 +434,7 @@ export function renderRecoveryOtpForm(opts: {

Enter recovery code

If a backup email matches, we sent a ${opts.otpLength}-${opts.otpCharset === 'alphanumeric' ? 'character' : 'digit'} code to ${escapeHtml(maskedEmail)}

- ${opts.error ? '

' + escapeHtml(opts.error) + '

' : ''} + ${opts.error ? '' : ''} From a675b23961e522220fdcf51ce6518448139ee843 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Thu, 7 May 2026 08:49:18 +0000 Subject: [PATCH 04/84] fix(demo): error banner announces to screen readers via role=alert MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The demo's sign-in error banner was a plain
. When an error appeared screen-reader users got no announcement — visual users saw the red banner, screen-reader users had no idea anything had changed. role="alert" matches the auth-service error banners in the previous commits (which got role=status / role=alert) and tells assistive tech to announce the message immediately. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/demo/src/app/components/LoginForm.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/demo/src/app/components/LoginForm.tsx b/packages/demo/src/app/components/LoginForm.tsx index e13c1c67..94ffdf09 100644 --- a/packages/demo/src/app/components/LoginForm.tsx +++ b/packages/demo/src/app/components/LoginForm.tsx @@ -40,6 +40,7 @@ export function LoginForm() { <> {errorMessage && (
Date: Thu, 7 May 2026 09:57:39 +0000 Subject: [PATCH 05/84] fix(shared): renderError adds role=alert on the error

MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared renderError() helper produces every styled HTML fallback page (Sign-in session expired, internal failures, session not found, …). The error message

was a plain paragraph; screen-reader users got no announcement when these pages loaded. role="alert" tells assistive tech to announce immediately on page render. Matches the role=alert treatment we just gave the inline server-rendered error banners on account-login and recovery. One-character behaviour change; no test fixtures depend on the exact HTML. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/shared/src/render-error.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/shared/src/render-error.ts b/packages/shared/src/render-error.ts index 22cf2593..56f7942e 100644 --- a/packages/shared/src/render-error.ts +++ b/packages/shared/src/render-error.ts @@ -102,7 +102,7 @@ export function renderError(

${escapeHtml(title)}

-

${escapeHtml(message)}

+ ${startOverHtml}
${bodyExtra} From 424bda0362bcd58329985ffd2f0aa4f6e699db41 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Thu, 7 May 2026 08:45:57 +0000 Subject: [PATCH 06/84] fix(auth-service): visible keyboard focus on Verify and Resend buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Buttons on the OTP form had :hover styles but no :focus-visible styles — so keyboard users tabbing through the form had no idea which button was focused (the browser default focus ring is suppressed by some user-agent stylesheets and the .btn-primary explicitly sets `border: none`). Add :focus-visible styles on .btn-primary and .btn-secondary that match the existing --focus-border CSS custom property the input fields already use, with a 2px outline + 2px offset so the ring sits clear of the rounded button edges. Pure CSS change; no behaviour change. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/auth-service/src/routes/login-page.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/auth-service/src/routes/login-page.ts b/packages/auth-service/src/routes/login-page.ts index 376b6343..eb534c67 100644 --- a/packages/auth-service/src/routes/login-page.ts +++ b/packages/auth-service/src/routes/login-page.ts @@ -616,9 +616,11 @@ export function renderLoginPage(opts: { .otp-actions { display: flex; gap: 32px; justify-content: center; margin-top: 12px; } .btn-primary { width: 100%; padding: 15px; background: ${brandColor}; color: white; border: none; border-radius: 9999px; font-size: 15px; font-weight: 500; cursor: pointer; transition: opacity 0.15s; } .btn-primary:hover { opacity: 0.9; } + .btn-primary:focus-visible { outline: 2px solid var(--focus-border, #2563eb); outline-offset: 2px; } .btn-primary:disabled { opacity: 0.7; cursor: not-allowed; } - .btn-secondary { display: inline-block; color: #6b6b6b; background: none; border: none; font-size: 14px; font-weight: 500; cursor: pointer; padding: 4px 0; } + .btn-secondary { display: inline-block; color: #6b6b6b; background: none; border: none; font-size: 14px; font-weight: 500; cursor: pointer; padding: 4px 0; border-radius: 4px; } .btn-secondary:hover { color: #1A130F; } + .btn-secondary:focus-visible { outline: 2px solid var(--focus-border, #2563eb); outline-offset: 2px; } .btn-social { display: flex; align-items: center; justify-content: center; gap: 8px; width: 100%; padding: 13px 20px; border: 1px solid var(--btn-secondary-border); border-radius: 9999px; font-size: 15px; font-weight: 500; cursor: pointer; text-decoration: none; background: white; color: #333; margin-bottom: 8px; transition: background 0.15s; } .btn-social:hover { background: #fafafa; } .btn-atproto { margin-top: 12px; margin-bottom: 0; color: #1A130F !important; background: var(--input-bg) !important; border-color: var(--input-border) !important; } From 446c5ae161b153ef239ba6df612cbad55165f877 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Thu, 7 May 2026 08:47:37 +0000 Subject: [PATCH 07/84] fix(auth-service): visible keyboard focus on remaining route buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same focus-visible treatment that the main login page got in the previous commit, applied to the standalone account-login form, the recovery form, and the handle-picker buttons. All three had :hover styles and no :focus-visible — keyboard users tabbing through had no idea where focus was. Pure CSS change. Co-Authored-By: Claude Opus 4.7 (1M context) --- packages/auth-service/src/routes/account-login.ts | 4 +++- packages/auth-service/src/routes/choose-handle.ts | 2 ++ packages/auth-service/src/routes/recovery.ts | 4 +++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/auth-service/src/routes/account-login.ts b/packages/auth-service/src/routes/account-login.ts index 7b044ab2..79b7ba64 100644 --- a/packages/auth-service/src/routes/account-login.ts +++ b/packages/auth-service/src/routes/account-login.ts @@ -239,6 +239,8 @@ const CSS = ` .otp-input { font-size: 28px !important; text-align: center; font-family: 'SF Mono', Menlo, Consolas, monospace !important; padding: 14px !important; } .btn-primary { width: 100%; padding: 12px; background: #0f1828; color: white; border: none; border-radius: 8px; font-size: 16px; font-weight: 500; cursor: pointer; } .btn-primary:hover { background: #1a2a40; } - .btn-secondary { display: inline-block; color: #0f1828; background: none; border: none; font-size: 14px; cursor: pointer; text-decoration: underline; } + .btn-primary:focus-visible { outline: 2px solid #0f1828; outline-offset: 2px; } + .btn-secondary { display: inline-block; color: #0f1828; background: none; border: none; font-size: 14px; cursor: pointer; text-decoration: underline; border-radius: 4px; } + .btn-secondary:focus-visible { outline: 2px solid #0f1828; outline-offset: 2px; } .error { color: #dc3545; background: #fdf0f0; padding: 12px; border-radius: 8px; margin: 12px 0; } ` diff --git a/packages/auth-service/src/routes/choose-handle.ts b/packages/auth-service/src/routes/choose-handle.ts index 555a912f..3b652cee 100644 --- a/packages/auth-service/src/routes/choose-handle.ts +++ b/packages/auth-service/src/routes/choose-handle.ts @@ -536,9 +536,11 @@ export function renderChooseHandlePage( .error { color: #dc3545; background: #fdf0f0; padding: 12px; border-radius: 8px; margin-bottom: 16px; font-size: 14px; } .btn-primary { width: 100%; padding: 12px; background: #0f1828; color: white; border: none; border-radius: 8px; font-size: 16px; font-weight: 500; cursor: pointer; margin-top: 8px; } .btn-primary:hover:not(:disabled) { background: #1a2a40; } + .btn-primary:focus-visible { outline: 2px solid #0f1828; outline-offset: 2px; } .btn-primary:disabled { opacity: 0.5; cursor: not-allowed; } .btn-secondary { width: 100%; padding: 10px; background: white; color: #0f1828; border: 1px solid #0f1828; border-radius: 8px; font-size: 15px; font-weight: 500; cursor: pointer; margin-top: 8px; } .btn-secondary:hover:not(:disabled) { background: #f0f2f5; } + .btn-secondary:focus-visible { outline: 2px solid #0f1828; outline-offset: 2px; } .btn-secondary:disabled { opacity: 0.5; cursor: not-allowed; } ${renderOptionalStyleTag(customCss)} diff --git a/packages/auth-service/src/routes/recovery.ts b/packages/auth-service/src/routes/recovery.ts index ac6ae5df..75a29297 100644 --- a/packages/auth-service/src/routes/recovery.ts +++ b/packages/auth-service/src/routes/recovery.ts @@ -511,6 +511,8 @@ const CSS = ` .otp-input { font-size: 28px !important; text-align: center; font-family: 'SF Mono', Menlo, Consolas, monospace !important; padding: 14px !important; } .btn-primary { width: 100%; padding: 12px; background: #0f1828; color: white; border: none; border-radius: 8px; font-size: 16px; font-weight: 500; cursor: pointer; } .btn-primary:hover { background: #1a2a40; } - .btn-secondary { display: inline-block; margin-top: 12px; color: #0f1828; background: none; border: none; font-size: 14px; cursor: pointer; text-decoration: underline; } + .btn-primary:focus-visible { outline: 2px solid #0f1828; outline-offset: 2px; } + .btn-secondary { display: inline-block; margin-top: 12px; color: #0f1828; background: none; border: none; font-size: 14px; cursor: pointer; text-decoration: underline; border-radius: 4px; } + .btn-secondary:focus-visible { outline: 2px solid #0f1828; outline-offset: 2px; } .error { color: #dc3545; background: #fdf0f0; padding: 12px; border-radius: 8px; margin: 12px 0; } ` From 23dcd0f0b88ca5e87b4b465523be19d14864ea04 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Thu, 7 May 2026 08:47:58 +0000 Subject: [PATCH 08/84] chore(changeset): keyboard focus-visible on buttons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers both prior commits (50077c0 main login + 645a2fb other routes) — single end-user change with one consolidated entry. Co-Authored-By: Claude Opus 4.7 (1M context) --- .changeset/keyboard-focus-visible-on-buttons.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/keyboard-focus-visible-on-buttons.md diff --git a/.changeset/keyboard-focus-visible-on-buttons.md b/.changeset/keyboard-focus-visible-on-buttons.md new file mode 100644 index 00000000..bf99df16 --- /dev/null +++ b/.changeset/keyboard-focus-visible-on-buttons.md @@ -0,0 +1,9 @@ +--- +'ePDS': patch +--- + +Sign-in buttons now show a visible keyboard focus ring. + +**Affects:** End users + +**End users:** keyboard users tabbing through the sign-in pages couldn't tell where focus was — the buttons styled out the browser default focus ring without replacing it. Verify, Resend, Use different email, Recover, Send recovery code, Continue with email, Update handle, etc. all now show a clear outline when focused via the keyboard, while still hiding the ring on a mouse click. From 610b5e6318e363f9987adaf7846393e66cd988a0 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Thu, 30 Jul 2026 14:29:45 +0100 Subject: [PATCH 09/84] chore(changeset): document accessible error alerts --- .changeset/announce-auth-errors.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/announce-auth-errors.md diff --git a/.changeset/announce-auth-errors.md b/.changeset/announce-auth-errors.md new file mode 100644 index 00000000..f28adc58 --- /dev/null +++ b/.changeset/announce-auth-errors.md @@ -0,0 +1,9 @@ +--- +'ePDS': patch +--- + +Sign-in and recovery errors are now announced automatically by screen readers. + +**Affects:** End users + +**End users:** authentication errors now use standard alert and live-region semantics across sign-in, recovery, and the demo client, so screen-reader users hear failures without having to search the page for changed text. From 9e12e30edf8a9259fec8d261f8587d90d24a2e37 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Thu, 30 Jul 2026 16:24:54 +0100 Subject: [PATCH 10/84] fix(auth): use accurate autocomplete hints --- .changeset/email-autocomplete-hints.md | 9 +++++++++ e2e/step-definitions/atproto-login-button.steps.ts | 2 ++ packages/auth-service/src/routes/account-login.ts | 1 + packages/auth-service/src/routes/login-page.ts | 3 +++ packages/auth-service/src/routes/recovery.ts | 1 + 5 files changed, 16 insertions(+) create mode 100644 .changeset/email-autocomplete-hints.md diff --git a/.changeset/email-autocomplete-hints.md b/.changeset/email-autocomplete-hints.md new file mode 100644 index 00000000..ed312c3b --- /dev/null +++ b/.changeset/email-autocomplete-hints.md @@ -0,0 +1,9 @@ +--- +'ePDS': patch +--- + +Sign-in and recovery email fields now work more reliably with browser autofill and password managers. + +**Affects:** End users + +**End users:** email fields now identify themselves to browsers as email addresses, while the same sign-in field identifies itself as a username after switching to handle entry. This helps browsers and password managers offer the right saved value in each mode. diff --git a/e2e/step-definitions/atproto-login-button.steps.ts b/e2e/step-definitions/atproto-login-button.steps.ts index f0100ca8..2c34d692 100644 --- a/e2e/step-definitions/atproto-login-button.steps.ts +++ b/e2e/step-definitions/atproto-login-button.steps.ts @@ -76,6 +76,7 @@ Then( const input = page.locator('#email') await expect(input).toHaveAttribute('type', 'text') await expect(input).toHaveAttribute('name', 'handle') + await expect(input).toHaveAttribute('autocomplete', 'username') await expect(input).toHaveAttribute('placeholder', 'you.bsky.social') await expect(page.locator('label[for="email"]')).toHaveText('Handle') }, @@ -88,6 +89,7 @@ Then( const input = page.locator('#email') await expect(input).toHaveAttribute('type', 'email') await expect(input).toHaveAttribute('name', 'email') + await expect(input).toHaveAttribute('autocomplete', 'email') await expect(input).toHaveAttribute('placeholder', 'you@example.com') await expect(page.locator('label[for="email"]')).toHaveText( 'Enter your email address', diff --git a/packages/auth-service/src/routes/account-login.ts b/packages/auth-service/src/routes/account-login.ts index 7b044ab2..aea686fb 100644 --- a/packages/auth-service/src/routes/account-login.ts +++ b/packages/auth-service/src/routes/account-login.ts @@ -157,6 +157,7 @@ function renderLoginForm(opts: { csrfToken: string; error?: string }): string {
diff --git a/packages/auth-service/src/routes/login-page.ts b/packages/auth-service/src/routes/login-page.ts index 376b6343..4f742773 100644 --- a/packages/auth-service/src/routes/login-page.ts +++ b/packages/auth-service/src/routes/login-page.ts @@ -664,6 +664,7 @@ export function renderLoginPage(opts: {
@@ -1035,6 +1036,7 @@ export function renderLoginPage(opts: { emailInput.type = 'text'; emailInput.placeholder = 'you.bsky.social'; emailInput.name = 'handle'; + emailInput.autocomplete = 'username'; emailInput.value = ''; // Browser's built-in type="email" validation would block valid // handles; remove it for handle mode. @@ -1046,6 +1048,7 @@ export function renderLoginPage(opts: { emailInput.type = 'email'; emailInput.placeholder = 'you@example.com'; emailInput.name = 'email'; + emailInput.autocomplete = 'email'; emailInput.value = ''; emailInput.setAttribute('required', ''); sendOtpBtn.textContent = 'Continue'; diff --git a/packages/auth-service/src/routes/recovery.ts b/packages/auth-service/src/routes/recovery.ts index ac6ae5df..f5917598 100644 --- a/packages/auth-service/src/routes/recovery.ts +++ b/packages/auth-service/src/routes/recovery.ts @@ -348,6 +348,7 @@ export function renderRecoveryForm(opts: {
From 1a900621936941066372228b423d26a6808cb21b Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Thu, 30 Jul 2026 16:24:53 +0100 Subject: [PATCH 11/84] fix(auth): clear email when returning from OTP entry --- .changeset/use-different-email-clears-form.md | 9 +++++++++ e2e/step-definitions/auth.steps.ts | 11 +++++++++++ features/passwordless-authentication.feature | 17 +++++++++++++++++ packages/auth-service/src/routes/login-page.ts | 9 +++++++++ 4 files changed, 46 insertions(+) create mode 100644 .changeset/use-different-email-clears-form.md diff --git a/.changeset/use-different-email-clears-form.md b/.changeset/use-different-email-clears-form.md new file mode 100644 index 00000000..3fa6e176 --- /dev/null +++ b/.changeset/use-different-email-clears-form.md @@ -0,0 +1,9 @@ +--- +'ePDS': patch +--- + +"Use different email" on the sign-in code page now clears the email field. + +**Affects:** End users + +**End users:** clicking **Use different email** on the code-entry page used to take you back to the email form with your previous address still filled in — exactly the opposite of what the button suggests, and forcing you to clear the field manually before you could type a new address. The form now starts empty, with the cursor in the field, so you can just type the new address and continue. diff --git a/e2e/step-definitions/auth.steps.ts b/e2e/step-definitions/auth.steps.ts index d0f2a6f5..2e18271c 100644 --- a/e2e/step-definitions/auth.steps.ts +++ b/e2e/step-definitions/auth.steps.ts @@ -929,3 +929,14 @@ Then( }) }, ) + +// --------------------------------------------------------------------------- +// "Use different email" UX +// --------------------------------------------------------------------------- + +Then('the email input is empty and focused', async function (this: EpdsWorld) { + const page = getPage(this) + const input = page.locator('#email') + await expect(input).toHaveValue('', { timeout: 5_000 }) + await expect(input).toBeFocused({ timeout: 5_000 }) +}) diff --git a/features/passwordless-authentication.feature b/features/passwordless-authentication.feature index 20edade8..1492e1a9 100644 --- a/features/passwordless-authentication.feature +++ b/features/passwordless-authentication.feature @@ -384,6 +384,23 @@ Feature: Passwordless authentication via email OTP # programmatically clearing the demo's `oauth_state` cookie just # before the OTP submission, which is equivalent to the cookie # having lapsed by wall-clock. + # The "Use different email" button on the OTP step takes the user + # back to the email-entry form so they can sign in with a different + # address. The form must be EMPTY when they get there — leaving the + # prior email pre-filled is exactly the misleading "looks like the + # form remembered me" UX that the button was meant to escape from, + # and forces the user to manually clear the field before they can + # type their actual email. + @email + Scenario: "Use different email" returns the user to a clean email form + When the demo client initiates an OAuth login + Then the browser is redirected to the auth service login page + And the login page displays an email input form + When the user enters a unique test email and submits + Then the login page shows an OTP verification form + When the user clicks "Use different email" + Then the email input is empty and focused + @email @demo-cookie-expiry @bug-report Scenario: Demo client's OAuth cookie has expired by the time of callback — useful error, not generic auth_failed When the demo client starts a new OAuth flow with random handle mode diff --git a/packages/auth-service/src/routes/login-page.ts b/packages/auth-service/src/routes/login-page.ts index 376b6343..70baa7a0 100644 --- a/packages/auth-service/src/routes/login-page.ts +++ b/packages/auth-service/src/routes/login-page.ts @@ -1087,6 +1087,15 @@ export function renderLoginPage(opts: { if (termsEl) termsEl.style.display = 'block'; clearError(); stopHeartbeat(); + // Reset the email field — the user clicked "Use different + // email" precisely to escape the previous value, so leaving + // it pre-filled both wastes a clearing keystroke and looks + // like the form remembered them when they wanted a fresh + // start. Focus the input so they can start typing + // immediately. + emailInput.value = ''; + currentEmail = ''; + emailInput.focus(); } // Send OTP via better-auth From 68b0760afa923ca2a3f3b4997137eca1a5ca0b77 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Thu, 30 Jul 2026 16:37:59 +0100 Subject: [PATCH 12/84] fix(auth): use truthful unavailable-handle copy --- .changeset/handle-not-available-message.md | 9 +++++ .../handle-availability.steps.ts | 39 +++++++++++++++++++ features/handle-availability.feature | 12 ++++++ .../auth-service/src/routes/choose-handle.ts | 6 +-- 4 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 .changeset/handle-not-available-message.md create mode 100644 e2e/step-definitions/handle-availability.steps.ts create mode 100644 features/handle-availability.feature diff --git a/.changeset/handle-not-available-message.md b/.changeset/handle-not-available-message.md new file mode 100644 index 00000000..6eb0bf43 --- /dev/null +++ b/.changeset/handle-not-available-message.md @@ -0,0 +1,9 @@ +--- +'ePDS': patch +--- + +Handle-picker error wording is now accurate when the handle is reserved. + +**Affects:** End users + +**End users:** if you tried to claim a handle that was on the reserved list (admin, www, …) the picker said "That handle was just taken — please choose another." That's misleading — it wasn't just taken, it's permanently unavailable. The wording is now "That handle is not available — please choose another." which doesn't imply you can wait it out. diff --git a/e2e/step-definitions/handle-availability.steps.ts b/e2e/step-definitions/handle-availability.steps.ts new file mode 100644 index 00000000..e2ff03f5 --- /dev/null +++ b/e2e/step-definitions/handle-availability.steps.ts @@ -0,0 +1,39 @@ +import { Then, When } from '@cucumber/cucumber' +import { expect } from '@playwright/test' +import { testEnv } from '../support/env.js' +import { getPage } from '../support/utils.js' +import type { EpdsWorld } from '../support/world.js' + +When( + 'the handle picker preview reports that {string} is unavailable', + async function (this: EpdsWorld, handle: string) { + const page = getPage(this) + await page.route('**/api/check-handle?*', async (route) => { + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify({ available: false }), + }) + }) + await page.goto(`${testEnv.authUrl}/preview/choose-handle`) + await page.getByLabel('Handle').fill(handle) + }, +) + +Then( + 'the handle picker shows {string}', + async function (this: EpdsWorld, expected: string) { + await expect(getPage(this).locator('#handle-status')).toHaveText( + `✗ ${expected}`, + ) + }, +) + +Then( + 'the handle picker does not show {string}', + async function (this: EpdsWorld, unexpected: string) { + await expect(getPage(this).locator('#handle-status')).not.toContainText( + unexpected, + ) + }, +) diff --git a/features/handle-availability.feature b/features/handle-availability.feature new file mode 100644 index 00000000..333c7ee9 --- /dev/null +++ b/features/handle-availability.feature @@ -0,0 +1,12 @@ +Feature: Handle availability feedback + The picker must not claim that every unavailable handle is already taken, + because policy and validation failures can produce the same unavailable + response. + + Background: + Given the ePDS test environment is running + + Scenario: Generic unavailable response uses truthful copy + When the handle picker preview reports that "alreadyused" is unavailable + Then the handle picker shows "Not available." + And the handle picker does not show "Already taken." diff --git a/packages/auth-service/src/routes/choose-handle.ts b/packages/auth-service/src/routes/choose-handle.ts index 555a912f..e6693e0c 100644 --- a/packages/auth-service/src/routes/choose-handle.ts +++ b/packages/auth-service/src/routes/choose-handle.ts @@ -209,7 +209,7 @@ export function createChooseHandleRouter( } const KNOWN_ERROR_MESSAGES: Record = { - handle_taken: 'That handle was just taken — please choose another.', + handle_taken: 'That handle is not available — please choose another.', } const rawError = req.query.error as string | undefined const error = rawError @@ -380,7 +380,7 @@ export function createChooseHandleRouter( .send( renderChooseHandlePage( handleDomain, - 'That handle is already taken.', + 'That handle is not available.', res.locals.csrfToken, showRandomButton, branding.customCss, @@ -626,7 +626,7 @@ export function renderChooseHandlePage( setStatus('\u2713 Available!', 'available'); } else { isAvailable = false; - setStatus('\u2717 Already taken.', 'taken'); + setStatus('\u2717 Not available.', 'taken'); } updateSubmit(); }) From 6f4fa2dd43b32720154d0b8cc80e272f32568b7b Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Thu, 7 May 2026 10:27:18 +0000 Subject: [PATCH 13/84] fix(shared): malformed client_id no longer leaks into displayed app name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolveClientMetadata returned `{ client_name: clientId }` when the clientId failed to parse as a URL, or used a non-http(s) protocol. The intent was probably "give the caller something to display" — but the upshot was that a malformed query-string client_id ("Sign in to not-a-url") or a schemeless one ("Sign in to javascript:…") showed up verbatim in: - the auth-service login page tag - the consent screen header - the OTP form's "Sign in to <appName>" heading — effectively letting the URL bar shape user-visible text. Return `{}` from those branches instead, so the caller's fallback chain (`client_name || extractDomain(clientId) || 'an application'`) ends in the static "an application" string. Tests: two existing assertions on the leaky behaviour are inverted to assert the fallback path. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- ...allback-app-name-on-malformed-client-id.md | 9 +++++++++ .../src/__tests__/client-metadata.test.ts | 20 +++++++++++++------ packages/shared/src/client-metadata.ts | 16 ++++++++++++--- 3 files changed, 36 insertions(+), 9 deletions(-) create mode 100644 .changeset/fallback-app-name-on-malformed-client-id.md diff --git a/.changeset/fallback-app-name-on-malformed-client-id.md b/.changeset/fallback-app-name-on-malformed-client-id.md new file mode 100644 index 00000000..376284f8 --- /dev/null +++ b/.changeset/fallback-app-name-on-malformed-client-id.md @@ -0,0 +1,9 @@ +--- +'ePDS': patch +--- + +Sign-in pages no longer leak a malformed client_id as the displayed app name. + +**Affects:** End users + +**End users:** if you arrived at a sign-in page with a broken `client_id` parameter on the URL (e.g. from a misconfigured app or a tampered link), the page would display that raw broken value as the app's name, e.g. "Sign in to not-a-url" or "Sign in to my-local-app". The page now falls back to "an application" in that case, so a malformed value in the URL doesn't leak into the visible page text or browser tab title. diff --git a/packages/shared/src/__tests__/client-metadata.test.ts b/packages/shared/src/__tests__/client-metadata.test.ts index 5a971c30..2b5c5d3d 100644 --- a/packages/shared/src/__tests__/client-metadata.test.ts +++ b/packages/shared/src/__tests__/client-metadata.test.ts @@ -23,9 +23,14 @@ beforeEach(() => { }) describe('resolveClientMetadata', () => { - it('returns client_name for non-URL client_id', async () => { + it('returns empty metadata for a non-URL client_id', async () => { + // Non-URL client_ids used to surface the raw value as + // metadata.client_name, which then ended up in page titles + // ("Sign in to not-a-url") on the auth-service login page. + // Returning empty metadata lets the caller's fallback chain + // (client_name || extractDomain → 'an application') win. const metadata = await resolveClientMetadata('my-local-app') - expect(metadata.client_name).toBe('my-local-app') + expect(metadata).toEqual({}) }) it('returns seeded metadata from cache', async () => { @@ -135,10 +140,13 @@ describe('resolveClientName', () => { expect(name).toBe('no-name.app') }) - it('returns client_id as-is for non-URL', async () => { - const name = await resolveClientName('unnamed-client') - expect(name).toBe('unnamed-client') - }) + it.each(['unnamed-client', 'ftp://example.com/client-metadata.json'])( + 'falls back to "an application" for unsupported client_id %s', + async (clientId) => { + const name = await resolveClientName(clientId) + expect(name).toBe('an application') + }, + ) }) describe('getClientCss', () => { diff --git a/packages/shared/src/client-metadata.ts b/packages/shared/src/client-metadata.ts index dae89c97..730dc5ad 100644 --- a/packages/shared/src/client-metadata.ts +++ b/packages/shared/src/client-metadata.ts @@ -168,10 +168,18 @@ export async function resolveClientMetadata( try { parsedUrl = new URL(clientId) } catch { - return { client_name: clientId } + // Malformed clientId — return empty metadata so the caller's + // fallback chain (client_name || extractDomain(clientId) || + // 'an application') doesn't end up surfacing the literal raw + // string ("Sign in to not-a-url") in page titles and copy. + // The caller's extractDomain will also fail; the final + // fallback to "an application" wins. + return {} } if (parsedUrl.protocol !== 'https:' && parsedUrl.protocol !== 'http:') { - return { client_name: clientId } + // Same shape as the unparseable case: don't surface the raw + // schemeless or non-http value to end users. + return {} } if (!options.noCache) { @@ -240,7 +248,9 @@ function fallback(clientId: string, skipCache: boolean): ClientMetadata { function extractDomain(urlStr: string): string | null { try { const url = new URL(urlStr) - return url.hostname + return url.protocol === 'https:' || url.protocol === 'http:' + ? url.hostname + : null } catch { return null } From 797bcd11c67e66569c8343c29b1646e009f5b3ba Mon Sep 17 00:00:00 2001 From: Adam Spiers <atproto@adamspiers.org> Date: Fri, 31 Jul 2026 09:37:28 +0100 Subject: [PATCH 14/84] fix(auth): rename unavailable handle status class The handle picker now reports unavailable results accurately, but its CSS class and demo theme override still used taken terminology. Rename the selector, call site, and related comments so reserved or policy-rejected handles are not described as claimed. Co-authored-by: OpenAI <noreply@openai.com> --- packages/auth-service/src/routes/choose-handle.ts | 12 ++++++------ packages/demo/src/lib/theme.ts | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/auth-service/src/routes/choose-handle.ts b/packages/auth-service/src/routes/choose-handle.ts index e6693e0c..31ced298 100644 --- a/packages/auth-service/src/routes/choose-handle.ts +++ b/packages/auth-service/src/routes/choose-handle.ts @@ -530,7 +530,7 @@ export function renderChooseHandlePage( } .status { min-height: 20px; font-size: 14px; margin-top: 6px; } .status.available { color: #28a745; } - .status.taken { color: #dc3545; } + .status.unavailable { color: #dc3545; } .status.checking { color: #888; } .status.format-error { color: #dc3545; } .error { color: #dc3545; background: #fdf0f0; padding: 12px; border-radius: 8px; margin-bottom: 16px; font-size: 14px; } @@ -587,8 +587,8 @@ export function renderChooseHandlePage( var debounceTimer = null; var currentAbort = null; - // isAvailable: null = unknown, true = confirmed available, false = confirmed taken. - // submitBtn is disabled only when handle is confirmed taken or unavailable. + // isAvailable: null = unknown, true = confirmed available, false = confirmed unavailable. + // submitBtn is disabled only when the handle is confirmed unavailable. var isAvailable = null; function setStatus(text, cls) { @@ -626,14 +626,14 @@ export function renderChooseHandlePage( setStatus('\u2713 Available!', 'available'); } else { isAvailable = false; - setStatus('\u2717 Not available.', 'taken'); + setStatus('\u2717 Not available.', 'unavailable'); } updateSubmit(); }) .catch(function(err) { if (err.name === 'AbortError') return; // silently ignore cancelled requests currentAbort = null; - // Network/timeout error: unknown state — don't block if handle isn't confirmed taken + // Network/timeout error: unknown state — don't block if the handle isn't confirmed unavailable isAvailable = null; setStatus('Could not check availability.', 'format-error'); updateSubmit(); @@ -714,7 +714,7 @@ export function renderChooseHandlePage( setStatus('Could not check availability.', 'format-error'); randomBtn.disabled = false; } else { - // Genuinely taken — retry with a new random value + // Genuinely unavailable — retry with a new random value tryRandomHandle(attemptsLeft - 1); } }) diff --git a/packages/demo/src/lib/theme.ts b/packages/demo/src/lib/theme.ts index 9f12846b..fd8bf339 100644 --- a/packages/demo/src/lib/theme.ts +++ b/packages/demo/src/lib/theme.ts @@ -141,7 +141,7 @@ function buildInjectedCss( `.handle-row { border-color: ${page.inputBorder}; }`, `.handle-suffix { color: ${page.textHint}; background: ${page.inputBg}; border-color: ${page.inputBorder}; }`, '.status.available { color: #4ade80; }', - `.status.taken { color: ${page.errorText}; }`, + `.status.unavailable { color: ${page.errorText}; }`, `.status.checking { color: ${page.textHint}; }`, `.permissions { background: ${page.inputBg}; }`, '.permissions li::before { color: #4ade80; }', From a500a597562b384bb98a9b9966684399a4b843e9 Mon Sep 17 00:00:00 2001 From: Adam Spiers <atproto@adamspiers.org> Date: Fri, 31 Jul 2026 18:00:19 +0100 Subject: [PATCH 15/84] fix(auth-service): make the sign-in flash region actually announce MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding role=status/aria-live to #error-msg was not sufficient on its own — the way showFlash mutated the region defeated it in two ways. The region is display:none until an error fires, and showFlash wrote textContent *before* setting display:block. A hidden element is excluded from the accessibility tree, so the text landed while the live region had no "before" state to diff against; assistive tech could legitimately announce nothing. Unhide first, then mutate. Assigning textContent in place also meant a repeated identical message was not a DOM mutation at all. Re-submitting a bad OTP yields the same "Invalid code" string, so the second failure was silent — precisely the "user thinks the form accepted their input" case this branch set out to fix. Build content off-DOM and swap it in via replaceChildren so every failure is a real mutation. Both concerns are now funnelled through one setFlash() helper, which also fixes the double-announcement in showErrorWithAction and the aborted-flow notice: each appended its action button *after* showFlash had already made the region live, so the message and its button announced as two separate updates. Building the whole content in a fragment makes each of them a single announcement. textContent remains the only sink for caller-supplied strings, so reflected better-auth errors still cannot be interpolated as HTML. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .../auth-service/src/routes/login-page.ts | 111 +++++++++++++----- 1 file changed, 80 insertions(+), 31 deletions(-) diff --git a/packages/auth-service/src/routes/login-page.ts b/packages/auth-service/src/routes/login-page.ts index 2942df44..34f8691a 100644 --- a/packages/auth-service/src/routes/login-page.ts +++ b/packages/auth-service/src/routes/login-page.ts @@ -845,22 +845,26 @@ export function renderLoginPage(opts: { // Render the notice in the existing error banner so the // styling / position is consistent with other errors. The // copy is set via textContent (no HTML), the Start over - // button is built imperatively. + // button is built imperatively. Both go in through setFlash + // so the notice and its button land as one mutation and + // announce as a single message. clearError(); - showFlash( - 'Your sign-in has timed out. The code we sent will no longer work. Start sign-in again from the app you came from.', - 'error', - ); - errorEl.appendChild(document.createElement('br')); - var startOverBtn = document.createElement('button'); - startOverBtn.type = 'button'; - startOverBtn.id = 'btn-start-over'; - startOverBtn.className = 'flash-action'; - startOverBtn.textContent = 'Start over'; - startOverBtn.addEventListener('click', function() { - window.location.href = '/auth/abort'; + setFlash('error', function(frag) { + appendMessage( + frag, + 'Your sign-in has timed out. The code we sent will no longer work. Start sign-in again from the app you came from.', + ); + frag.appendChild(document.createElement('br')); + var startOverBtn = document.createElement('button'); + startOverBtn.type = 'button'; + startOverBtn.id = 'btn-start-over'; + startOverBtn.className = 'flash-action'; + startOverBtn.textContent = 'Start over'; + startOverBtn.addEventListener('click', function() { + window.location.href = '/auth/abort'; + }); + frag.appendChild(startOverBtn); }); - errorEl.appendChild(startOverBtn); } /** @@ -987,16 +991,52 @@ export function renderLoginPage(opts: { box.addEventListener('focus', function() { box.select(); }); }); - function showFlash(msg, kind) { - // Build the message DOM imperatively so showErrorWithAction - // can append an inline action (e.g. Resend) without ever - // interpolating user-influenced strings as HTML. textContent - // is the only sink for the msg argument, which neutralises - // any HTML in the better-auth error string. - errorEl.textContent = msg; + /** + * Swap the flash region's contents in a single mutation. + * + * Two ordering constraints make this fiddlier than it looks: + * + * 1. The region must already be visible before its text changes. + * A display:none element is excluded from the accessibility + * tree, so text written while hidden leaves the live region + * with no "before" state to diff against and assistive tech + * may never announce it. + * 2. The new content must arrive as one mutation. Building it + * off-DOM and appending a fragment means an error plus its + * inline action announce together rather than twice. + * + * Content is always built imperatively with textContent as the + * only sink for caller-supplied strings, so a reflected + * better-auth error can never be interpolated as HTML. + */ + function setFlash(kind, buildContent) { errorEl.classList.remove('error', 'success'); errorEl.classList.add(kind); errorEl.style.display = 'block'; + + var frag = document.createDocumentFragment(); + buildContent(frag); + + // Replace the children wholesale rather than assigning + // textContent in place. Re-submitting a bad OTP yields the + // identical string, which is not a DOM mutation and so would + // announce nothing — leaving the screen-reader user unsure the + // retry was even processed. Swapping nodes is always a + // mutation, so every failure announces. + errorEl.replaceChildren(frag); + } + + /** Append msg to frag as a text-only node. */ + function appendMessage(frag, msg) { + var msgNode = document.createElement('span'); + msgNode.textContent = msg; + frag.appendChild(msgNode); + } + + function showFlash(msg, kind) { + setFlash(kind, function(frag) { + appendMessage(frag, msg); + }); } function showError(msg) { showFlash(msg, 'error'); } @@ -1011,20 +1051,29 @@ export function renderLoginPage(opts: { * absent, behaves like showError. */ function showErrorWithAction(msg, actionLabel, onClick) { - showFlash(msg, 'error'); - if (!actionLabel || typeof onClick !== 'function') return; - errorEl.appendChild(document.createTextNode(' ')); - var btn = document.createElement('button'); - btn.type = 'button'; - btn.className = 'flash-action'; - btn.textContent = actionLabel; - btn.addEventListener('click', onClick); - errorEl.appendChild(btn); + if (!actionLabel || typeof onClick !== 'function') { + showError(msg); + return; + } + setFlash('error', function(frag) { + appendMessage(frag, msg); + frag.appendChild(document.createTextNode(' ')); + var btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'flash-action'; + btn.textContent = actionLabel; + btn.addEventListener('click', onClick); + frag.appendChild(btn); + }); } function clearError() { + // Empty the region before hiding it. Clearing after the + // display:none would mutate a region that is already out of + // the accessibility tree, which some assistive tech reports + // as a stale announcement. + errorEl.replaceChildren(); errorEl.style.display = 'none'; - errorEl.textContent = ''; errorEl.classList.remove('error', 'success'); } From f84486af19d287eaaa0cb7f248a0f5f1880b1f22 Mon Sep 17 00:00:00 2001 From: Adam Spiers <atproto@adamspiers.org> Date: Fri, 31 Jul 2026 18:00:29 +0100 Subject: [PATCH 16/84] fix(auth-service): announce handle-choice errors and availability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The choose-handle page was missed by the earlier commits in this branch. Two separate gaps: The server-rendered error banner used the same <div class="error"> pattern as account-login and recovery, so it gets the same role=alert. Only the populated branch needs it — the empty placeholder is never written to by this page's script. More significantly, #handle-status had no live-region semantics at all. It is the sole feedback channel for whether a handle is free ("Checking…" / "Available!" / "Already taken." / format errors) and it gates the Create button via submitBtn.disabled. Screen-reader users therefore got no availability feedback whatsoever, and hit a button that had been silently disabled with no stated reason. aria-live=polite rather than assertive because setStatus() fires on every debounced keystroke; assertive would interrupt the user mid-type. aria-describedby also associates the status with the input so it is read on focus, not only on change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- packages/auth-service/src/routes/choose-handle.ts | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/auth-service/src/routes/choose-handle.ts b/packages/auth-service/src/routes/choose-handle.ts index 555a912f..398fafe4 100644 --- a/packages/auth-service/src/routes/choose-handle.ts +++ b/packages/auth-service/src/routes/choose-handle.ts @@ -497,8 +497,12 @@ export function renderChooseHandlePage( customFaviconUrl?: string | null, customFaviconUrlDark?: string | null, ): string { + // role=alert only on the populated branch: it is static at render + // time, so the default assertive announcement is what we want. The + // empty placeholder is never written to by this page's script, so + // it needs no live-region semantics. const errorHtml = error - ? `<div class="error" id="error-msg">${escapeHtml(error)}</div>` + ? `<div class="error" id="error-msg" role="alert">${escapeHtml(error)}</div>` : `<div class="error" id="error-msg" style="display:none;"></div>` return `<!DOCTYPE html> @@ -565,10 +569,15 @@ export function renderChooseHandlePage( spellcheck="false" minlength="5" maxlength="20" + aria-describedby="handle-status" > <span class="handle-suffix">.${escapeHtml(handleDomain)}</span> </div> - <div class="status" id="handle-status"></div> + <!-- Availability feedback is rewritten on every debounced + keystroke, so it must announce politely rather than + interrupting the user mid-type. Without this, screen + reader users get no availability feedback at all. --> + <div class="status" id="handle-status" role="status" aria-live="polite"></div> </div> ${showRandomButton ? `<button type="button" id="random-btn" class="btn-secondary">Generate random handle</button>` : ''} <button type="submit" id="submit-btn" class="btn-primary">Create</button> From bd1d0d2c7680e6e95800d72f516180dd3f36beb5 Mon Sep 17 00:00:00 2001 From: Adam Spiers <atproto@adamspiers.org> Date: Fri, 31 Jul 2026 18:00:39 +0100 Subject: [PATCH 17/84] docs(changeset): cover handle choice and repeated failures Widens the existing changeset to match what the branch now does: handle selection is a newly covered surface, and repeated identical failures announcing each time is a user-visible behaviour change worth calling out separately from the initial role/aria additions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .changeset/announce-auth-errors.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.changeset/announce-auth-errors.md b/.changeset/announce-auth-errors.md index f28adc58..b499d6c2 100644 --- a/.changeset/announce-auth-errors.md +++ b/.changeset/announce-auth-errors.md @@ -2,8 +2,8 @@ 'ePDS': patch --- -Sign-in and recovery errors are now announced automatically by screen readers. +Sign-in, recovery and handle-choice feedback is now announced automatically by screen readers. **Affects:** End users -**End users:** authentication errors now use standard alert and live-region semantics across sign-in, recovery, and the demo client, so screen-reader users hear failures without having to search the page for changed text. +**End users:** authentication errors now use standard alert and live-region semantics across sign-in, recovery, handle selection, and the demo client, so screen-reader users hear failures without having to search the page for changed text. Repeated failures — such as entering a second incorrect one-time code — announce each time rather than falling silent, and handle availability ("Available!" / "Already taken.") is now announced as you type. From fe3b15c17b85319e7d7c73b036bafe90ade8c06b Mon Sep 17 00:00:00 2001 From: Adam Spiers <atproto@adamspiers.org> Date: Fri, 31 Jul 2026 20:14:33 +0100 Subject: [PATCH 18/84] feat(auth): suggest corrections for mistyped email domains Without this change, addresses such as gmial.com and hotmal.com proceed directly to code delivery and leave users unable to receive their sign-in code. Add a conservative one-edit domain check for common providers. Both email entry points now pause before sending, offer the corrected address, and still allow the original address explicitly. Behavioral tests cover corrected and original sends. Refs HYPER-545. Co-authored-by: OpenAI Codex <noreply@openai.com> --- .changeset/suggest-mistyped-email-domains.md | 9 + .../email-typo-guard.steps.ts | 193 ++++++++++++++ e2e/support/world.ts | 12 + features/email-typo-guard.feature | 46 ++++ .../src/__tests__/email-typo-guard.test.ts | 36 +++ .../auth-service/src/lib/email-typo-guard.ts | 251 ++++++++++++++++++ .../auth-service/src/routes/account-login.ts | 10 +- .../auth-service/src/routes/login-page.ts | 8 + 8 files changed, 564 insertions(+), 1 deletion(-) create mode 100644 .changeset/suggest-mistyped-email-domains.md create mode 100644 e2e/step-definitions/email-typo-guard.steps.ts create mode 100644 features/email-typo-guard.feature create mode 100644 packages/auth-service/src/__tests__/email-typo-guard.test.ts create mode 100644 packages/auth-service/src/lib/email-typo-guard.ts diff --git a/.changeset/suggest-mistyped-email-domains.md b/.changeset/suggest-mistyped-email-domains.md new file mode 100644 index 00000000..cc872278 --- /dev/null +++ b/.changeset/suggest-mistyped-email-domains.md @@ -0,0 +1,9 @@ +--- +'ePDS': minor +--- + +Catch likely email-domain typos before sending a sign-in code. + +**Affects:** End users + +**End users:** addresses one edit away from Gmail, Hotmail, Outlook, Yahoo, or iCloud now show a “Did you mean…?” prompt as soon as they are typed. Accepting replaces the address in the email field; dismissing keeps the original. The prompt never changes or blocks an address silently. diff --git a/e2e/step-definitions/email-typo-guard.steps.ts b/e2e/step-definitions/email-typo-guard.steps.ts new file mode 100644 index 00000000..eb13fe06 --- /dev/null +++ b/e2e/step-definitions/email-typo-guard.steps.ts @@ -0,0 +1,193 @@ +import { randomBytes } from 'node:crypto' +import { Then, When } from '@cucumber/cucumber' +import { expect, type Request } from '@playwright/test' +import { testEnv } from '../support/env.js' +import { getPage } from '../support/utils.js' +import type { EpdsWorld } from '../support/world.js' + +function isOtpSendRequest(request: Request): boolean { + const pathname = new URL(request.url()).pathname + return ( + pathname.endsWith('/email-otp/send-verification-otp') || + pathname === '/account/send-otp' + ) +} + +function requestEmail(request: Request): string | undefined { + const body = request.postData() + if (!body) return undefined + + try { + const parsed = JSON.parse(body) as { email?: unknown } + if (typeof parsed.email === 'string') return parsed.email.toLowerCase() + } catch { + const email = new URLSearchParams(body).get('email') + if (email) return email.toLowerCase() + } + return undefined +} + +function watchOtpSendRequests(world: EpdsWorld): void { + const page = getPage(world) + world.otpSendRequestCount = 0 + world.lastOtpRequestEmail = undefined + page.on('request', (request) => { + if (!isOtpSendRequest(request)) return + world.otpSendRequestCount = (world.otpSendRequestCount ?? 0) + 1 + world.lastOtpRequestEmail = requestEmail(request) + }) +} + +When( + 'the user enters an email at {string} that should be {string}', + async function (this: EpdsWorld, typoDomain: string, correctDomain: string) { + const localPart = `typo-${Date.now()}-${randomBytes(3).toString('hex')}` + this.emailTypoOriginal = `${localPart}@${typoDomain}` + this.emailTypoSuggestion = `${localPart}@${correctDomain}` + watchOtpSendRequests(this) + + const page = getPage(this) + await page.locator('#email').fill(this.emailTypoOriginal) + }, +) + +Then( + 'the corrected-address suggestion appears before any code request', + async function (this: EpdsWorld) { + if (!this.emailTypoOriginal || !this.emailTypoSuggestion) { + throw new Error('No typo email is available — submit a typo first') + } + + const page = getPage(this) + const prompt = page.locator('.email-typo-suggestion') + await expect(prompt).toBeVisible() + await expect(prompt.locator('[role="status"]')).toHaveText( + `Did you mean ${this.emailTypoSuggestion}?`, + ) + await expect(prompt).not.toContainText(this.emailTypoOriginal) + await expect(prompt.locator('.email-typo-accept')).toHaveText('Yes, fix it') + await expect(prompt.locator('.email-typo-action')).toHaveCount(1) + await expect(prompt.locator('.email-typo-dismiss')).toHaveAccessibleName( + 'Dismiss email correction suggestion', + ) + await expect(page.locator('#email')).toHaveValue(this.emailTypoOriginal) + expect(this.otpSendRequestCount).toBe(0) + }, +) + +Then( + 'the form cannot continue until the user resolves the suggestion', + async function (this: EpdsWorld) { + const page = getPage(this) + const submitButton = page.locator('form:has(#email) button[type="submit"]') + await expect(submitButton).toBeDisabled() + + // The disabled button covers pointer users; the capture-phase guard must + // also stop implicit keyboard submission while the choice is unresolved. + await page.locator('#email').press('Enter') + expect(this.otpSendRequestCount).toBe(0) + await expect(page.locator('.email-typo-suggestion')).toBeVisible() + }, +) + +When( + 'the user accepts the corrected email suggestion', + async function (this: EpdsWorld) { + const page = getPage(this) + await page.locator('.email-typo-accept').click() + }, +) + +When( + 'the user dismisses the email correction suggestion', + async function (this: EpdsWorld) { + const page = getPage(this) + await page.locator('.email-typo-dismiss').click() + }, +) + +Then( + 'the corrected address replaces the email and Continue is re-enabled', + async function (this: EpdsWorld) { + if (!this.emailTypoSuggestion) { + throw new Error('No corrected email is available — submit a typo first') + } + const page = getPage(this) + await expect(page.locator('#email')).toHaveValue(this.emailTypoSuggestion) + await expect(page.locator('.email-typo-suggestion')).toBeHidden() + await expect( + page.locator('form:has(#email) button[type="submit"]'), + ).toBeEnabled() + expect(this.otpSendRequestCount).toBe(0) + }, +) + +Then( + 'the original address remains and Continue is re-enabled', + async function (this: EpdsWorld) { + if (!this.emailTypoOriginal) { + throw new Error('No original email is available — submit a typo first') + } + const page = getPage(this) + await expect(page.locator('#email')).toHaveValue(this.emailTypoOriginal) + await expect(page.locator('.email-typo-suggestion')).toBeHidden() + await expect( + page.locator('form:has(#email) button[type="submit"]'), + ).toBeEnabled() + expect(this.otpSendRequestCount).toBe(0) + }, +) + +When( + 'the user continues from the email form', + async function (this: EpdsWorld) { + const page = getPage(this) + await page.locator('form:has(#email) button[type="submit"]').click() + }, +) + +Then( + 'one code request targets the corrected email address', + async function (this: EpdsWorld) { + if (!this.emailTypoSuggestion) { + throw new Error('No corrected email is available — submit a typo first') + } + await expect.poll(() => this.otpSendRequestCount).toBe(1) + expect(this.lastOtpRequestEmail).toBe( + this.emailTypoSuggestion.toLowerCase(), + ) + }, +) + +Then( + 'one code request targets the original email address', + async function (this: EpdsWorld) { + if (!this.emailTypoOriginal) { + throw new Error('No original email is available — submit a typo first') + } + await expect.poll(() => this.otpSendRequestCount).toBe(1) + expect(this.lastOtpRequestEmail).toBe(this.emailTypoOriginal.toLowerCase()) + }, +) + +Then('the email code form is shown', async function (this: EpdsWorld) { + const page = getPage(this) + const oauthCodeForm = page.locator('#step-otp.active') + const accountCodeForm = page.locator('#otp') + await expect + .poll( + async () => + (await oauthCodeForm.isVisible()) || + (await accountCodeForm.isVisible()), + ) + .toBe(true) +}) + +When( + 'the user opens the account settings email sign-in page', + async function (this: EpdsWorld) { + const page = getPage(this) + await page.goto(`${testEnv.authUrl}/account/login`) + await expect(page.locator('#email')).toBeVisible() + }, +) diff --git a/e2e/support/world.ts b/e2e/support/world.ts index 8350ce1e..41e4c1fe 100644 --- a/e2e/support/world.ts +++ b/e2e/support/world.ts @@ -19,6 +19,18 @@ export class EpdsWorld extends World { /** Generated unique email for the current scenario — set by "unique test email" steps. */ testEmail?: string + /** Mistyped address entered by an email-domain suggestion scenario. */ + emailTypoOriginal?: string + + /** Corrected address offered by the email-domain suggestion. */ + emailTypoSuggestion?: string + + /** Number of code-send requests observed after the typo guard was armed. */ + otpSendRequestCount?: number + + /** Email in the most recent observed code-send request. */ + lastOtpRequestEmail?: string + /** Generated unique backup email for backup-email scenarios — set by "unique backup email" steps. */ backupEmail?: string diff --git a/features/email-typo-guard.feature b/features/email-typo-guard.feature new file mode 100644 index 00000000..4b4d90df --- /dev/null +++ b/features/email-typo-guard.feature @@ -0,0 +1,46 @@ +Feature: Email-domain typo suggestions + A mistyped email domain prevents a user from receiving their sign-in code. + Before sending a code, ePDS should suggest an obvious correction while + still allowing the user to dismiss the suggestion and keep their address. + + Background: + Given the ePDS test environment is running + And the demo OAuth client's metadata is discoverable + + @email @email-typo-guard + Scenario: OAuth login uses the suggested correction + When the demo client initiates an OAuth login via flow 2 + Then the browser is redirected to the auth service login page + When the user enters an email at "gmial.com" that should be "gmail.com" + Then the corrected-address suggestion appears before any code request + And the form cannot continue until the user resolves the suggestion + When the user accepts the corrected email suggestion + Then the corrected address replaces the email and Continue is re-enabled + When the user continues from the email form + Then one code request targets the corrected email address + And the email code form is shown + + @email @email-typo-guard + Scenario: OAuth login can dismiss the suggestion and keep the original address + When the demo client initiates an OAuth login via flow 2 + Then the browser is redirected to the auth service login page + When the user enters an email at "gnail.com" that should be "gmail.com" + Then the corrected-address suggestion appears before any code request + And the form cannot continue until the user resolves the suggestion + When the user dismisses the email correction suggestion + Then the original address remains and Continue is re-enabled + When the user continues from the email form + Then one code request targets the original email address + And the email code form is shown + + @account-settings @email-typo-guard + Scenario: Account settings login uses the suggested correction + When the user opens the account settings email sign-in page + And the user enters an email at "hotmal.com" that should be "hotmail.com" + Then the corrected-address suggestion appears before any code request + And the form cannot continue until the user resolves the suggestion + When the user accepts the corrected email suggestion + Then the corrected address replaces the email and Continue is re-enabled + When the user continues from the email form + Then one code request targets the corrected email address + And the email code form is shown diff --git a/packages/auth-service/src/__tests__/email-typo-guard.test.ts b/packages/auth-service/src/__tests__/email-typo-guard.test.ts new file mode 100644 index 00000000..0a3b81a3 --- /dev/null +++ b/packages/auth-service/src/__tests__/email-typo-guard.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { suggestEmailAddress } from '../lib/email-typo-guard.js' + +describe('suggestEmailAddress', () => { + it.each([ + ['person@gnail.com', 'person@gmail.com'], + ['person@gmial.com', 'person@gmail.com'], + ['person@gmail.coml', 'person@gmail.com'], + ['person@hotmal.com', 'person@hotmail.com'], + ['person@outlok.com', 'person@outlook.com'], + ['person@yaho.com', 'person@yahoo.com'], + ['person@iclod.com', 'person@icloud.com'], + ])('suggests %s as %s', (email, expected) => { + expect(suggestEmailAddress(email)).toBe(expected) + }) + + it('preserves the local part while normalising the suggested domain', () => { + expect(suggestEmailAddress(' First.Last+tag@GMIAL.COM ')).toBe( + 'First.Last+tag@gmail.com', + ) + }) + + it.each([ + 'person@gmail.com', + 'person@GMAIL.COM', + 'person@example.com', + 'person@proton.me', + 'person@gmx.com', + 'not-an-email', + '@gmial.com', + 'person@', + 'person@@gmial.com', + ])('does not guess a correction for %s', (email) => { + expect(suggestEmailAddress(email)).toBeNull() + }) +}) diff --git a/packages/auth-service/src/lib/email-typo-guard.ts b/packages/auth-service/src/lib/email-typo-guard.ts new file mode 100644 index 00000000..1692e0da --- /dev/null +++ b/packages/auth-service/src/lib/email-typo-guard.ts @@ -0,0 +1,251 @@ +const COMMON_EMAIL_DOMAINS = [ + 'gmail.com', + 'hotmail.com', + 'outlook.com', + 'yahoo.com', + 'icloud.com', +] as const + +/** + * Return the optimal-string-alignment distance between two strings. + * + * Unlike plain Levenshtein distance, one adjacent transposition counts as one + * edit, so common mistakes such as `gmial.com` are recognised conservatively. + */ +function editDistance(left: string, right: string): number { + const rows = left.length + 1 + const columns = right.length + 1 + const distance = Array.from({ length: rows }, () => + Array<number>(columns).fill(0), + ) + + for (let row = 0; row < rows; row += 1) distance[row][0] = row + for (let column = 0; column < columns; column += 1) { + distance[0][column] = column + } + + for (let row = 1; row < rows; row += 1) { + for (let column = 1; column < columns; column += 1) { + const substitutionCost = left[row - 1] === right[column - 1] ? 0 : 1 + distance[row][column] = Math.min( + distance[row - 1][column] + 1, + distance[row][column - 1] + 1, + distance[row - 1][column - 1] + substitutionCost, + ) + + if ( + row > 1 && + column > 1 && + left[row - 1] === right[column - 2] && + left[row - 2] === right[column - 1] + ) { + distance[row][column] = Math.min( + distance[row][column], + distance[row - 2][column - 2] + 1, + ) + } + } + } + + return distance[left.length][right.length] +} + +/** + * Suggest a corrected address when its domain is exactly one edit away from a + * common provider. Exact domains and less-certain matches are left untouched. + */ +export function suggestEmailAddress(email: string): string | null { + const trimmed = email.trim() + const at = trimmed.lastIndexOf('@') + if (at <= 0 || at !== trimmed.indexOf('@') || at === trimmed.length - 1) { + return null + } + + const localPart = trimmed.slice(0, at) + const domain = trimmed.slice(at + 1).toLowerCase() + if (COMMON_EMAIL_DOMAINS.some((candidate) => candidate === domain)) { + return null + } + + const suggestion = COMMON_EMAIL_DOMAINS.find( + (candidate) => editDistance(domain, candidate) === 1, + ) + return suggestion ? `${localPart}@${suggestion}` : null +} + +export const EMAIL_TYPO_GUARD_CSS = ` +.email-typo-suggestion { position: relative; margin: -10px 0 20px; padding: 12px 34px 12px 12px; border: 1px solid var(--email-typo-border, #e4c86a); border-radius: 8px; background: var(--email-typo-bg, #fff8dc); color: var(--email-typo-foreground, #4a3b00); font-size: 14px; line-height: 1.5; text-align: left; } +.email-typo-suggestion[hidden] { display: none; } +.email-typo-suggestion p { margin: 0; } +.email-typo-action { border: 1px solid var(--email-typo-action-border, currentColor); border-radius: 6px; padding: 3px 8px; background: var(--email-typo-action-bg, rgba(255, 255, 255, 0.65)); color: inherit; font: inherit; font-size: 13px; font-weight: 600; line-height: 1.3; cursor: pointer; } +.email-typo-action:hover { background: var(--email-typo-action-hover-bg, rgba(255, 255, 255, 0.9)); } +.email-typo-action:focus-visible, .email-typo-dismiss:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; } +.email-typo-accept { display: inline-block; margin-top: 8px; } +.email-typo-dismiss { position: absolute; top: 4px; right: 4px; display: flex; width: 24px; height: 24px; align-items: center; justify-content: center; border: 0; border-radius: 3px; padding: 0; background: transparent; color: inherit; font: inherit; font-size: 18px; line-height: 1; opacity: 0.55; cursor: pointer; } +.email-typo-dismiss:hover { opacity: 0.85; } +.email-typo-suggestion ~ button[type="submit"]:disabled { opacity: 0.55; cursor: not-allowed; } +` + +export function renderEmailTypoGuardMarkup(): string { + return `<div class="email-typo-suggestion" hidden> + <button type="button" class="email-typo-dismiss" aria-label="Dismiss email correction suggestion" title="Dismiss">×</button> + <p role="status" aria-live="polite">Did you mean <strong class="email-typo-correction"></strong>?</p> + <button type="button" class="email-typo-action email-typo-accept">Yes, fix it</button> +</div>` +} + +/** + * Render a dependency-free browser guard for one email form. + * + * Input changes reveal likely corrections immediately. The capture-phase + * submit listener remains as defence in depth, so a typo cannot fire an OTP + * request until the user accepts or dismisses the suggestion. + */ +export function renderEmailTypoGuardScript( + formId: string, + inputId: string, +): string { + return `<script> +(function() { + var form = document.getElementById(${JSON.stringify(formId)}); + var input = document.getElementById(${JSON.stringify(inputId)}); + if (!form || !input) return; + + var commonDomains = ${JSON.stringify(COMMON_EMAIL_DOMAINS)}; + var prompt = form.querySelector('.email-typo-suggestion'); + var correctionText = form.querySelector('.email-typo-correction'); + var acceptButton = form.querySelector('.email-typo-accept'); + var dismissButton = form.querySelector('.email-typo-dismiss'); + var submitButton = form.querySelector('button[type="submit"]'); + if (!prompt || !correctionText || !acceptButton || !dismissButton || !submitButton) return; + + var originalEmail = ''; + var suggestedEmail = ''; + var bypassEmail = null; + var promptActive = false; + + function editDistance(left, right) { + var distance = []; + var row; + var column; + for (row = 0; row <= left.length; row += 1) { + distance[row] = []; + distance[row][0] = row; + } + for (column = 0; column <= right.length; column += 1) { + distance[0][column] = column; + } + for (row = 1; row <= left.length; row += 1) { + for (column = 1; column <= right.length; column += 1) { + var substitutionCost = left.charAt(row - 1) === right.charAt(column - 1) ? 0 : 1; + distance[row][column] = Math.min( + distance[row - 1][column] + 1, + distance[row][column - 1] + 1, + distance[row - 1][column - 1] + substitutionCost + ); + if ( + row > 1 && + column > 1 && + left.charAt(row - 1) === right.charAt(column - 2) && + left.charAt(row - 2) === right.charAt(column - 1) + ) { + distance[row][column] = Math.min( + distance[row][column], + distance[row - 2][column - 2] + 1 + ); + } + } + } + return distance[left.length][right.length]; + } + + function suggest(email) { + var trimmed = email.trim(); + var at = trimmed.lastIndexOf('@'); + if (at <= 0 || at !== trimmed.indexOf('@') || at === trimmed.length - 1) return null; + var localPart = trimmed.slice(0, at); + var domain = trimmed.slice(at + 1).toLowerCase(); + for (var i = 0; i < commonDomains.length; i += 1) { + if (domain === commonDomains[i]) return null; + } + for (var j = 0; j < commonDomains.length; j += 1) { + if (editDistance(domain, commonDomains[j]) === 1) { + return localPart + '@' + commonDomains[j]; + } + } + return null; + } + + function hidePrompt() { + prompt.hidden = true; + if (promptActive) submitButton.disabled = false; + promptActive = false; + correctionText.textContent = ''; + originalEmail = ''; + suggestedEmail = ''; + } + + function showPrompt(email, correction) { + originalEmail = email; + suggestedEmail = correction; + // Reveal the live region before changing its content so assistive + // technology can observe and announce the suggestion mutation. + prompt.hidden = false; + promptActive = true; + submitButton.disabled = true; + correctionText.textContent = suggestedEmail; + } + + function refreshPrompt() { + var email = input.value.trim(); + var correction = suggest(email); + if (correction) { + showPrompt(email, correction); + } else { + hidePrompt(); + } + } + + input.addEventListener('input', function() { + bypassEmail = null; + refreshPrompt(); + }); + + form.addEventListener('submit', function(event) { + var email = input.value.trim(); + if (bypassEmail !== null && email.toLowerCase() === bypassEmail.toLowerCase()) { + bypassEmail = null; + hidePrompt(); + return; + } + + var correction = suggest(email); + if (!correction) { + hidePrompt(); + return; + } + + event.preventDefault(); + event.stopImmediatePropagation(); + showPrompt(email, correction); + acceptButton.focus(); + }, true); + + acceptButton.addEventListener('click', function() { + var correction = suggestedEmail; + input.value = correction; + bypassEmail = correction; + hidePrompt(); + input.focus(); + }); + + dismissButton.addEventListener('click', function() { + bypassEmail = originalEmail; + hidePrompt(); + input.focus(); + }); + + refreshPrompt(); +})(); +</script>` +} diff --git a/packages/auth-service/src/routes/account-login.ts b/packages/auth-service/src/routes/account-login.ts index cfd6d18e..f628585d 100644 --- a/packages/auth-service/src/routes/account-login.ts +++ b/packages/auth-service/src/routes/account-login.ts @@ -20,6 +20,11 @@ import type { AuthServiceContext } from '../context.js' import { buildOtpInputProps } from '../otp-input.js' import type { BetterAuthInstance } from '../better-auth.js' import { POWERED_BY_CSS, POWERED_BY_HTML } from '../lib/page-helpers.js' +import { + EMAIL_TYPO_GUARD_CSS, + renderEmailTypoGuardMarkup, + renderEmailTypoGuardScript, +} from '../lib/email-typo-guard.js' const logger = createLogger('auth:account-login') @@ -152,7 +157,7 @@ function renderLoginForm(opts: { csrfToken: string; error?: string }): string { <h1>Account Settings</h1> <p class="subtitle">Sign in to manage your account</p> ${opts.error ? '<p class="error" role="alert">' + escapeHtml(opts.error) + '</p>' : ''} - <form method="POST" action="/account/send-otp"> + <form id="form-account-send-otp" method="POST" action="/account/send-otp"> <input type="hidden" name="csrf" value="${escapeHtml(opts.csrfToken)}"> <div class="field"> <label for="email">Email address</label> @@ -160,11 +165,13 @@ function renderLoginForm(opts: { csrfToken: string; error?: string }): string { autocomplete="email" placeholder="you@example.com"> </div> + ${renderEmailTypoGuardMarkup()} <button type="submit" class="btn-primary">Continue with email</button> </form> </div> ${POWERED_BY_HTML} </div> + ${renderEmailTypoGuardScript('form-account-send-otp', 'email')} </body> </html>` } @@ -237,6 +244,7 @@ const CSS = ` .field label { display: block; font-size: 14px; font-weight: 500; color: #333; margin-bottom: 6px; } .field input { width: 100%; padding: 10px 12px; border: 1px solid #ddd; border-radius: 8px; font-size: 16px; outline: none; } .field input:focus { border-color: #0f1828; } + ${EMAIL_TYPO_GUARD_CSS} .otp-input { font-size: 28px !important; text-align: center; font-family: 'SF Mono', Menlo, Consolas, monospace !important; padding: 14px !important; } .btn-primary { width: 100%; padding: 12px; background: #0f1828; color: white; border: none; border-radius: 8px; font-size: 16px; font-weight: 500; cursor: pointer; } .btn-primary:hover { background: #1a2a40; } diff --git a/packages/auth-service/src/routes/login-page.ts b/packages/auth-service/src/routes/login-page.ts index c12c6831..5e7bce2b 100644 --- a/packages/auth-service/src/routes/login-page.ts +++ b/packages/auth-service/src/routes/login-page.ts @@ -53,6 +53,11 @@ import { renderFaviconTag, } from '../lib/page-helpers.js' import { renderError } from '../lib/render-error.js' +import { + EMAIL_TYPO_GUARD_CSS, + renderEmailTypoGuardMarkup, + renderEmailTypoGuardScript, +} from '../lib/email-typo-guard.js' import { appendOrphanDeviceCookieClearHeaders, buildPdsAuthorizeRedirect, @@ -609,6 +614,7 @@ export function renderLoginPage(opts: { .field label { display: block; font-size: 16px; line-height: 24px; font-weight: 600; color: #1A130F; margin-bottom: 8px; } .field input { width: 100%; padding: 14px 20px; border: 1px solid var(--input-border); border-radius: 8px; font-size: 16px; outline: none; background: var(--input-bg); transition: border-color 0.15s; } .field input:focus { border-color: var(--focus-border); } + ${EMAIL_TYPO_GUARD_CSS} .otp-boxes { display: flex; gap: 10px; justify-content: center; margin-bottom: 24px; } .otp-box { width: 48px; height: 56px; padding: 0; text-align: center; font-size: 24px; font-family: 'SF Mono', Menlo, Consolas, monospace; border: 1px solid var(--input-border); border-radius: 8px; background: var(--input-bg); color: #1A130F; outline: none; transition: border-color 0.15s; } .otp-box::placeholder { color: #d4d4d4; } @@ -670,6 +676,7 @@ export function renderLoginPage(opts: { placeholder="you@example.com" value="${escapeHtml(opts.loginHint)}"> </div> + ${renderEmailTypoGuardMarkup()} <button type="submit" class="btn-primary">Continue</button> </form> ${handleLoginButtonHtml} @@ -716,6 +723,7 @@ export function renderLoginPage(opts: { </a> </div> + ${renderEmailTypoGuardScript('form-send-otp', 'email')} <script> (function() { var authBasePath = ${JSON.stringify(opts.authBasePath)}; From 87ef8320a169e970474747ca0e9d65b26a3984da Mon Sep 17 00:00:00 2001 From: Adam Spiers <atproto@adamspiers.org> Date: Mon, 3 Aug 2026 20:18:31 +0100 Subject: [PATCH 19/84] fix(auth): darken muted sign-in text to meet WCAG AA MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --muted-foreground was #999, which carries the 13px terms line, the "Recover with backup email" link, and the "powered by Certified" footer. Measured against the two backgrounds those actually sit on: #999 on #F8F8F8 (card) 2.68:1 #999 on #E8E8E8 (page) 2.33:1 Both are far below the 4.5:1 WCAG AA minimum for normal-size text, and below even the 3.0:1 large-text threshold that does not apply at 13px anyway. Use #666 (5.41:1 and 4.69:1). #6b6b6b was the value originally proposed alongside the spam-folder hint in #221, but it clears AA only on the card: .powered-by lives in .page-wrap outside .container, so it renders on the page background where #6b6b6b reaches just 4.35:1 and still fails. #666 passes on both, and is a conventional shorthand. Extracted from #221, where it rode along as an undocumented global change. The hint that motivated it is gone — superseded by the resend-time message — but the contrast defect predates that PR and stands on its own. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --- .changeset/muted-text-contrast-on-sign-in.md | 11 +++++++++++ packages/auth-service/src/routes/login-page.ts | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 .changeset/muted-text-contrast-on-sign-in.md diff --git a/.changeset/muted-text-contrast-on-sign-in.md b/.changeset/muted-text-contrast-on-sign-in.md new file mode 100644 index 00000000..a645d4f7 --- /dev/null +++ b/.changeset/muted-text-contrast-on-sign-in.md @@ -0,0 +1,11 @@ +--- +'ePDS': patch +--- + +Small grey text on the sign-in page is darker and easier to read. + +**Affects:** End users, Operators + +**End users:** the 13px grey text — the terms line, the "Recover with backup email" link, and the "powered by Certified" footer — was too light to meet the WCAG AA contrast minimum against either the card or the page background behind it. It is now dark enough to pass on both. + +**Operators:** the `--muted-foreground` custom property on the sign-in page changes from `#999` to `#666`. If your `branding.css` overrides it, check your value clears 4.5:1 against both `#F8F8F8` (the card) and `#E8E8E8` (the page); the old default reached only 2.68:1 and 2.33:1 respectively. diff --git a/packages/auth-service/src/routes/login-page.ts b/packages/auth-service/src/routes/login-page.ts index 5e7bce2b..6cc4cd2e 100644 --- a/packages/auth-service/src/routes/login-page.ts +++ b/packages/auth-service/src/routes/login-page.ts @@ -602,7 +602,7 @@ export function renderLoginPage(opts: { ${renderFaviconTag(opts.customFaviconUrl, opts.customFaviconUrlDark)} <title>Sign in to ${escapeHtml(appName)} ${renderOptionalStyleTag(opts.customCss)} diff --git a/packages/auth-service/src/routes/recovery.ts b/packages/auth-service/src/routes/recovery.ts index 6d3e235b..70504f41 100644 --- a/packages/auth-service/src/routes/recovery.ts +++ b/packages/auth-service/src/routes/recovery.ts @@ -514,7 +514,11 @@ const CSS = ` .btn-primary { width: 100%; padding: 12px; background: #0f1828; color: white; border: none; border-radius: 8px; font-size: 16px; font-weight: 500; cursor: pointer; } .btn-primary:hover { background: #1a2a40; } .btn-primary:focus-visible { outline: 2px solid #0f1828; outline-offset: 2px; } - .btn-secondary { display: inline-block; margin-top: 12px; color: #0f1828; background: none; border: none; font-size: 14px; cursor: pointer; text-decoration: underline; border-radius: 4px; } + /* Standalone action in its own row — see the link-affordance convention + documented in login-page.ts. No underline, darkens on hover. "Resend + code" here and on the sign-in page must not render differently. */ + .btn-secondary { display: inline-block; margin-top: 12px; color: #0f1828; background: none; border: none; font-size: 14px; cursor: pointer; text-decoration: none; border-radius: 4px; } + .btn-secondary:hover { color: #000; } .btn-secondary:focus-visible { outline: 2px solid #0f1828; outline-offset: 2px; } .error { color: #dc3545; background: #fdf0f0; padding: 12px; border-radius: 8px; margin: 12px 0; } ` diff --git a/packages/demo/src/lib/theme.ts b/packages/demo/src/lib/theme.ts index fd8bf339..1d1f2edb 100644 --- a/packages/demo/src/lib/theme.ts +++ b/packages/demo/src/lib/theme.ts @@ -128,7 +128,11 @@ function buildInjectedCss( `.otp-box:focus { border-color: ${page.focusBorder} !important; }`, `.btn-primary { background: ${page.primary}; color: ${page.primaryText}; }`, `.btn-primary:hover { background: ${page.primaryHover}; }`, - `.btn-secondary { color: ${page.textMuted}; }`, + // Standalone actions under Verify (.btn-secondary buttons and the + // .recovery-link anchor) are one visual class, so they must theme + // identically — see the link-affordance convention in login-page.ts. + `.btn-secondary, .recovery-link { color: ${page.textMuted}; }`, + `.btn-secondary:hover, .recovery-link:hover { color: ${page.text}; }`, `.btn-social { background: ${page.inputBg}; border-color: ${page.inputBorder}; color: ${page.text}; }`, `.btn-atproto { background: ${page.inputBg} !important; border-color: ${page.inputBorder} !important; color: ${page.text} !important; }`, `.btn-social:hover { background: ${page.inputBorder}; }`, @@ -136,8 +140,6 @@ function buildInjectedCss( `.divider::before, .divider::after { background: ${page.inputBorder}; }`, `.error { background: ${page.errorBg}; color: ${page.errorText}; }`, `.flash-msg.error { background: ${page.errorBg}; color: ${page.errorText}; }`, - `.recovery-link { color: ${page.textHint}; }`, - `.recovery-link:hover { color: ${page.textMuted}; }`, `.handle-row { border-color: ${page.inputBorder}; }`, `.handle-suffix { color: ${page.textHint}; background: ${page.inputBg}; border-color: ${page.inputBorder}; }`, '.status.available { color: #4ade80; }', From ddde1b22f89b45dfd90a38a6f34915dce9b013d3 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 4 Aug 2026 20:42:43 +0100 Subject: [PATCH 39/84] fix(auth): write sign-in errors for users, not developers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rejected code rendered "Invalid OTP Send a new code" — better-auth's own wording pushed straight to the user, running into the recovery link beside it with only a space between them. "Invalid OTP" is a developer string: an unexplained acronym, no article, no indication of what to do next. It is also the single line a user actually reads when sign-in fails. Map the three known reasons to plain copy, and supply a full stop before the inline action when the message does not already end in one. Unrecognised errors pass through verbatim rather than collapsing into a generic apology: an unexpected failure that still names itself can be diagnosed from a screenshot, one that says "Something went wrong" cannot. verifyOtp() now returns the raw reason alongside the display text, and the expired-code branch tests the raw one. Matching the rewritten copy would have worked by luck today — "That code has expired." happens to contain "expir" — but would couple control flow to wording, so a later copy edit could silently change which recovery action is offered. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/human-sign-in-error-copy.md | 9 ++++ .../src/__tests__/login-page.test.ts | 37 ++++++++++++++++ .../auth-service/src/routes/login-page.ts | 43 +++++++++++++++++-- 3 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 .changeset/human-sign-in-error-copy.md diff --git a/.changeset/human-sign-in-error-copy.md b/.changeset/human-sign-in-error-copy.md new file mode 100644 index 00000000..7d27cc54 --- /dev/null +++ b/.changeset/human-sign-in-error-copy.md @@ -0,0 +1,9 @@ +--- +'ePDS': patch +--- + +Sign-in errors are written in plain English instead of showing the raw failure code. + +**Affects:** End users + +**End users:** a rejected code used to report "Invalid OTP", an unexplained acronym that ran straight into the recovery link beside it — "Invalid OTP Send a new code". It now reads "That code didn't work." followed by the link, and the two other common failures are similarly rewritten: an aged-out code says "That code has expired.", and one rejected after repeated wrong attempts says "Too many tries — that code is no longer usable.". Any failure outside those three is still shown as-is rather than hidden behind a generic apology, so an unexpected problem can still be reported accurately. diff --git a/packages/auth-service/src/__tests__/login-page.test.ts b/packages/auth-service/src/__tests__/login-page.test.ts index f7279d65..01a8784c 100644 --- a/packages/auth-service/src/__tests__/login-page.test.ts +++ b/packages/auth-service/src/__tests__/login-page.test.ts @@ -524,6 +524,43 @@ function renderDefault(overrides: Partial = {}): string { }) } +describe('renderLoginPage sign-in error copy', () => { + it.each([ + ['Invalid OTP', "That code didn't work."], + ['OTP expired', 'That code has expired.'], + ['Too many attempts', 'Too many tries — that code is no longer usable.'], + ])('rewrites better-auth %s as end-user copy', (raw, display) => { + const html = renderDefault() + expect(html).toContain(`case '${raw}':`) + expect(html).toContain(display) + }) + + it('passes an unrecognised error through verbatim', () => { + // A failure that names itself can be diagnosed from a screenshot; + // one collapsed into a generic apology cannot. + const html = renderDefault() + expect(html).toMatch(/default:\s*return raw;/) + }) + + it('keys the expired branch off the raw reason, not the display copy', () => { + // otpErrorText() owns the wording. If the branch matched the + // rewritten string instead, editing that copy could silently + // reroute which recovery action the user is offered. + const html = renderDefault() + expect(html).toContain('test(result.rawError || result.error)') + expect(html).toContain('rawError: raw') + expect(html).not.toMatch( + /isExpired = \/expir\|too long\/i\.test\(result\.error\)/, + ) + }) + + it('separates the message from its inline action', () => { + // Without this the banner reads "Invalid OTP Send a new code". + const html = renderDefault() + expect(html).toMatch(/\/\[\.!\?\]\$\/\.test\(msg\) \? ' ' : '\. '/) + }) +}) + describe('renderLoginPage OTP verify-form double-submit latch (regression)', () => { it('declares the verifying flag at IIFE scope so input/paste/submit handlers share it', () => { const html = renderDefault() diff --git a/packages/auth-service/src/routes/login-page.ts b/packages/auth-service/src/routes/login-page.ts index f68c5456..5bfb41ba 100644 --- a/packages/auth-service/src/routes/login-page.ts +++ b/packages/auth-service/src/routes/login-page.ts @@ -1113,6 +1113,29 @@ export function renderLoginPage(opts: { * handler runs the supplied callback. When actionLabel is * absent, behaves like showError. */ + /** + * Rewrite better-auth's verification errors as end-user copy. + * + * better-auth returns developer-facing strings — "Invalid OTP" is + * an unexplained acronym with no article, and none of them say + * what to do next. The rendered text is the one thing a user + * actually reads when sign-in fails, so it is worth owning. + * + * Anything unrecognised passes through verbatim rather than + * collapsing into a generic apology: an unexpected failure that + * still names itself can be diagnosed from a screenshot, and one + * that says "Something went wrong" cannot. + */ + function otpErrorText(raw) { + switch (raw) { + case 'Invalid OTP': return "That code didn't work."; + case 'OTP expired': return 'That code has expired.'; + case 'Too many attempts': + return 'Too many tries — that code is no longer usable.'; + default: return raw; + } + } + function showErrorWithAction(msg, actionLabel, onClick) { if (!actionLabel || typeof onClick !== 'function') { showError(msg); @@ -1120,7 +1143,13 @@ export function renderLoginPage(opts: { } setFlash('error', function(frag) { appendMessage(frag, msg); - frag.appendChild(document.createTextNode(' ')); + // Separate the sentence from the action. Mapped copy already + // ends in a full stop, but a passed-through better-auth string + // may not, so supply one rather than letting the two run + // together as "Invalid OTP Send a new code". + frag.appendChild( + document.createTextNode(/[.!?]$/.test(msg) ? ' ' : '. '), + ); var btn = document.createElement('button'); btn.type = 'button'; btn.className = 'flash-action'; @@ -1259,7 +1288,12 @@ export function renderLoginPage(opts: { }); if (!res.ok) { var data = await res.json().catch(function() { return {}; }); - return { error: data.message || data.error || 'Invalid code' }; + var raw = data.message || data.error || 'Invalid code'; + // Keep the raw reason alongside the display text: callers + // branch on it (see isExpired below), and branching on the + // rewritten copy would couple control flow to wording, so + // an innocuous copy edit could silently change behaviour. + return { error: otpErrorText(raw), rawError: raw }; } // Success: redirect to /auth/complete to complete the AT Protocol flow window.location.href = '/auth/complete'; @@ -1339,7 +1373,10 @@ export function renderLoginPage(opts: { // match catches the better-auth wording ("Invalid or // expired code") and the auth-service wording ("OTP // expired") plus generic "expir"/"too long" variants. - var isExpired = /expir|too long/i.test(result.error); + // Test the raw better-auth reason, not the rewritten copy: + // otpErrorText() owns the wording, and matching against it + // would mean a copy edit could silently reroute the branch. + var isExpired = /expir|too long/i.test(result.rawError || result.error); if (isExpired) { // Only offer "Send a new code" when the PAR is still // alive. If it isn't, a fresh OTP would issue but From bfd481d053e160d8bb4256b4bbf505c7eb0bc674 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 4 Aug 2026 20:52:10 +0100 Subject: [PATCH 40/84] test(e2e): assert the sign-in error copy users actually see MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The expiry scenario asserted the banner contained "OTP expired" — better-auth's raw string, which the previous commit stopped rendering. CI caught it: 74 scenarios passed, that one failed with Expected substring: "OTP expired" unexpected value "That code has expired. Send a new code" The unit tests missed it because they assert against the rendered page source, not against what a browser displays, so nothing local covered the gap between the two. Assert the user-facing string instead. Loosening the assertion to keep matching the old wording would have been worse: the scenario exists to prove the user is told something useful, and better-auth's wording is now an implementation detail behind otpErrorText(). The step pattern becomes a regex accepting "an" or "the" so the feature line reads as English with a message that starts with "That". Co-Authored-By: Claude Opus 5 (1M context) --- e2e/step-definitions/auth.steps.ts | 2 +- features/passwordless-authentication.feature | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/e2e/step-definitions/auth.steps.ts b/e2e/step-definitions/auth.steps.ts index 95722734..71abc40f 100644 --- a/e2e/step-definitions/auth.steps.ts +++ b/e2e/step-definitions/auth.steps.ts @@ -611,7 +611,7 @@ Then( ) Then( - 'the verification form shows an {string} error', + /^the verification form shows (?:an|the) "([^"]*)" error$/, async function (this: EpdsWorld, expected: string) { const page = getPage(this) await expect(page.locator('#error-msg')).toBeVisible({ timeout: 10_000 }) diff --git a/features/passwordless-authentication.feature b/features/passwordless-authentication.feature index fd1c9139..09858b62 100644 --- a/features/passwordless-authentication.feature +++ b/features/passwordless-authentication.feature @@ -250,7 +250,7 @@ Feature: Passwordless authentication via email OTP # better-auth verification row only. We deliberately leave the # auth_flow row + cookie alive to mirror reality at the 10-minute mark. # After the OTP has been aged past expiry, submitting it must fail with - # the helpful "OTP expired" message; resending must produce a fresh + # the "That code has expired." message; resending must produce a fresh # code that completes the flow normally. # @email @otp-expiry @@ -263,7 +263,7 @@ Feature: Passwordless authentication via email OTP And the login page shows an OTP verification form When more than 10 minutes pass before the user enters the OTP And the user enters the OTP code - Then the verification form shows an "OTP expired" error + Then the verification form shows the "That code has expired." error And the OTP entry boxes are visible and enabled When the user enters two digits from the old OTP And the user requests a new OTP via the resend button From 02546198946d3aa93244a459439d86ee93b123b8 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 4 Aug 2026 22:22:10 +0100 Subject: [PATCH 41/84] docs(skill): name real audience labels, and satisfy prettier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fixes to the capitalization rule added in d897e61. The rule referred to a literal `**Audience:**` label, which does not exist — the real labels are `**End users:**`, `**Client app developers:**` and `**Operators:**`. A reader could reasonably have taken `**Audience:**` for a valid label. Name two real ones instead. The paragraph also carried a blank line before its bullet list, which `prettier --check` rejected and which is inconsistent with the two other nested bullet lists in this file (neither has one). Raised by Copilot in review of #235. Co-Authored-By: Claude Opus 5 (1M context) --- .agents/skills/writing-changesets/SKILL.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.agents/skills/writing-changesets/SKILL.md b/.agents/skills/writing-changesets/SKILL.md index ede543c5..c7444cb5 100644 --- a/.agents/skills/writing-changesets/SKILL.md +++ b/.agents/skills/writing-changesets/SKILL.md @@ -180,14 +180,14 @@ Every non-trivial changeset **must** contain, in this order: the plain-language rule only applies to the summary line above. **Capitalize the first word after the bold audience label.** - The `**Audience:**` label is a lead-in, but the text following - it is a full sentence and must start with a capital letter — the - colon does not make the following word mid-sentence. This applies - even when the first word is an identifier that is lowercase in - code: capitalize the surrounding prose, or reword so the - identifier isn't sentence-initial (e.g. start with "The - `email_verified` claim …", not "`email_verified` is now …"). - + A label such as `**End users:**` or `**Operators:**` is a + lead-in, but the text following it is a full sentence and must + start with a capital letter — the colon does not make the + following word mid-sentence. This applies even when the first + word is an identifier that is lowercase in code: capitalize the + surrounding prose, or reword so the identifier isn't + sentence-initial (e.g. start with "The `email_verified` claim + …", not "`email_verified` is now …"). - ✅ **End users:** Apps no longer ask you to verify an address you have already confirmed. - ✅ **Client app developers:** The `email_verified` claim is now `true` … - ✅ **Operators:** Deploy the auth service and the PDS together … From d4256ea03a0982386662c3a9adb1ff7d9f347a38 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Thu, 26 Mar 2026 14:19:46 -0700 Subject: [PATCH 42/84] security: require secret env vars at startup, remove dev fallbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hardcoded development secret fallbacks with a requireEnv() helper that throws immediately if AUTH_SESSION_SECRET or EPDS_CALLBACK_SECRET are missing. This prevents services from silently running with well-known secrets in production. Also removes the unused csrfSecret config field and parameter — the CSRF middleware already uses crypto.randomBytes and ignores it. Closes #37 --- .changeset/require-secret-env-vars.md | 11 +++++ .env.example | 3 +- docs/configuration.md | 3 +- packages/auth-service/.env.example | 5 ++- .../auth-service/src/__tests__/csrf.test.ts | 2 +- .../__tests__/login-page-prompt-login.test.ts | 3 +- packages/auth-service/src/context.ts | 1 - packages/auth-service/src/index.ts | 11 ++--- packages/auth-service/src/middleware/csrf.ts | 2 +- packages/pds-core/.env.example | 1 + packages/pds-core/src/index.ts | 4 +- .../shared/src/__tests__/require-env.test.ts | 42 +++++++++++++++++++ packages/shared/src/__tests__/types.test.ts | 1 - packages/shared/src/index.ts | 1 + packages/shared/src/require-env.ts | 20 +++++++++ packages/shared/src/types.ts | 1 - scripts/setup.sh | 4 +- 17 files changed, 92 insertions(+), 23 deletions(-) create mode 100644 .changeset/require-secret-env-vars.md create mode 100644 packages/shared/src/__tests__/require-env.test.ts create mode 100644 packages/shared/src/require-env.ts diff --git a/.changeset/require-secret-env-vars.md b/.changeset/require-secret-env-vars.md new file mode 100644 index 00000000..c437bde3 --- /dev/null +++ b/.changeset/require-secret-env-vars.md @@ -0,0 +1,11 @@ +--- +'ePDS': patch +--- + +Both services now refuse to start unless their secret environment variables are set, instead of silently falling back to well-known development defaults. + +**Affects:** Operators + +**Operators:** `AUTH_SESSION_SECRET` and `EPDS_CALLBACK_SECRET` are now mandatory. Previously, a deployment that omitted them would boot successfully using hardcoded placeholders such as `dev-session-secret-change-me`, leaving session cookies and the signed `/oauth/epds-callback` redirect verifiable by anyone who has read the source. The auth service and PDS Core now throw at startup with a message naming the missing variable and the `openssl rand -hex 32` command to generate one. Check that both variables are set in your deployment before upgrading, and note that `EPDS_CALLBACK_SECRET` must be identical across the two services. + +`AUTH_CSRF_SECRET` has been removed entirely. CSRF protection derives its tokens from `crypto.randomBytes` and never consumed the value, so the variable was inert; it is no longer read, no longer generated by `scripts/setup.sh`, and can be deleted from your environment. diff --git a/.env.example b/.env.example index b25b42a7..ccfdea00 100644 --- a/.env.example +++ b/.env.example @@ -49,6 +49,7 @@ PDS_HOSTNAME=pds.example PDS_PUBLIC_URL=https://pds.example # HMAC secret for signing /oauth/epds-callback redirects. +# REQUIRED — both services refuse to start without it. # Generate with: openssl rand -hex 32 EPDS_CALLBACK_SECRET= @@ -158,9 +159,9 @@ EPDS_INVITE_CODE= AUTH_HOSTNAME=auth.pds.example AUTH_PORT=3001 +# REQUIRED — auth service refuses to start without it. # Generate with: openssl rand -hex 32 AUTH_SESSION_SECRET= -AUTH_CSRF_SECRET= EPDS_LINK_EXPIRY_MINUTES=10 EPDS_LINK_BASE_URL=https://auth.pds.example/auth/verify diff --git a/docs/configuration.md b/docs/configuration.md index 642ec3ac..cbf7817d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -126,8 +126,7 @@ Optional PDS email variables: | --------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `AUTH_HOSTNAME` | Auth subdomain (e.g. `auth.pds.example.com`) — must be a subdomain of `PDS_HOSTNAME` | | `AUTH_PORT` | Port for Auth Service. Resolved as `AUTH_PORT` → `PORT` → `3001`, so platforms that inject `PORT` (e.g. Railway) work without a service-specific override. | -| `AUTH_SESSION_SECRET` | Session secret — generate with `openssl rand -hex 32` | -| `AUTH_CSRF_SECRET` | CSRF secret — generate with `openssl rand -hex 32` | +| `AUTH_SESSION_SECRET` | **Required.** Session secret — generate with `openssl rand -hex 32`. Auth service refuses to start if unset. | | `PDS_INTERNAL_URL` | **Required.** Internal URL for auth→PDS calls. Docker: `http://core:3000`; Railway: `http://.railway.internal:3000`; local dev: `http://localhost:3000`. Auth service crashes at startup if unset. | ### Verification link settings diff --git a/packages/auth-service/.env.example b/packages/auth-service/.env.example index caa0d728..9a91fdac 100644 --- a/packages/auth-service/.env.example +++ b/packages/auth-service/.env.example @@ -26,6 +26,7 @@ PDS_HOSTNAME=pds.example PDS_PUBLIC_URL=https://pds.example # [shared] HMAC secret for signing /oauth/epds-callback redirects. +# REQUIRED — both services refuse to start without it. # MUST match pds-core. Generate with: openssl rand -hex 32 EPDS_CALLBACK_SECRET= @@ -79,9 +80,9 @@ AUTH_HOSTNAME=auth.pds.example PORT=3001 AUTH_PORT=3001 -# Auth service secrets (generate with: openssl rand -hex 32) +# Auth service session secret (REQUIRED — service refuses to start without it) +# Generate with: openssl rand -hex 32 AUTH_SESSION_SECRET= -AUTH_CSRF_SECRET= # Internal URL for reaching pds-core (REQUIRED — auth service crashes without it). # Docker: http://core:3000 diff --git a/packages/auth-service/src/__tests__/csrf.test.ts b/packages/auth-service/src/__tests__/csrf.test.ts index 86d7b61a..c321c378 100644 --- a/packages/auth-service/src/__tests__/csrf.test.ts +++ b/packages/auth-service/src/__tests__/csrf.test.ts @@ -46,7 +46,7 @@ function makeRes() { } describe('csrfProtection middleware', () => { - const middleware = csrfProtection('test-secret') + const middleware = csrfProtection() describe('GET requests', () => { it('sets a CSRF cookie when none exists', () => { diff --git a/packages/auth-service/src/__tests__/login-page-prompt-login.test.ts b/packages/auth-service/src/__tests__/login-page-prompt-login.test.ts index 28561c03..6725c4db 100644 --- a/packages/auth-service/src/__tests__/login-page-prompt-login.test.ts +++ b/packages/auth-service/src/__tests__/login-page-prompt-login.test.ts @@ -58,7 +58,6 @@ function makeConfig(dbPath: string): AuthServiceConfig { hostname: 'auth.test.local', port: 0, sessionSecret: 'test-session-secret', - csrfSecret: 'test-csrf-secret', epdsCallbackSecret: 'test-callback-secret', pdsHostname: 'test.local', pdsPublicUrl: 'https://test.local', @@ -88,7 +87,7 @@ async function startApp(ctx: AuthServiceContext): Promise<{ // signal clean. app.disable('x-powered-by') app.use(cookieParser()) - app.use(csrfProtection(ctx.config.csrfSecret)) + app.use(csrfProtection()) app.use(createLoginPageRouter(ctx)) const server = app.listen(0) await new Promise((resolve, reject) => { diff --git a/packages/auth-service/src/context.ts b/packages/auth-service/src/context.ts index a31014fa..59c5c5bf 100644 --- a/packages/auth-service/src/context.ts +++ b/packages/auth-service/src/context.ts @@ -5,7 +5,6 @@ export interface AuthServiceConfig { hostname: string port: number sessionSecret: string - csrfSecret: string /** Shared HMAC-SHA256 secret for signing epds-callback redirect URLs. */ epdsCallbackSecret: string pdsHostname: string diff --git a/packages/auth-service/src/index.ts b/packages/auth-service/src/index.ts index 12a3c6cf..4b4766b9 100644 --- a/packages/auth-service/src/index.ts +++ b/packages/auth-service/src/index.ts @@ -1,4 +1,4 @@ -import { createLogger, getEpdsVersion } from '@certified-app/shared' +import { createLogger, getEpdsVersion, requireEnv } from '@certified-app/shared' import express from 'express' import cookieParser from 'cookie-parser' import * as path from 'node:path' @@ -94,7 +94,7 @@ export function createAuthService(config: AuthServiceConfig): { app.use(createTestHooksRouter(config.dbLocation)) } - app.use(csrfProtection(config.csrfSecret)) + app.use(csrfProtection()) app.use(requestRateLimit({ windowMs: 60_000, maxRequests: 60 })) // Security headers (X-Frame-Options, CSP, HSTS, etc.). The CSP's @@ -163,11 +163,8 @@ async function main() { const config: AuthServiceConfig = { hostname: process.env.AUTH_HOSTNAME || 'auth.localhost', port: resolveAuthPort(), - sessionSecret: - process.env.AUTH_SESSION_SECRET || 'dev-session-secret-change-me', - csrfSecret: process.env.AUTH_CSRF_SECRET || 'dev-csrf-secret-change-me', - epdsCallbackSecret: - process.env.EPDS_CALLBACK_SECRET || 'dev-callback-secret-change-me', + sessionSecret: requireEnv('AUTH_SESSION_SECRET'), + epdsCallbackSecret: requireEnv('EPDS_CALLBACK_SECRET'), pdsHostname: process.env.PDS_HOSTNAME || 'localhost', pdsPublicUrl: process.env.PDS_PUBLIC_URL || 'http://localhost:3000', email: { diff --git a/packages/auth-service/src/middleware/csrf.ts b/packages/auth-service/src/middleware/csrf.ts index 2eae05af..3f3f1c23 100644 --- a/packages/auth-service/src/middleware/csrf.ts +++ b/packages/auth-service/src/middleware/csrf.ts @@ -4,7 +4,7 @@ import * as crypto from 'node:crypto' const CSRF_COOKIE = 'epds_csrf' const CSRF_HEADER = 'x-csrf-token' -export function csrfProtection(_secret: string) { +export function csrfProtection() { return (req: Request, res: Response, next: NextFunction): void => { // GET requests: set CSRF cookie if not present if (req.method === 'GET') { diff --git a/packages/pds-core/.env.example b/packages/pds-core/.env.example index 944286af..d56a521d 100644 --- a/packages/pds-core/.env.example +++ b/packages/pds-core/.env.example @@ -24,6 +24,7 @@ PDS_HOSTNAME=pds.example PDS_PUBLIC_URL=https://pds.example # [shared] HMAC secret for signing /oauth/epds-callback redirects. +# REQUIRED — both services refuse to start without it. # MUST match auth-service. Generate with: openssl rand -hex 32 EPDS_CALLBACK_SECRET= diff --git a/packages/pds-core/src/index.ts b/packages/pds-core/src/index.ts index bdbe8c71..48ea356a 100644 --- a/packages/pds-core/src/index.ts +++ b/packages/pds-core/src/index.ts @@ -44,6 +44,7 @@ import { getEpdsVersion, renderError, validateClientMetadataForPreview, + requireEnv, } from '@certified-app/shared' import { shouldRewriteSecFetchSite } from './lib/sec-fetch-site-rewrite.js' import { @@ -168,8 +169,7 @@ async function main() { // Called by the auth service after OTP verification + user consent. // Steps: load device -> resolve account -> issue code -> redirect to client - const epdsCallbackSecret = - process.env.EPDS_CALLBACK_SECRET || 'dev-callback-secret-change-me' + const epdsCallbackSecret = requireEnv('EPDS_CALLBACK_SECRET') // When true, consent may be skipped on initial sign-up for trusted clients // that request it via epds_skip_consent_on_signup in their metadata. diff --git a/packages/shared/src/__tests__/require-env.test.ts b/packages/shared/src/__tests__/require-env.test.ts new file mode 100644 index 00000000..d607dd1d --- /dev/null +++ b/packages/shared/src/__tests__/require-env.test.ts @@ -0,0 +1,42 @@ +import { describe, it, expect, afterEach } from 'vitest' +import { requireEnv } from '../require-env.js' + +describe('requireEnv', () => { + const ORIGINAL_ENV = process.env + + afterEach(() => { + process.env = { ...ORIGINAL_ENV } + }) + + function setEnv(vars: Record) { + process.env = { ...ORIGINAL_ENV, ...vars } + } + + it('returns the value when the variable is set', () => { + setEnv({ AUTH_SESSION_SECRET: 's3cret' }) + expect(requireEnv('AUTH_SESSION_SECRET')).toBe('s3cret') + }) + + it('throws when the variable is unset', () => { + setEnv({ AUTH_SESSION_SECRET: undefined }) + expect(() => requireEnv('AUTH_SESSION_SECRET')).toThrow( + 'Missing required environment variable: AUTH_SESSION_SECRET', + ) + }) + + // An empty value is as dangerous as an absent one — it would silently + // produce unsigned cookies rather than failing loudly. + it('throws when the variable is set but empty', () => { + setEnv({ EPDS_CALLBACK_SECRET: '' }) + expect(() => requireEnv('EPDS_CALLBACK_SECRET')).toThrow( + 'Missing required environment variable: EPDS_CALLBACK_SECRET', + ) + }) + + it('names the offending variable and how to generate one', () => { + setEnv({ EPDS_CALLBACK_SECRET: undefined }) + expect(() => requireEnv('EPDS_CALLBACK_SECRET')).toThrow( + 'Missing required environment variable: EPDS_CALLBACK_SECRET. Generate a value with: openssl rand -hex 32', + ) + }) +}) diff --git a/packages/shared/src/__tests__/types.test.ts b/packages/shared/src/__tests__/types.test.ts index 6ce2d0db..b4e3856c 100644 --- a/packages/shared/src/__tests__/types.test.ts +++ b/packages/shared/src/__tests__/types.test.ts @@ -50,7 +50,6 @@ describe('Type interfaces (compile-time checks)', () => { hostname: 'auth.example.com', port: 3001, sessionSecret: 'secret', - csrfSecret: 'csrf-secret', pdsHostname: 'pds.example.com', pdsPublicUrl: 'https://pds.example.com', verificationLink: { expiryMinutes: 10, maxAttemptsPerToken: 5 }, diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f3a0e973..cbf16447 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -80,3 +80,4 @@ export type { PostHookResult } from './test-utils/post-hook.js' export { ERROR_CSS, renderError } from './render-error.js' export type { RenderErrorOptions } from './render-error.js' export { resolveStartOverHref, sanitiseHttpUrl } from './start-over-href.js' +export { requireEnv } from './require-env.js' diff --git a/packages/shared/src/require-env.ts b/packages/shared/src/require-env.ts new file mode 100644 index 00000000..40613454 --- /dev/null +++ b/packages/shared/src/require-env.ts @@ -0,0 +1,20 @@ +/** + * Read an environment variable that has no safe default, throwing if it is + * missing or empty. + * + * Secrets must never fall back to a hardcoded development value: a + * deployment that forgot to set one would boot successfully while signing + * cookies and callback URLs with a value published in the source tree. + * Callers invoke this at startup so the process dies immediately with an + * actionable message rather than running in a silently insecure state. + */ +export function requireEnv(name: string): string { + const value = process.env[name] + if (!value) { + throw new Error( + `Missing required environment variable: ${name}. ` + + `Generate a value with: openssl rand -hex 32`, + ) + } + return value +} diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index a1b502bb..f48d4211 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -17,7 +17,6 @@ export interface AuthConfig { hostname: string port: number sessionSecret: string - csrfSecret: string pdsHostname: string pdsPublicUrl: string verificationLink: EpdsLinkConfig diff --git a/scripts/setup.sh b/scripts/setup.sh index e794f0e9..aa561a54 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -362,7 +362,7 @@ setup_toplevel_env() { cp .env.example .env generate_secrets_in_file .env \ - PDS_JWT_SECRET PDS_DPOP_SECRET AUTH_SESSION_SECRET AUTH_CSRF_SECRET \ + PDS_JWT_SECRET PDS_DPOP_SECRET AUTH_SESSION_SECRET \ PDS_ADMIN_PASSWORD EPDS_CALLBACK_SECRET EPDS_INTERNAL_SECRET fi @@ -410,7 +410,7 @@ setup_package_envs() { echo "" setup_package_env packages/pds-core PDS_JWT_SECRET PDS_DPOP_SECRET - setup_package_env packages/auth-service AUTH_SESSION_SECRET AUTH_CSRF_SECRET + setup_package_env packages/auth-service AUTH_SESSION_SECRET # Demo needs special handling: SESSION_SECRET line is commented out by default if [ ! -f packages/demo/.env ]; then From b3a5a3d35fe21103ebba53d5261398a4fcdcf8bf Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 5 Aug 2026 14:20:06 +0100 Subject: [PATCH 43/84] fix(shared): reject whitespace-only required env vars MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit requireEnv() treated whitespace-only values (e.g. ' ') as present, so a secret set to blanks would boot with an effectively empty value rather than failing loudly. Trim before the emptiness check, and mark EPDS_CALLBACK_SECRET as required in docs/configuration.md — both pds-core and auth-service call requireEnv() on it and refuse to start when unset. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/configuration.md | 2 +- packages/shared/src/__tests__/require-env.test.ts | 9 +++++++++ packages/shared/src/require-env.ts | 4 ++-- 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index cbf7817d..71a8cee7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -30,7 +30,7 @@ marked `[shared]` in the per-package `.env.example` files. | `EPDS_VERSION` | Override the version string returned by `/health`. In Docker/Railway builds this is set automatically to `+<8-char commit SHA>`. In dev it falls back to the root `package.json` version. Only set this if you need a custom override. | | `PDS_HOSTNAME` | Your PDS domain — handles will be `.PDS_HOSTNAME` | | `PDS_PUBLIC_URL` | Full public URL of the PDS, used as OAuth issuer (e.g. `https://pds.example.com`) | -| `EPDS_CALLBACK_SECRET` | HMAC-SHA256 secret signing the `/oauth/epds-callback` redirect — generate with `openssl rand -hex 32` | +| `EPDS_CALLBACK_SECRET` | **Required.** HMAC-SHA256 secret signing the `/oauth/epds-callback` redirect — generate with `openssl rand -hex 32`. Both pds-core and auth-service refuse to start if unset. | | `EPDS_INTERNAL_SECRET` | Shared secret for internal service-to-service calls (auth → PDS) — generate with `openssl rand -hex 32` | | `PDS_ADMIN_PASSWORD` | PDS admin API password (auth-service uses it for account provisioning) | | `NODE_ENV` | Set to `development` for dev mode (disables secure cookies) | diff --git a/packages/shared/src/__tests__/require-env.test.ts b/packages/shared/src/__tests__/require-env.test.ts index d607dd1d..f3fa8436 100644 --- a/packages/shared/src/__tests__/require-env.test.ts +++ b/packages/shared/src/__tests__/require-env.test.ts @@ -33,6 +33,15 @@ describe('requireEnv', () => { ) }) + // Whitespace-only is effectively empty for a secret: it would boot with an + // unusable value rather than failing loudly, so trim before checking. + it('throws when the variable is set but whitespace-only', () => { + setEnv({ EPDS_CALLBACK_SECRET: ' ' }) + expect(() => requireEnv('EPDS_CALLBACK_SECRET')).toThrow( + 'Missing required environment variable: EPDS_CALLBACK_SECRET', + ) + }) + it('names the offending variable and how to generate one', () => { setEnv({ EPDS_CALLBACK_SECRET: undefined }) expect(() => requireEnv('EPDS_CALLBACK_SECRET')).toThrow( diff --git a/packages/shared/src/require-env.ts b/packages/shared/src/require-env.ts index 40613454..f1ba004e 100644 --- a/packages/shared/src/require-env.ts +++ b/packages/shared/src/require-env.ts @@ -1,6 +1,6 @@ /** * Read an environment variable that has no safe default, throwing if it is - * missing or empty. + * missing, empty, or whitespace-only. * * Secrets must never fall back to a hardcoded development value: a * deployment that forgot to set one would boot successfully while signing @@ -9,7 +9,7 @@ * actionable message rather than running in a silently insecure state. */ export function requireEnv(name: string): string { - const value = process.env[name] + const value = process.env[name]?.trim() if (!value) { throw new Error( `Missing required environment variable: ${name}. ` + From 406421e76cd659e4674747a3a26beb5279dc5131 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 5 Aug 2026 14:38:30 +0100 Subject: [PATCH 44/84] style(docs): fix prettier table alignment in configuration.md Follow-up to the EPDS_CALLBACK_SECRET required-note change: prettier re-pads the Shared variables table column after the cell length changed. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/configuration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/configuration.md b/docs/configuration.md index 71a8cee7..94427468 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -30,7 +30,7 @@ marked `[shared]` in the per-package `.env.example` files. | `EPDS_VERSION` | Override the version string returned by `/health`. In Docker/Railway builds this is set automatically to `+<8-char commit SHA>`. In dev it falls back to the root `package.json` version. Only set this if you need a custom override. | | `PDS_HOSTNAME` | Your PDS domain — handles will be `.PDS_HOSTNAME` | | `PDS_PUBLIC_URL` | Full public URL of the PDS, used as OAuth issuer (e.g. `https://pds.example.com`) | -| `EPDS_CALLBACK_SECRET` | **Required.** HMAC-SHA256 secret signing the `/oauth/epds-callback` redirect — generate with `openssl rand -hex 32`. Both pds-core and auth-service refuse to start if unset. | +| `EPDS_CALLBACK_SECRET` | **Required.** HMAC-SHA256 secret signing the `/oauth/epds-callback` redirect — generate with `openssl rand -hex 32`. Both pds-core and auth-service refuse to start if unset. | | `EPDS_INTERNAL_SECRET` | Shared secret for internal service-to-service calls (auth → PDS) — generate with `openssl rand -hex 32` | | `PDS_ADMIN_PASSWORD` | PDS admin API password (auth-service uses it for account provisioning) | | `NODE_ENV` | Set to `development` for dev mode (disables secure cookies) | From c4fbbb09af79bc1d41c900bc7c1b16c775916faa Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 14 Apr 2026 14:49:49 +0000 Subject: [PATCH 45/84] fix(pds-core): redact request_uri from CSS middleware warn log request_uri is an opaque PAR handle that should not be logged verbatim. Log only its presence for debugging. Addresses CodeRabbit review on PR #48. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/pds-core/src/lib/client-css-injection.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/pds-core/src/lib/client-css-injection.ts b/packages/pds-core/src/lib/client-css-injection.ts index 52580d11..eb7d9a17 100644 --- a/packages/pds-core/src/lib/client-css-injection.ts +++ b/packages/pds-core/src/lib/client-css-injection.ts @@ -172,7 +172,7 @@ export function createClientCssInjectionMiddleware({ clientId = await resolveClientIdFromRequestUri(requestUri) } catch (err) { logger.warn( - { err, requestUri }, + { err, hasRequestUri: true }, 'CSS middleware: failed to resolve client_id from request_uri', ) } From 1e152b5c436da1175e470ffb7f924737f9b76e6e Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 5 Aug 2026 18:02:15 +0100 Subject: [PATCH 46/84] refactor(pds-core): extract OAuth client-id resolution from CSS middleware OAuth authorize pages carry either an explicit client_id query parameter or only a PAR request_uri that has to be resolved through the provider's request manager. That resolution lived inline in the CSS injection middleware, but chooser enrichment needs exactly the same step, so hoist it into a shared module rather than duplicating it at the second call site. Resolver errors stay with the caller so each middleware keeps its own logging and fallback behaviour. The CSS middleware's failure log moves from warn to error, since a client_id that cannot be resolved means the page renders unbranded. Split out of #148, which bundled this refactor with the enrichment work that motivated it. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/client-css-injection.test.ts | 2 +- .../__tests__/oauth-request-context.test.ts | 83 +++++++++++++++++++ .../pds-core/src/lib/client-css-injection.ts | 39 ++++----- .../pds-core/src/lib/oauth-request-context.ts | 37 +++++++++ 4 files changed, 138 insertions(+), 23 deletions(-) create mode 100644 packages/pds-core/src/__tests__/oauth-request-context.test.ts create mode 100644 packages/pds-core/src/lib/oauth-request-context.ts diff --git a/packages/pds-core/src/__tests__/client-css-injection.test.ts b/packages/pds-core/src/__tests__/client-css-injection.test.ts index d9148b4f..99068f85 100644 --- a/packages/pds-core/src/__tests__/client-css-injection.test.ts +++ b/packages/pds-core/src/__tests__/client-css-injection.test.ts @@ -9,7 +9,7 @@ import { } from '../lib/client-css-injection.js' function mockLogger() { - return { info: vi.fn(), warn: vi.fn(), debug: vi.fn() } + return { info: vi.fn(), error: vi.fn(), warn: vi.fn(), debug: vi.fn() } } describe('shouldInjectClientCss', () => { diff --git a/packages/pds-core/src/__tests__/oauth-request-context.test.ts b/packages/pds-core/src/__tests__/oauth-request-context.test.ts new file mode 100644 index 00000000..d25c5fbe --- /dev/null +++ b/packages/pds-core/src/__tests__/oauth-request-context.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it, vi } from 'vitest' +import { resolveOAuthClientIdFromQuery } from '../lib/oauth-request-context.js' + +const CLIENT_ID = 'https://app.example/client.json' +const REQUEST_URI = 'urn:ietf:params:oauth:request_uri:req-123' + +describe('resolveOAuthClientIdFromQuery', () => { + it('prefers an explicit client_id query parameter', async () => { + const resolver = vi.fn() + + await expect( + resolveOAuthClientIdFromQuery({ client_id: CLIENT_ID }, resolver), + ).resolves.toBe(CLIENT_ID) + // The PAR round-trip is skipped entirely when the id is already present. + expect(resolver).not.toHaveBeenCalled() + }) + + it('prefers client_id even when a request_uri is also present', async () => { + const resolver = vi.fn().mockResolvedValue('https://other.example/c.json') + + await expect( + resolveOAuthClientIdFromQuery( + { client_id: CLIENT_ID, request_uri: REQUEST_URI }, + resolver, + ), + ).resolves.toBe(CLIENT_ID) + expect(resolver).not.toHaveBeenCalled() + }) + + it('falls back to resolving the PAR request_uri', async () => { + const resolver = vi.fn().mockResolvedValue(CLIENT_ID) + + await expect( + resolveOAuthClientIdFromQuery({ request_uri: REQUEST_URI }, resolver), + ).resolves.toBe(CLIENT_ID) + expect(resolver).toHaveBeenCalledWith(REQUEST_URI) + }) + + it('returns undefined when the resolver finds no client for the request_uri', async () => { + const resolver = vi.fn().mockResolvedValue(undefined) + + await expect( + resolveOAuthClientIdFromQuery({ request_uri: REQUEST_URI }, resolver), + ).resolves.toBeUndefined() + }) + + it('returns undefined when no resolver is supplied', async () => { + await expect( + resolveOAuthClientIdFromQuery({ request_uri: REQUEST_URI }), + ).resolves.toBeUndefined() + }) + + it('returns undefined for an empty query', async () => { + const resolver = vi.fn() + + await expect( + resolveOAuthClientIdFromQuery({}, resolver), + ).resolves.toBeUndefined() + expect(resolver).not.toHaveBeenCalled() + }) + + it.each([ + { name: 'non-string client_id', query: { client_id: 42 } }, + { name: 'non-string request_uri', query: { request_uri: 42 } }, + ])('ignores a $name', async ({ query }) => { + const resolver = vi.fn().mockResolvedValue(CLIENT_ID) + + await expect( + resolveOAuthClientIdFromQuery(query, resolver), + ).resolves.toBeUndefined() + expect(resolver).not.toHaveBeenCalled() + }) + + it('propagates resolver rejections to the caller', async () => { + // Deliberate: the module leaves error handling to each middleware so + // they can choose their own logging and fallback behaviour. + const resolver = vi.fn().mockRejectedValue(new Error('PAR lookup failed')) + + await expect( + resolveOAuthClientIdFromQuery({ request_uri: REQUEST_URI }, resolver), + ).rejects.toThrow('PAR lookup failed') + }) +}) diff --git a/packages/pds-core/src/lib/client-css-injection.ts b/packages/pds-core/src/lib/client-css-injection.ts index eb7d9a17..d291cc2a 100644 --- a/packages/pds-core/src/lib/client-css-injection.ts +++ b/packages/pds-core/src/lib/client-css-injection.ts @@ -2,9 +2,14 @@ import { createHash } from 'node:crypto' import type { ClientMetadata } from '@certified-app/shared' import { DEFAULT_BRANDING_CSS } from './default-branding.js' +import { + resolveOAuthClientIdFromQuery, + type ResolveClientIdFromRequestUri, +} from './oauth-request-context.js' type LoggerLike = { info: (obj: object, msg: string) => void + error: (obj: object, msg: string) => void warn: (obj: object, msg: string) => void debug: (obj: object, msg: string) => void } @@ -18,9 +23,7 @@ type ClientCssInjectionDeps = { trustedClients: string[], ) => string | null /** Resolve client_id from a PAR request_uri (optional). */ - resolveClientIdFromRequestUri?: ( - requestUri: string, - ) => Promise + resolveClientIdFromRequestUri?: ResolveClientIdFromRequestUri logger: LoggerLike } @@ -158,25 +161,17 @@ export function createClientCssInjectionMiddleware({ return } - // Resolve client_id: it may be on the query string directly, or - // inside a PAR request_uri that needs to be looked up via the - // oauth-provider's request manager. PAR-based flows (the common - // case in ePDS) only carry request_uri on the query string. - let clientId = - typeof query.client_id === 'string' ? query.client_id : undefined - if (!clientId && resolveClientIdFromRequestUri) { - const requestUri = - typeof query.request_uri === 'string' ? query.request_uri : undefined - if (requestUri) { - try { - clientId = await resolveClientIdFromRequestUri(requestUri) - } catch (err) { - logger.warn( - { err, hasRequestUri: true }, - 'CSS middleware: failed to resolve client_id from request_uri', - ) - } - } + let clientId: string | undefined + try { + clientId = await resolveOAuthClientIdFromQuery( + query, + resolveClientIdFromRequestUri, + ) + } catch (err) { + logger.error( + { err, requestUri: query.request_uri }, + 'CSS middleware: failed to resolve client_id from request_uri', + ) } // Resolve trusted-client css if applicable. Untrusted/unknown diff --git a/packages/pds-core/src/lib/oauth-request-context.ts b/packages/pds-core/src/lib/oauth-request-context.ts new file mode 100644 index 00000000..2b7dde4d --- /dev/null +++ b/packages/pds-core/src/lib/oauth-request-context.ts @@ -0,0 +1,37 @@ +/** + * OAuth request-context helpers for pds-core response enrichment. + * + * OAuth authorize pages may receive either a direct `client_id` query + * parameter or only a PAR `request_uri`. This module owns the narrow + * resolution step from the current request query to the OAuth client id, + * while callers keep their feature-specific policy decisions local. + */ + +export type OAuthRequestQuery = Record + +export type ResolveClientIdFromRequestUri = ( + requestUri: string, +) => Promise + +/** + * Resolve the OAuth client id visible from the current request query. + * + * Prefer an explicit `client_id` query parameter. When it is absent, + * optionally resolve the PAR `request_uri` through the provider request + * manager supplied by the caller. Resolver errors are intentionally left + * to the caller so each middleware can choose its own logging and fallback + * behaviour. + */ +export async function resolveOAuthClientIdFromQuery( + query: OAuthRequestQuery, + resolveClientIdFromRequestUri?: ResolveClientIdFromRequestUri, +): Promise { + if (typeof query.client_id === 'string') return query.client_id + if (!resolveClientIdFromRequestUri) return undefined + + const requestUri = + typeof query.request_uri === 'string' ? query.request_uri : undefined + if (!requestUri) return undefined + + return resolveClientIdFromRequestUri(requestUri) +} From 707355ba55d88bd0a6896d719b895ac0619f167d Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 5 Aug 2026 18:21:41 +0100 Subject: [PATCH 47/84] fix(pds-core): log request_uri presence, not its value request_uri is a short-lived bearer reference to the PAR entry, so a log line carrying its value is replayable by anyone who can read the logs. Log whether it was present instead. Raised independently by CodeRabbit and Copilot on #241. The value was already being logged before this series; hoisting the resolution out of the request_uri guard promoted the line from warn to error, which made it more visible without making it safe. #80 fixes the same leak on the pre-refactor code. typeof rather than a hardcoded true: the log now sits outside the `if (requestUri)` guard, so presence is no longer implied by reaching it. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/client-css-injection.test.ts | 31 +++++++++++++++++++ .../pds-core/src/lib/client-css-injection.ts | 4 ++- 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/pds-core/src/__tests__/client-css-injection.test.ts b/packages/pds-core/src/__tests__/client-css-injection.test.ts index 99068f85..f7c14aec 100644 --- a/packages/pds-core/src/__tests__/client-css-injection.test.ts +++ b/packages/pds-core/src/__tests__/client-css-injection.test.ts @@ -263,6 +263,37 @@ describe('createClientCssInjectionMiddleware', () => { expect(next).toHaveBeenCalledOnce() }) + // request_uri is a short-lived bearer reference to the PAR entry, so a + // log line carrying its value is replayable by anyone reading the logs. + it('logs request_uri presence but never its value when PAR resolution fails', async () => { + const requestUri = 'urn:ietf:params:oauth:request_uri:secret-par-handle' + const logger = mockLogger() + const middleware = createClientCssInjectionMiddleware({ + trustedClients: [trustedClient], + resolveClientMetadata: vi.fn(), + getClientCss: vi.fn(), + resolveClientIdFromRequestUri: vi + .fn() + .mockRejectedValue(new Error('boom')), + logger, + }) + const req = { + method: 'GET', + path: '/oauth/authorize', + query: { request_uri: requestUri }, + } + const { res } = createResponseDouble() + const next = vi.fn() + + await middleware(req, res, next) + + expect(logger.error).toHaveBeenCalledOnce() + const [context] = logger.error.mock.calls[0] + expect(context).toMatchObject({ hasRequestUri: true }) + expect(JSON.stringify(context)).not.toContain(requestUri) + expect(next).toHaveBeenCalledOnce() + }) + // ─── Regression: ERR_HTTP_HEADERS_SENT crash ───────────────────────── // // @atproto/oauth-provider flushes response headers before calling diff --git a/packages/pds-core/src/lib/client-css-injection.ts b/packages/pds-core/src/lib/client-css-injection.ts index d291cc2a..bbce6368 100644 --- a/packages/pds-core/src/lib/client-css-injection.ts +++ b/packages/pds-core/src/lib/client-css-injection.ts @@ -168,8 +168,10 @@ export function createClientCssInjectionMiddleware({ resolveClientIdFromRequestUri, ) } catch (err) { + // Log presence, not the value: request_uri is a short-lived bearer + // reference to the PAR entry, so a log line carrying it is replayable. logger.error( - { err, requestUri: query.request_uri }, + { err, hasRequestUri: typeof query.request_uri === 'string' }, 'CSS middleware: failed to resolve client_id from request_uri', ) } From a0a819af2d39288351ea17f4f4f9de62af026be8 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 5 Aug 2026 18:03:19 +0100 Subject: [PATCH 48/84] fix(auth): gate email sign-in until its handlers are installed The Continue button was clickable from first paint, but its submit handler is installed at the end of a long inline script. A click landing in that window did nothing: the form had no handler yet, so the sign-in attempt was silently dropped. Fast e2e runs hit this routinely; real users on slow connections can too. Ship the button disabled and enable it as the last step of handler setup, so it becomes clickable exactly when it starts working. Add a noscript warning as well, since with scripting off the button would otherwise never respond and never explain why. The tests pin the ordering rather than the mechanism: the enable must follow the submit binding, and must not be driven by a timer. Split out of #148, which bundled this with unrelated enrichment work. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/sign-in-button-readiness.md | 9 ++++ .../src/__tests__/login-page.test.ts | 53 +++++++++++++++++++ .../auth-service/src/routes/login-page.ts | 11 +++- 3 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 .changeset/sign-in-button-readiness.md diff --git a/.changeset/sign-in-button-readiness.md b/.changeset/sign-in-button-readiness.md new file mode 100644 index 00000000..d3a7cc2d --- /dev/null +++ b/.changeset/sign-in-button-readiness.md @@ -0,0 +1,9 @@ +--- +'ePDS': patch +--- + +The email sign-in button now waits until the page is ready before accepting clicks. + +**Affects:** End users + +**End users:** tapping Continue before the sign-in page finished loading could start a broken sign-in attempt that silently did nothing. The button is now disabled until the page is ready. Browsers with JavaScript disabled see a message explaining that JavaScript is required to sign in with email, instead of a button that never responds. diff --git a/packages/auth-service/src/__tests__/login-page.test.ts b/packages/auth-service/src/__tests__/login-page.test.ts index 01a8784c..ae52d6a1 100644 --- a/packages/auth-service/src/__tests__/login-page.test.ts +++ b/packages/auth-service/src/__tests__/login-page.test.ts @@ -561,6 +561,59 @@ describe('renderLoginPage sign-in error copy', () => { }) }) +describe('renderLoginPage email form readiness gate', () => { + it('renders the email OTP form without a readiness dataset marker', () => { + const html = renderDefault() + expect(html).toContain('') + expect(html).not.toContain('data-epds-login-ready') + expect(html).not.toContain('epdsLoginReady') + }) + + it('renders the email submit button disabled with the existing label', () => { + const html = renderDefault() + expect(html).toMatch( + / + ${handleLoginButtonHtml}
+ +

${ @@ -775,6 +777,7 @@ export function renderLoginPage(opts: { var otpSubtitle = document.getElementById('otp-subtitle'); var otpEmailInput = document.getElementById('otp-email'); var atprotoBtn = document.querySelector('.btn-atproto'); + var sendOtpForm = document.getElementById('form-send-otp'); var emailInput = document.getElementById('email'); var emailLabel = document.querySelector('label[for="email"]'); var sendOtpBtn = document.querySelector('#form-send-otp button[type=submit]'); @@ -1304,7 +1307,7 @@ export function renderLoginPage(opts: { } // Form: send OTP (email mode) or hand off to client (handle mode) - document.getElementById('form-send-otp').addEventListener('submit', async function(e) { + sendOtpForm.addEventListener('submit', async function(e) { e.preventDefault(); clearError(); var raw = emailInput.value.trim(); @@ -1490,6 +1493,10 @@ export function renderLoginPage(opts: { clearOtpBoxes(); }); + // Enable only after the submit handler is installed. This avoids a race mostly + // seen in fast e2e runs where the button is clicked before JS is ready. + sendOtpBtn.disabled = false; + // Pillar 1: If login_hint was provided, the OTP step is already visible // server-side — no DOM transition needed. // Pillar 2: Auto-fire the OTP send as a client-side POST. From b3c66f696e5e56de884cf01f6f35e5ab19f54bdb Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Mon, 3 Aug 2026 18:31:48 +0100 Subject: [PATCH 49/84] build(pds-core): move to Node 22 + pure ESM Platform prerequisite for the upstream @atproto upgrade (bead atproto-pd5.1). Latest @atproto/pds and @atproto/oauth-provider drop Node 18/20 and convert to pure ESM, so pds-core must flip first. - pds-core: add "type":"module" and engines.node>=22; bump @types/node ^22 - root engines.node >=20.0.0 -> >=22; add .nvmrc (22) - tsconfig: module/moduleResolution Node16 -> NodeNext - index.ts: replace bare require.resolve with createRequire(import.meta.url) - preview-shared.ts: createRequire(__filename) -> createRequire(import.meta.url); refresh stale CJS/Node-20 comments - preview-test-helpers: annotate mockLogger return (TS2742 under NodeNext) - Dockerfiles + CI workflows: node 20 -> 22 Dependency versions unchanged (bead atproto-pd5.2). Build clean, 299 pds-core tests green, dist/index.js boots as ESM on Node 22. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 8 ++++---- .github/workflows/e2e-tests.yml | 2 +- .github/workflows/release.yml | 2 +- .nvmrc | 1 + Dockerfile.auth | 4 ++-- Dockerfile.demo | 4 ++-- Dockerfile.pds | 4 ++-- package.json | 2 +- packages/pds-core/package.json | 6 +++++- .../src/__tests__/preview-test-helpers.ts | 11 +++++++++-- packages/pds-core/src/index.ts | 8 ++++++-- packages/pds-core/src/lib/preview-shared.ts | 16 ++++++++-------- packages/pds-core/tsconfig.json | 4 ++-- pnpm-lock.yaml | 10 +++++----- 14 files changed, 49 insertions(+), 33 deletions(-) create mode 100644 .nvmrc diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8eb2dd2..97827a8d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,7 +19,7 @@ jobs: - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm format:check @@ -31,7 +31,7 @@ jobs: - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm lint @@ -43,7 +43,7 @@ jobs: - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm typecheck @@ -55,7 +55,7 @@ jobs: - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile - run: pnpm build diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index eca24cf9..db82518d 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -115,7 +115,7 @@ jobs: - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v5.0.0 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: - node-version: 20 + node-version: 22 cache: pnpm - run: pnpm install --frozen-lockfile diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5b538a76..e9197079 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -60,7 +60,7 @@ jobs: - uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 # v4.4.0 - uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0 with: - node-version: 20 + node-version: 22 cache: 'pnpm' - run: pnpm install --frozen-lockfile diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..2bd5a0a9 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/Dockerfile.auth b/Dockerfile.auth index cb058177..27b07f9f 100644 --- a/Dockerfile.auth +++ b/Dockerfile.auth @@ -4,7 +4,7 @@ # so each service has its own Dockerfile. # -- Base -- -FROM node:20-alpine AS base +FROM node:22-alpine AS base RUN corepack enable && corepack prepare pnpm@9.15.9 --activate RUN addgroup -g 1001 appuser && adduser -u 1001 -G appuser -D appuser WORKDIR /app @@ -24,7 +24,7 @@ COPY tsconfig.json ./ RUN pnpm --filter @certified-app/auth-service... build # -- Auth Service -- -FROM node:20-alpine +FROM node:22-alpine RUN addgroup -g 1001 appuser && adduser -u 1001 -G appuser -D appuser RUN apk add --no-cache wget su-exec WORKDIR /app diff --git a/Dockerfile.demo b/Dockerfile.demo index 4dbde82e..9cc44d32 100644 --- a/Dockerfile.demo +++ b/Dockerfile.demo @@ -3,7 +3,7 @@ # with output: "standalone" and then runs the self-contained server. # -- Base -- -FROM node:20-alpine AS base +FROM node:22-alpine AS base RUN corepack enable && corepack prepare pnpm@9.15.9 --activate WORKDIR /app @@ -35,7 +35,7 @@ RUN /tmp/resolve-version.sh && \ pnpm --filter @certified-app/demo build # -- Production -- -FROM node:20-alpine +FROM node:22-alpine RUN addgroup -g 1001 appuser && adduser -u 1001 -G appuser -D appuser WORKDIR /app diff --git a/Dockerfile.pds b/Dockerfile.pds index 9dd798e9..7cecbab4 100644 --- a/Dockerfile.pds +++ b/Dockerfile.pds @@ -4,7 +4,7 @@ # so each service has its own Dockerfile. # -- Base -- -FROM node:20-alpine AS base +FROM node:22-alpine AS base RUN corepack enable && corepack prepare pnpm@9.15.9 --activate RUN addgroup -g 1001 appuser && adduser -u 1001 -G appuser -D appuser WORKDIR /app @@ -24,7 +24,7 @@ COPY tsconfig.json ./ RUN pnpm --filter @certified-app/pds-core... build # -- PDS Core -- -FROM node:20-alpine +FROM node:22-alpine RUN addgroup -g 1001 appuser && adduser -u 1001 -G appuser -D appuser RUN apk add --no-cache wget su-exec WORKDIR /app diff --git a/package.json b/package.json index 5d117057..080e8717 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "description": "ePDS — extended Personal Data Server for AT Protocol with passwordless OTP authentication", "license": "MIT", "engines": { - "node": ">=20.0.0" + "node": ">=22" }, "packageManager": "pnpm@9.15.9", "//workspaces": "This field is only read by @manypkg/get-packages (used by Changesets) to put us into single-package mode. pnpm ignores it because pnpm-workspace.yaml takes precedence for actual workspace resolution. Do not remove without reading docs/PUBLISHING.md.", diff --git a/packages/pds-core/package.json b/packages/pds-core/package.json index 7dccdbfd..52bfda6c 100644 --- a/packages/pds-core/package.json +++ b/packages/pds-core/package.json @@ -1,8 +1,12 @@ { "name": "@certified-app/pds-core", "private": true, + "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", + "engines": { + "node": ">=22" + }, "scripts": { "build": "tsc --build", "dev": "tsx watch src/index.ts", @@ -21,7 +25,7 @@ }, "devDependencies": { "@types/express": "^4.17.21", - "@types/node": "^20.11.0", + "@types/node": "^22.0.0", "@types/serialize-javascript": "^5.0.4", "tsx": "^4.7.0", "typescript": "^5.3.3" diff --git a/packages/pds-core/src/__tests__/preview-test-helpers.ts b/packages/pds-core/src/__tests__/preview-test-helpers.ts index 66e3e244..15207960 100644 --- a/packages/pds-core/src/__tests__/preview-test-helpers.ts +++ b/packages/pds-core/src/__tests__/preview-test-helpers.ts @@ -5,9 +5,16 @@ * about new-code duplication and gives the suites a single place to * grow the mock when the handler signature changes. */ -import { vi } from 'vitest' +import { vi, type Mock } from 'vitest' -export function mockLogger() { +// Explicit return type: under NodeNext module resolution the inferred type +// referenced @vitest/spy by a non-portable path (TS2742). Naming the Mock +// fields keeps the emitted .d.ts self-contained. +export function mockLogger(): { + info: Mock + warn: Mock + debug: Mock +} { return { info: vi.fn(), warn: vi.fn(), debug: vi.fn() } } diff --git a/packages/pds-core/src/index.ts b/packages/pds-core/src/index.ts index 48ea356a..1b361356 100644 --- a/packages/pds-core/src/index.ts +++ b/packages/pds-core/src/index.ts @@ -27,9 +27,13 @@ import { randomBytes } from 'node:crypto' import * as path from 'node:path' import { PDS, envToCfg, envToSecrets, readEnv } from '@atproto/pds' import { readFileSync } from 'node:fs' -/* v8 ignore next 3 -- module-level init, only testable via e2e */ +import { createRequire } from 'node:module' +/* v8 ignore next 4 -- module-level init, only testable via e2e */ const atprotoPdsPkg: { version: string } = JSON.parse( - readFileSync(require.resolve('@atproto/pds/package.json'), 'utf8'), + readFileSync( + createRequire(import.meta.url).resolve('@atproto/pds/package.json'), + 'utf8', + ), ) import { HandleUnavailableError } from '@atproto/oauth-provider' import { diff --git a/packages/pds-core/src/lib/preview-shared.ts b/packages/pds-core/src/lib/preview-shared.ts index aba8a66f..ee66d63b 100644 --- a/packages/pds-core/src/lib/preview-shared.ts +++ b/packages/pds-core/src/lib/preview-shared.ts @@ -16,11 +16,11 @@ import { readFile } from 'node:fs/promises' import { createRequire } from 'node:module' import serialize from 'serialize-javascript' -// pds-core compiles to CommonJS, so createRequire(__filename) gives us a -// require that can resolve the sibling package without tripping the -// no-require-imports lint rule (we never call the local `require` — only -// require.resolve, which is a function lookup not a syntactic require). -const nodeRequire = createRequire(__filename) +// pds-core is pure ESM, so we build a require via import.meta.url to resolve +// the sibling package without tripping the no-require-imports lint rule (we +// never call the local `require` — only require.resolve, which is a function +// lookup not a syntactic require). +const nodeRequire = createRequire(import.meta.url) // Use structural request/response types rather than importing from // express — pds-core doesn't depend on express's types directly and @@ -58,9 +58,9 @@ export async function loadAssetRefs(): Promise<{ }> { if (cachedAssets) return cachedAssets // Read the manifest as a plain file rather than via `import(..., { with: - // { type: 'json' } })`. Import-attributes are only stable in Node 22+ - // and the repo allows Node >=20.0.0; require.resolve + fs.readFile - // works on every Node 20.x without attributes. + // { type: 'json' } })`. require.resolve + fs.readFile keeps the manifest + // load lazy (import-attributes would hoist it to parse time) and avoids + // depending on attribute-syntax stability across bundlers. const manifestPath = nodeRequire.resolve( '@atproto/oauth-provider-ui/bundle-manifest.json', ) diff --git a/packages/pds-core/tsconfig.json b/packages/pds-core/tsconfig.json index 229fed07..3f5441d1 100644 --- a/packages/pds-core/tsconfig.json +++ b/packages/pds-core/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { "target": "ES2022", - "module": "Node16", - "moduleResolution": "Node16", + "module": "NodeNext", + "moduleResolution": "NodeNext", "lib": ["ES2022"], "strict": true, "esModuleInterop": true, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a0d50948..ededda66 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,8 +172,8 @@ importers: specifier: ^4.17.21 version: 4.17.25 '@types/node': - specifier: ^20.11.0 - version: 20.19.33 + specifier: ^22.0.0 + version: 22.19.11 '@types/serialize-javascript': specifier: ^5.0.4 version: 5.0.4 @@ -6489,11 +6489,11 @@ snapshots: '@types/better-sqlite3@7.6.13': dependencies: - '@types/node': 20.19.33 + '@types/node': 22.19.11 '@types/bn.js@5.2.0': dependencies: - '@types/node': 20.19.33 + '@types/node': 22.19.11 '@types/body-parser@1.19.6': dependencies: @@ -6557,7 +6557,7 @@ snapshots: '@types/nodemailer@6.4.22': dependencies: - '@types/node': 20.19.33 + '@types/node': 22.19.11 '@types/normalize-package-data@2.4.4': {} From 78f6f498107987bdd26eed40d8ac453e5eeeb421 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Mon, 3 Aug 2026 18:33:19 +0100 Subject: [PATCH 50/84] build(pds-core): bump @atproto deps to latest upstream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bead atproto-pd5.2. Raise the three atproto pins through the breaking minor channels to the current latest: - @atproto/oauth-provider ^0.15.9 -> ^0.21.1 - @atproto/oauth-provider-ui ^0.4.3 -> ^0.8.9 - @atproto/pds ^0.4.209 -> ^0.5.23 Upstream advanced past the upgrade brief (checked 2026-07-27): oauth-provider crossed an additional 0.21.0 minor beyond the report's 0.20.x target — an extra breaking channel to clear in bead .3/.4. Install is clean on Node 22 with a single resolved copy of each package and no old transitive versions. The build is intentionally RED after this commit: the barrel-import and type breakage is fixed in beads .3 (imports) and .5 (Account typing). Co-Authored-By: Claude Opus 4.8 --- packages/pds-core/package.json | 6 +- pnpm-lock.yaml | 2225 ++++++++++---------------------- 2 files changed, 714 insertions(+), 1517 deletions(-) diff --git a/packages/pds-core/package.json b/packages/pds-core/package.json index 52bfda6c..57c67c50 100644 --- a/packages/pds-core/package.json +++ b/packages/pds-core/package.json @@ -14,9 +14,9 @@ }, "dependencies": { "@atproto/crypto": "^0.4.5", - "@atproto/oauth-provider": "^0.15.9", - "@atproto/oauth-provider-ui": "^0.4.3", - "@atproto/pds": "^0.4.209", + "@atproto/oauth-provider": "^0.21.1", + "@atproto/oauth-provider-ui": "^0.8.9", + "@atproto/pds": "^0.5.23", "@certified-app/shared": "workspace:*", "@did-plc/lib": "^0.0.4", "dotenv": "^16.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ededda66..c0b8257e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -144,14 +144,14 @@ importers: specifier: ^0.4.5 version: 0.4.5 '@atproto/oauth-provider': - specifier: ^0.15.9 - version: 0.15.10 + specifier: ^0.21.1 + version: 0.21.1 '@atproto/oauth-provider-ui': - specifier: ^0.4.3 - version: 0.4.3 + specifier: ^0.8.9 + version: 0.8.9 '@atproto/pds': - specifier: ^0.4.209 - version: 0.4.211 + specifier: ^0.5.23 + version: 0.5.23 '@certified-app/shared': specifier: workspace:* version: link:../shared @@ -211,49 +211,64 @@ importers: packages: - '@atproto-labs/did-resolver@0.2.6': - resolution: {integrity: sha512-2K1bC04nI2fmgNcvof+yA28IhGlpWn2JKYlPa7To9JTKI45FINCGkQSGiL2nyXlyzDJJ34fZ1aq6/IRFIOIiqg==} + '@atproto-labs/did-resolver@0.3.6': + resolution: {integrity: sha512-5Bb3xfDgOw4r+AjAVuZvk7lWQlUkq7dxJoRMHI46UTLEt99CQGqvKOBbA0xAXehYc76LJyaG9fulVQuWmVuqUA==} + engines: {node: '>=22'} '@atproto-labs/fetch-node@0.2.0': resolution: {integrity: sha512-Krq09nH/aeoiU2s9xdHA0FjTEFWG9B5FFenipv1iRixCcPc7V3DhTNDawxG9gI8Ny0k4dBVS9WTRN/IDzBx86Q==} engines: {node: '>=18.7.0'} + '@atproto-labs/fetch-node@0.3.6': + resolution: {integrity: sha512-sGBMlRMCRu0xAnesRe7WCe9e2TMdVMd4I5WrNa43dFs14KxBzHXaPLDM1ZiYe/wGf7midNG3md5/BXvdC6vPCA==} + engines: {node: '>=22'} + '@atproto-labs/fetch@0.2.3': resolution: {integrity: sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw==} + '@atproto-labs/fetch@0.3.5': + resolution: {integrity: sha512-iDFZEoNqfL17EVKE+uNzdUD7YF8LWhEhxeBtP6x+PNuAxoXv3ip9vLmB8nNzNxFwVn++FipfDtSiPqmziv1bdA==} + engines: {node: '>=22'} + '@atproto-labs/pipe@0.1.1': resolution: {integrity: sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg==} - '@atproto-labs/simple-store-memory@0.1.4': - resolution: {integrity: sha512-3mKY4dP8I7yKPFj9VKpYyCRzGJOi5CEpOLPlRhoJyLmgs3J4RzDrjn323Oakjz2Aj2JzRU/AIvWRAZVhpYNJHw==} + '@atproto-labs/pipe@0.2.4': + resolution: {integrity: sha512-n67jCcrC+ouAeO10cWkpPzzLMlDi/lDCU30Us+LGqhOPhT6c4t5ASdBLQi9W3jUQtRzQBt3G9zipF+xKWNvVbw==} + engines: {node: '>=22'} + + '@atproto-labs/simple-store-memory@0.2.5': + resolution: {integrity: sha512-Qme5VqkHKZA0w218/3pncvT+KYQzVdtP4aF72eCswh53SLrdGCG6F/NtllmJuYmp9uPGnHdql/Yt0BY1slAKjQ==} + engines: {node: '>=22'} - '@atproto-labs/simple-store-redis@0.0.1': - resolution: {integrity: sha512-hGkfDNVtTqwcRx27k6u25pgwNIHq3xDCRuojkfHf6c1B9R5rKphdZJ91Mn3lCvsyDB/lUqqLuzKuXQWFml/u5g==} + '@atproto-labs/simple-store-redis@0.1.5': + resolution: {integrity: sha512-pr8RflgKBiKFTl+wjXs2M+gYX2kEf0K/QOTn5qy5Opy6mLGhoxcbKvc1VWG6nNTu33RdjrZTbeOAQG4ZjbpGOg==} + engines: {node: '>=22'} peerDependencies: ioredis: ^5.3.2 - '@atproto-labs/simple-store@0.3.0': - resolution: {integrity: sha512-nOb6ONKBRJHRlukW1sVawUkBqReLlLx6hT35VS3imaNPwiXDxLnTK7lxw3Lrl9k5yugSBDQAkZAq3MPTEFSUBQ==} + '@atproto-labs/simple-store@0.5.0': + resolution: {integrity: sha512-tA/BmCahnrLV+seUZzjkC1xVZQbjogiooQ5C4kkC6we2mP68v7SreQTuv/XPhK2wsSES6c1SYCgDkkLlDTzALA==} + engines: {node: '>=22'} - '@atproto-labs/xrpc-utils@0.0.24': - resolution: {integrity: sha512-wWXd2Ht47UsL/UbDCr3twMFSZrh0xSI56u4O3kz0DTU4G+530mCG71mMVE6eeYcR+j6FEjp7o2Ld6c7wFklYGw==} + '@atproto-labs/xrpc-utils@0.1.12': + resolution: {integrity: sha512-ecAsf6eRT2e8hn0jQdXcGREFhsKyqthVa2oN10Rlj2T8WflRVeV5PtiaxB4/L7Zre6eUN1xQbQtAcnIKL+Uccw==} + engines: {node: '>=22'} - '@atproto/api@0.18.21': - resolution: {integrity: sha512-s35MIJerGT/pKe2xJtKKswqlIr/ola2r2iURBKBL0Mk1OKe6jP4YvTMh1N2d2PEANFzNNTbKoDaLfJPo2Uvc/w==} + '@atproto/aws@0.3.9': + resolution: {integrity: sha512-UPx2vXBiD0Ly4CRpGDXN5GzF/3ASc+TB3zsp/JFgYJqgBd1uuYGJgEkzsJ/dcnQHQbiPyniOkwCuUyFjkUUXhg==} + engines: {node: '>=22'} - '@atproto/aws@0.2.31': - resolution: {integrity: sha512-jzw0v4KttNqeEZtkIujIOCvWABJl4m8WQuSOlJtYeJhn/xaBC1+CNt7qnvYCzBd+T5nncZU8junHSEbOMdVwiA==} - engines: {node: '>=18.7.0'} - - '@atproto/common-web@0.4.17': - resolution: {integrity: sha512-sfxD8NGxyoxhxmM9EUshEFbWcJ3+JHEOZF4Quk6HsCh1UxpHBmLabT/vEsAkDWl+C/8U0ine0+c/gHyE/OZiQQ==} + '@atproto/common-web@0.5.7': + resolution: {integrity: sha512-lvU8lFO5UByacsl3AReXkpTWjcPBS8mBqt40B45HwCt+s58a+xT/YI/+AUpmzSmsPVZCpeCmP6wgsOSC8RILXg==} + engines: {node: '>=22'} '@atproto/common@0.1.1': resolution: {integrity: sha512-GYwot5wF/z8iYGSPjrLHuratLc0CVgovmwfJss7+BUOB6y2/Vw8+1Vw0n9DDI0gb5vmx3UI8z0uJgC8aa8yuJg==} - '@atproto/common@0.5.13': - resolution: {integrity: sha512-+5c3wlvZVCCReoPSwvkPhKz3Y2FZlJzm69BrfkHMccAH0Rs0KLwnWtoE34zyzTLNH7hhy5okx5qJS6+ZlgE9Sg==} - engines: {node: '>=18.7.0'} + '@atproto/common@0.7.3': + resolution: {integrity: sha512-pmu+B6n83exJDJSCXazVVOpk+RuXsMK8VxKUnkiSXHP03+nRDDeNXaWEp1COBBU2dfAzYZZB6k4M8G3gq3zkcQ==} + engines: {node: '>=22'} '@atproto/crypto@0.1.0': resolution: {integrity: sha512-9xgFEPtsCiJEPt9o3HtJT30IdFTGw5cQRSJVIy5CFhqBA4vDLcdXiRDLCjkzHEVbtNCsHUW6CrlfOgbeLPcmcg==} @@ -262,268 +277,202 @@ packages: resolution: {integrity: sha512-n40aKkMoCatP0u9Yvhrdk6fXyOHFDDbkdm4h4HCyWW+KlKl8iXfD5iV+ECq+w5BM+QH25aIpt3/j6EUNerhLxw==} engines: {node: '>=18.7.0'} - '@atproto/did@0.3.0': - resolution: {integrity: sha512-raUPzUGegtW/6OxwCmM8bhZvuIMzxG5t9oWsth6Tp91Kb5fTnHV2h/KKNF1C82doeA4BdXCErTyg7ISwLbQkzA==} - - '@atproto/identity@0.4.12': - resolution: {integrity: sha512-P+Jn0HvKhIh1tps5n3xGrCxt+XiFWzp4kdgloyFhFmVLwjDU547DQkWx4r5Vhuiah7fRZGVSlk39R4U6SPrACg==} - engines: {node: '>=18.7.0'} - - '@atproto/jwk-jose@0.1.11': - resolution: {integrity: sha512-i4Fnr2sTBYmMmHXl7NJh8GrCH+tDQEVWrcDMDnV5DjJfkgT17wIqvojIw9SNbSL4Uf0OtfEv6AgG0A+mgh8b5Q==} - - '@atproto/jwk@0.6.0': - resolution: {integrity: sha512-bDoJPvt7TrQVi/rBfBrSSpGykhtIriKxeYCYQTiPRKFfyRhbgpElF0wPXADjIswnbzZdOwbY63az4E/CFVT3Tw==} - - '@atproto/lex-cbor@0.0.13': - resolution: {integrity: sha512-63nbzXJnQwV02XGpEa8WZxt7Zu87dnbzrUVL0Mqr55S1EGCzEF9U7Dauc9tKKLoZ88GmYrJN0irBsXtSi0VeWg==} + '@atproto/crypto@0.5.4': + resolution: {integrity: sha512-UR0BkuYNYuFtw+dA+y/oPPxzX0SWRnGJ+1Cfh/jGP1BvjRUyezK3omjpeLms5fYrXbM9vnfX+ckJFJqkBgLOdw==} + engines: {node: '>=22'} - '@atproto/lex-client@0.0.13': - resolution: {integrity: sha512-NftQ9SSIilMFFj99fBlv1hvZ6Oe4Bl+HYn4VkXrWsGrHeOIM3GLgVZSMWAlg33rCvd6bYfb+YnIdPxcV6lCU0g==} + '@atproto/did@0.5.4': + resolution: {integrity: sha512-BlnwQ+obL+4ZA71KH/EzZ3TY+cpSxnLiUI85mjBJIQwvDF/oN2sQA18wIj9jpduviIt2b/cMtlJuzHjzBkfXvw==} + engines: {node: '>=22'} - '@atproto/lex-data@0.0.12': - resolution: {integrity: sha512-aekJudcK1p6sbTqUv2bJMJBAGZaOJS0mgDclpK3U6VuBREK/au4B6ffunBFWgrDfg0Vwj2JGyEA7E51WZkJcRw==} + '@atproto/identity@0.5.7': + resolution: {integrity: sha512-a2/pFBZH3l1olWx1VJgPhnTSV47rOyTfeoaWHtxQqZcpjx3pbXaaNMTGvUZblgTpVj7y9rhFL2ieU7YrNDt3+Q==} + engines: {node: '>=22'} - '@atproto/lex-document@0.0.14': - resolution: {integrity: sha512-BaCSZOZUIv3kQ23b3Lhe4sprJYHc0spSeWS3TLaMoVi6bFZ3RzeM8n7ROTzVP3BTNYxpRHPHtOarx9A8ZAAC4w==} + '@atproto/jwk-jose@0.2.4': + resolution: {integrity: sha512-gzDoA0JTwnc0ZJOBLM7WX9xFxtynRS2K1Bofb8epzoMWDQvyvfbPcfkdPrKFM7NXCFUVpGpBnsCB8KFPTf1rCg==} + engines: {node: '>=22'} - '@atproto/lex-json@0.0.12': - resolution: {integrity: sha512-XlEpnWWZdDJ5BIgG25GyH+6iBfyrFL18BI5JSE6rUfMObbFMrQRaCuRLQfryRXNysVz3L3U+Qb9y8KcXbE8AcA==} + '@atproto/jwk@0.7.4': + resolution: {integrity: sha512-tq7TUDmNfe1yDfpRgdGQMJdl9TUlJmREQNCag9yg5w8Evu+TOiFiLgiOCbo7X4ouRPSgd1DpOzXbUa8UyKKMZA==} + engines: {node: '>=22'} - '@atproto/lex-resolver@0.0.15': - resolution: {integrity: sha512-oNxcNCts3ReJ+A4hTPthQbPA68yMQ0ZrBUIiUTZwRazrmg/xkV15jtyYSnH4CGBjRdt420MV9aLR2s4qLSmyTQ==} + '@atproto/lex-builder@0.1.9': + resolution: {integrity: sha512-NfvrxND66jibS9BDRCaKFVjubeaCenVz0j++yFGv10L+POu1XU6cc4naIjnLg9uvZrB4GXG8fvZJpyvfNghn0A==} + engines: {node: '>=22'} - '@atproto/lex-schema@0.0.13': - resolution: {integrity: sha512-FeY4YBesEUO4Ey3BJhDRma0cZt6XxunSZPXny5Q/6ltc7pvyJGXXtJ8D7mHl7p5EXPwylEYOQkM6ck4IyfMP0A==} + '@atproto/lex-cbor@0.1.5': + resolution: {integrity: sha512-JKDVu1/+QrRXy+cM72Ze7N7N3GUxhs3+wmwf3DyRQbDO0bdseC8mvZ9TYXhthM1Y92+pnCHIPci41etx8wz75Q==} + engines: {node: '>=22'} - '@atproto/lexicon@0.6.1': - resolution: {integrity: sha512-/vI1kVlY50Si+5MXpvOucelnYwb0UJ6Qto5mCp+7Q5C+Jtp+SoSykAPVvjVtTnQUH2vrKOFOwpb3C375vSKzXw==} + '@atproto/lex-client@0.3.1': + resolution: {integrity: sha512-GK5rhqOtIhzxd2BEByQTs1/K7u1QJ+0NkkksV94HgrTAHG+0S3DBg5P5+RPQu2uhHIDKFzXFhP5L+fFJubX4GQ==} + engines: {node: '>=22'} - '@atproto/oauth-provider-api@0.3.7': - resolution: {integrity: sha512-7yU9vuQFt/hy4NzlDtn+LuhIGvVKkhgWAkCmopnseMPBw6oGPT90uOsTxMkVGtHuKVvBSz7hOXoELXpnZq3gDQ==} + '@atproto/lex-data@0.1.6': + resolution: {integrity: sha512-rvovRrYWDEuerX+ZeQbdI5dKs0bYvuMjhwDOtT+pkOvdY4MkfN41DJCz/RAYVAQ7noPdaalB6pAX9euukheE/w==} + engines: {node: '>=22'} - '@atproto/oauth-provider-frontend@0.2.9': - resolution: {integrity: sha512-m1NhqC7ydEpF5ELV13CxkgCawyMX8wbAXls/4af3Tsz3IOkdM6zPgQeYK7Zj+7Nywjml4mZTkJ8rZiT6O9+5sw==} - engines: {node: '>=18.7.0'} + '@atproto/lex-document@0.1.7': + resolution: {integrity: sha512-DcJagPJpjg3MTPhpDQRkjJDmSDPZy4F9MRftsni757qLZ1NCE5dYjfg9XX6DPya6/JvscAV7qga/CJrHeUFyiw==} + engines: {node: '>=22'} - '@atproto/oauth-provider-ui@0.4.3': - resolution: {integrity: sha512-BLNZmtOwoHu2qk/Oi6dUR8TcXQaJre6wgW8YjkW5bKf+Vftn3PGzh8bKgr1fQZDYweZ6AF148imA2OzVSAaLHQ==} - engines: {node: '>=18.7.0'} + '@atproto/lex-installer@0.1.11': + resolution: {integrity: sha512-7sH0GcRKneaR7GBdgkM/f59huz2jebTrsfgQ50RonfZKPqfO/6uNwIjsJb8r6BjSxM7I7Os2z6LWdPVMYyoYDg==} + engines: {node: '>=22'} - '@atproto/oauth-provider@0.15.10': - resolution: {integrity: sha512-Iyh5F0nGQD+CrTV5YC52MiMySDBalCP0gj70naov5zgQ85qpvdr2W1puCv6IabB7/5LVL6bLgU0d7hbNfXhxDA==} - engines: {node: '>=18.7.0'} + '@atproto/lex-json@0.1.5': + resolution: {integrity: sha512-qOp6+Za5p/xDXACYIP3vySbKaf9CJDwiZEN78ghPT1gRoJohKG3ONt/WmvOxBVJqc6VTRaqEpbG9vX9oLF2qsA==} + engines: {node: '>=22'} - '@atproto/oauth-scopes@0.3.1': - resolution: {integrity: sha512-eUD9C78uYH+0ZUmiV/X6pRj4BKlH9I1xxJYW1Gb/qJiATuTZkJVm02urJb/BkWX4Qpxy4rOr8EProNg1wByIEA==} + '@atproto/lex-resolver@0.2.4': + resolution: {integrity: sha512-S7lxWp8CT5Par3eTq+VUXVpu6W5zHarq9oKZ6UneN0925Ey6juJ/kZI0jucxIpV/DXM+EF3GntOMbackFPxieg==} + engines: {node: '>=22'} - '@atproto/oauth-types@0.6.2': - resolution: {integrity: sha512-2cuboM4RQBCYR8NQC5uGRkW6KgCgKyq/B5/+tnMmWZYtZGVUQvsUWQHK/ZiMCnVXbcDNtc/RIEJQJDZ8FXMoxg==} + '@atproto/lex-schema@0.2.3': + resolution: {integrity: sha512-218xvlL7EdEdREY7ieAhaVPnBeGAf5h3tNkzE+X1Ob4tFLrWwA1sOQqliIURG9owE8v0ctqf9Uftx15eN0U0qQ==} + engines: {node: '>=22'} - '@atproto/oauth-types@0.6.3': - resolution: {integrity: sha512-jdKuoPknJuh/WjI+mYk7agSbx9mNVMbS6Dr3k1z2YMY2oRiCQjxYBuo4MLKATbxj05nMQaZRWlHRUazoAu5Cng==} + '@atproto/lex@0.3.2': + resolution: {integrity: sha512-/HcdVoq2BtkHxdWRo3vCciT6Cge9LfHVYtatnBrGlgHw6LYvjG2LHxmquhfPj26mKa3n2zVM8+3ia9RpoemSxw==} + engines: {node: '>=22'} + hasBin: true - '@atproto/pds@0.4.211': - resolution: {integrity: sha512-kEvdHHkpKXFrOuMo0vDDa12A2WkDSg+uu7JYonDxlFo+d2xHVCyZlCSmmtXOy9zr2cUBGx5YyNaJGv4UCVdH4w==} - engines: {node: '>=18.7.0'} + '@atproto/lexicon@0.7.9': + resolution: {integrity: sha512-/NoDu8xE0EmT0uCgTy03r9FIEjBSNN+xVHacJ+h+MW4MTMgegLdKlLnCySl2dzhYPu3/TK28CY4xIeEHz//GAw==} + engines: {node: '>=22'} - '@atproto/repo@0.8.12': - resolution: {integrity: sha512-QpVTVulgfz5PUiCTELlDBiRvnsnwrFWi+6CfY88VwXzrRHd9NE8GItK7sfxQ6U65vD/idH8ddCgFrlrsn1REPQ==} - engines: {node: '>=18.7.0'} + '@atproto/oauth-provider-api@0.7.7': + resolution: {integrity: sha512-CCC/p2LWqrCwPbvDFiNkZekk9nwqyldOAK0nr8OSIjOZ1eMQ9M335ghwD5+zm/TS55Z/Bf1AxlqX0JIK60dg/Q==} + engines: {node: '>=22'} - '@atproto/syntax@0.4.3': - resolution: {integrity: sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA==} + '@atproto/oauth-provider-ui@0.8.9': + resolution: {integrity: sha512-RKSEoIRv2aCcJPBP0b1k1hxPgu8p8+L1JmCxw7jLF9IbOWe2zSsNMbwQbE4tygKwIClx3JSUL1OCYZgbylsz8g==} + engines: {node: '>=22'} - '@atproto/ws-client@0.0.4': - resolution: {integrity: sha512-dox1XIymuC7/ZRhUqKezIGgooZS45C6vHCfu0PnWjfvsLCK2kAlnvX4IBkA/WpcoijDhQ9ejChnFbo/sLmgvAg==} - engines: {node: '>=18.7.0'} + '@atproto/oauth-provider@0.21.1': + resolution: {integrity: sha512-C3O82D0bAD0oGy/h6Afwgnx2Z+A+IBf+lS3uz6ow9edZEk61QVc4PM9z+uJtusZ0ZJqUCrFAwT3lkDFUcSEPYQ==} + engines: {node: '>=22'} - '@atproto/xrpc-server@0.10.14': - resolution: {integrity: sha512-vzpf5rs/eP/EIqPgw8Qzv/FaVr11MxyvGiZoe4+JPCPdCjzJIoZEY0dPP2x6dPx8MV8urRWnVApaYQgpVzIIIg==} - engines: {node: '>=18.7.0'} + '@atproto/oauth-scopes@0.5.7': + resolution: {integrity: sha512-RWhz93nR8R0R2vbwjbUemktjsVIkjrw6IAlNlcSQyzFvmsI/grWeZIHdRLGK1CC1WIQlyDYPWG8hChlQq63Iyw==} + engines: {node: '>=22'} - '@atproto/xrpc@0.7.7': - resolution: {integrity: sha512-K1ZyO/BU8JNtXX5dmPp7b5UrkLMMqpsIa/Lrj5D3Su+j1Xwq1m6QJ2XJ1AgjEjkI1v4Muzm7klianLE6XGxtmA==} + '@atproto/oauth-types@0.7.5': + resolution: {integrity: sha512-x75O0HsKB1IGfBikAQrrTX6EL8Rt4Q0+wMcwhZQRGPk/N/WqbYbsW3Powj4R8ZJKSfWCpVfaw32Piu1pTi891Q==} + engines: {node: '>=22'} - '@aws-crypto/crc32@5.2.0': - resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} - engines: {node: '>=16.0.0'} + '@atproto/pds@0.5.23': + resolution: {integrity: sha512-3zE1dDEuYsLP6p1F1pYUa2Dwh7mJ3sudhG2gcaVaHc48YyvInrpn4wI62sg7vL4KBdAJdmmaUh1lSiVMd6SSgw==} + engines: {node: '>=22'} - '@aws-crypto/crc32c@5.2.0': - resolution: {integrity: sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==} + '@atproto/repo@0.10.7': + resolution: {integrity: sha512-nMV3y4d/KERrt9VOe3hcKyl/FVQeo6+54YKCt6bLiMLl8CPEtjwdVGwWlSkekaCoayjL0rTQ4zvjmKs+vCA1Ew==} + engines: {node: '>=22'} - '@aws-crypto/sha1-browser@5.2.0': - resolution: {integrity: sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==} + '@atproto/syntax@0.4.3': + resolution: {integrity: sha512-YoZUz40YAJr5nPwvCDWgodEOlt5IftZqPJvA0JDWjuZKD8yXddTwSzXSaKQAzGOpuM+/A3uXRtPzJJqlScc+iA==} - '@aws-crypto/sha256-browser@5.2.0': - resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + '@atproto/syntax@0.7.2': + resolution: {integrity: sha512-tZ1Tr0R9pK4bI4Zs69t29cjMlCFQvRNBeNkqJC5pGeNuCt64D0eoF7s/AlqeVmYppClmQ0xJquggGgsMDP6j7w==} + engines: {node: '>=22'} - '@aws-crypto/sha256-js@5.2.0': - resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} - engines: {node: '>=16.0.0'} + '@atproto/ws-client@0.1.8': + resolution: {integrity: sha512-EX9SpoOJY4QhDKXe/olJw2h+Lj10SMd0ErDSE8pf3aFmg4MEEA+KBMg7HO8b9U6SNSS6n2nl7GPe37D3rIJ5zw==} + engines: {node: '>=22'} - '@aws-crypto/supports-web-crypto@5.2.0': - resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + '@atproto/xrpc-server@0.11.13': + resolution: {integrity: sha512-i8wM2Cx022QhVqkafy+qMFZ8aGFb66siF3JpFch0R8/qVNKT8UqBlKzc7TI0Ef2Yh6c3BExVQ5kyWwdzL952zw==} + engines: {node: '>=22'} - '@aws-crypto/util@5.2.0': - resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + '@atproto/xrpc@0.8.8': + resolution: {integrity: sha512-5cZIaP775QpTZjSgqvt++ZEe5ZmSHFYNEMwa9IjXLkannitBPfEhIaGuHOI7If3Juc/sIkqVbAxedLSsRk1F6Q==} + engines: {node: '>=22'} - '@aws-sdk/client-cloudfront@3.994.0': - resolution: {integrity: sha512-43NqYL+JjPY38qVc1rjVQna3FNEO484AkQXAqBJByI7PIzURtrtSWv42PstI99t/GnGlZFikf72y+iclvz32pQ==} + '@aws-sdk/checksums@3.1000.24': + resolution: {integrity: sha512-7TWLjypP8kk3savsDBRuhZJx7mBuFFA2136BQhwwLllsAnO4Tmq/p+SXZaNxbuulkzUFz3BZzj0bb4YzexZcNQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-kms@3.994.0': - resolution: {integrity: sha512-fEDPmSJKgu1o4vRPO41ItR/9lhr7UuZ+2TXhPNvmxc/mIzI10/nX3YlSpHxMdFT7ONZqvOPE2wU61mzrQLFjZQ==} + '@aws-sdk/client-cloudfront@3.1101.0': + resolution: {integrity: sha512-oeGTFiZ6o80tZ7FPZ2SQElXO0sgeMTwF6ZKwibNSEt9fEUtYEzRd5rUMn626BnqJtG5iO/SoTRl7xP9Z/3RKNw==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-s3@3.994.0': - resolution: {integrity: sha512-zIVQt/XfE2zTFrcPEf8R+KRaRD1++XHMPRhxXM2kVA6NA6Aq/cFCUyYOYYwSbWLF/XeToaX1auYGn3IoZKruPQ==} + '@aws-sdk/client-kms@3.1101.0': + resolution: {integrity: sha512-qczxakJlFYG9E6nE2yWIJ2nXq0HbMzHxMSWIsyubPZqx1TANHd2CSJWDTkg+IsNKq+94tG+e+EJo3uADwZ6cdw==} engines: {node: '>=20.0.0'} - '@aws-sdk/client-sso@3.993.0': - resolution: {integrity: sha512-VLUN+wIeNX24fg12SCbzTUBnBENlL014yMKZvRhPkcn4wHR6LKgNrjsG3fZ03Xs0XoKaGtNFi1VVrq666sGBoQ==} + '@aws-sdk/client-s3@3.1101.0': + resolution: {integrity: sha512-16EFb1aTEBgPcfUAWAjjlB57IZCyn7B3rlfT+xqE7M6WoH8AMMU3vFZO0UOitwh/xvvzVx73YED1/n0PU4qBMw==} engines: {node: '>=20.0.0'} - '@aws-sdk/core@3.973.11': - resolution: {integrity: sha512-wdQ8vrvHkKIV7yNUKXyjPWKCdYEUrZTHJ8Ojd5uJxXp9vqPCkUR1dpi1NtOLcrDgueJH7MUH5lQZxshjFPSbDA==} + '@aws-sdk/core@3.977.4': + resolution: {integrity: sha512-CEkcQlMOQJCvul60U7wdAOACjtdgFWDsfJI+6wUOGdhGNV2lGbuJpi/R50QLpFG3Tp+sQxa/RmzC3X7KHbhuTA==} engines: {node: '>=20.0.0'} - '@aws-sdk/crc64-nvme@3.972.0': - resolution: {integrity: sha512-ThlLhTqX68jvoIVv+pryOdb5coP1cX1/MaTbB9xkGDCbWbsqQcLqzPxuSoW1DCnAAIacmXCWpzUNOB9pv+xXQw==} + '@aws-sdk/credential-provider-env@3.972.65': + resolution: {integrity: sha512-lJT2aRw9wCV8jPHyFJjdZLD4HTydL6/22AnCSOB8e/LqOc55nEJGLHkJQeSxhn8QiqyjFwPKQFtMw0ovjRUY/g==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-env@3.972.9': - resolution: {integrity: sha512-ZptrOwQynfupubvcngLkbdIq/aXvl/czdpEG8XJ8mN8Nb19BR0jaK0bR+tfuMU36Ez9q4xv7GGkHFqEEP2hUUQ==} + '@aws-sdk/credential-provider-http@3.972.67': + resolution: {integrity: sha512-N7fw/15hSwI/CPxe5ohOyb7O4ge9f5me1gVIn8OIkBRB0squ8OJqQyDyH/HoL+Sb1W5xdC88jVC+bHkw73iu+Q==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-http@3.972.11': - resolution: {integrity: sha512-hECWoOoH386bGr89NQc9vA/abkGf5TJrMREt+lhNcnSNmoBS04fK7vc3LrJBSQAUGGVj0Tz3f4dHB3w5veovig==} + '@aws-sdk/credential-provider-ini@3.973.10': + resolution: {integrity: sha512-Zh9XRaPnDN9buO7GfWBubS22R6Nq5D6hbyYEMN05LiOnXugm/8WDjUx6y756bSPbdn3aJB2qG4zFW3bN82QhoQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-ini@3.972.9': - resolution: {integrity: sha512-zr1csEu9n4eDiHMTYJabX1mDGuGLgjgUnNckIivvk43DocJC9/f6DefFrnUPZXE+GHtbW50YuXb+JIxKykU74A==} + '@aws-sdk/credential-provider-login@3.972.72': + resolution: {integrity: sha512-zZapIKwaHp7TdTf9hbH1I3CVUdEupmt7FXO/BoTQGC+4h6NkXKWpqF2p5WyfpjurDLHCpSyh+BzMlAg8arqWLA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-login@3.972.9': - resolution: {integrity: sha512-m4RIpVgZChv0vWS/HKChg1xLgZPpx8Z+ly9Fv7FwA8SOfuC6I3htcSaBz2Ch4bneRIiBUhwP4ziUo0UZgtJStQ==} + '@aws-sdk/credential-provider-node@3.972.76': + resolution: {integrity: sha512-1yzLmRiYSgGC25v7ZZEwJn/auhHHTIHgFOmzL2f36hf1+7jSLcX+1QrAz4760WEzPiiQl8xmlpFhHfl2OoyVzA==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-node@3.972.10': - resolution: {integrity: sha512-70nCESlvnzjo4LjJ8By8MYIiBogkYPSXl3WmMZfH9RZcB/Nt9qVWbFpYj6Fk1vLa4Vk8qagFVeXgxdieMxG1QA==} + '@aws-sdk/credential-provider-process@3.972.65': + resolution: {integrity: sha512-e5DbbNteOSalN58U83G6kFa4ECLEuGbGqNBHIXE7zYXA/m4GHblIGjFbSH7wYv6gBV8iNSDcRZBKfQZF5vF9nw==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-process@3.972.9': - resolution: {integrity: sha512-gOWl0Fe2gETj5Bk151+LYKpeGi2lBDLNu+NMNpHRlIrKHdBmVun8/AalwMK8ci4uRfG5a3/+zvZBMpuen1SZ0A==} + '@aws-sdk/credential-provider-sso@3.973.9': + resolution: {integrity: sha512-0V0u4t+KBku9fbh5CPCaC5hUWwSzDafp8nCuDy817zWbp2gz80jO44rMQkiwnZ+k54B+tjAtzRy00DJRGTKGBg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-sso@3.972.9': - resolution: {integrity: sha512-ey7S686foGTArvFhi3ifQXmgptKYvLSGE2250BAQceMSXZddz7sUSNERGJT2S7u5KIe/kgugxrt01hntXVln6w==} + '@aws-sdk/credential-provider-web-identity@3.972.71': + resolution: {integrity: sha512-e4dwiRltGAaQ+2yxw57Hj0l/BF3BHiG14+QpYE7bGYBlpAq/fkIri2BDhjWon8c0mhhtd2txQBAkQb9BcTStFg==} engines: {node: '>=20.0.0'} - '@aws-sdk/credential-provider-web-identity@3.972.9': - resolution: {integrity: sha512-8LnfS76nHXoEc9aRRiMMpxZxJeDG0yusdyo3NvPhCgESmBUgpMa4luhGbClW5NoX/qRcGxxM6Z/esqANSNMTow==} + '@aws-sdk/lib-storage@3.1101.0': + resolution: {integrity: sha512-S2kYJzmX8a9SPgesOn8la1Ezuto8OqI4e85yNdiOJ/trLMrBxxEZoz8CtOk0uT3FjEJXVbYApzLNmVGVLvUwxA==} engines: {node: '>=20.0.0'} - - '@aws-sdk/lib-storage@3.879.0': - resolution: {integrity: sha512-FAb3vOfLIrf8lPuDoxKRu18DxXfQLEFm7MoXi0jd8ooFjD09jpVCQGNrRuMCqc688wrx7zJSovWObtn4LRjvrg==} - engines: {node: '>=18.0.0'} peerDependencies: - '@aws-sdk/client-s3': ^3.879.0 - - '@aws-sdk/middleware-bucket-endpoint@3.972.3': - resolution: {integrity: sha512-fmbgWYirF67YF1GfD7cg5N6HHQ96EyRNx/rDIrTF277/zTWVuPI2qS/ZHgofwR1NZPe/NWvoppflQY01LrbVLg==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/middleware-expect-continue@3.972.3': - resolution: {integrity: sha512-4msC33RZsXQpUKR5QR4HnvBSNCPLGHmB55oDiROqqgyOc+TOfVu2xgi5goA7ms6MdZLeEh2905UfWMnMMF4mRg==} - engines: {node: '>=20.0.0'} + '@aws-sdk/client-s3': ^3.1101.0 - '@aws-sdk/middleware-flexible-checksums@3.972.9': - resolution: {integrity: sha512-E663+r/UQpvF3aJkD40p5ZANVQFsUcbE39jifMtN7wc0t1M0+2gJJp3i75R49aY9OiSX5lfVyPUNjN/BNRCCZA==} + '@aws-sdk/middleware-sdk-s3@3.972.70': + resolution: {integrity: sha512-APdP0iODt39AkjCjzTFIoFrxDH/Cz3CpWRDKLcsJg7eOnfE1htkxL9BhDoe/xL7cXdoMwh2HBYv3DiT1uf64NQ==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-host-header@3.972.3': - resolution: {integrity: sha512-aknPTb2M+G3s+0qLCx4Li/qGZH8IIYjugHMv15JTYMe6mgZO8VBpYgeGYsNMGCqCZOcWzuf900jFBG5bopfzmA==} + '@aws-sdk/nested-clients@3.997.39': + resolution: {integrity: sha512-wU5NPnj62Sb7A8xn/Zb+xThe05P3otNtDl37iOIi5DDMeCesNeCckaG+eXWGUs12Z9R34I8CD05TaTe6SIa61g==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-location-constraint@3.972.3': - resolution: {integrity: sha512-nIg64CVrsXp67vbK0U1/Is8rik3huS3QkRHn2DRDx4NldrEFMgdkZGI/+cZMKD9k4YOS110Dfu21KZLHrFA/1g==} + '@aws-sdk/signature-v4-multi-region@3.996.43': + resolution: {integrity: sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-logger@3.972.3': - resolution: {integrity: sha512-Ftg09xNNRqaz9QNzlfdQWfpqMCJbsQdnZVJP55jfhbKi1+FTWxGuvfPoBhDHIovqWKjqbuiew3HuhxbJ0+OjgA==} + '@aws-sdk/token-providers@3.1100.0': + resolution: {integrity: sha512-THf3MkgY3fNJZ3zdgSenLqR7gSE68KccCj1RCKretlG73Ppszvues02VpCUO9NlB/tZDC483FvGCld+AiPCkvg==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-recursion-detection@3.972.3': - resolution: {integrity: sha512-PY57QhzNuXHnwbJgbWYTrqIDHYSeOlhfYERTAuc16LKZpTZRJUjzBFokp9hF7u1fuGeE3D70ERXzdbMBOqQz7Q==} + '@aws-sdk/types@3.974.2': + resolution: {integrity: sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-sdk-s3@3.972.11': - resolution: {integrity: sha512-Qr0T7ZQTRMOuR6ahxEoJR1thPVovfWrKB2a6KBGR+a8/ELrFodrgHwhq50n+5VMaGuLtGhHiISU3XGsZmtmVXQ==} + '@aws-sdk/xml-builder@3.972.37': + resolution: {integrity: sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==} engines: {node: '>=20.0.0'} - '@aws-sdk/middleware-ssec@3.972.3': - resolution: {integrity: sha512-dU6kDuULN3o3jEHcjm0c4zWJlY1zWVkjG9NPe9qxYLLpcbdj5kRYBS2DdWYD+1B9f910DezRuws7xDEqKkHQIg==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/middleware-user-agent@3.972.11': - resolution: {integrity: sha512-R8CvPsPHXwzIHCAza+bllY6PrctEk4lYq/SkHJz9NLoBHCcKQrbOcsfXxO6xmipSbUNIbNIUhH0lBsJGgsRdiw==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/nested-clients@3.993.0': - resolution: {integrity: sha512-iOq86f2H67924kQUIPOAvlmMaOAvOLoDOIb66I2YqSUpMYB6ufiuJW3RlREgskxv86S5qKzMnfy/X6CqMjK6XQ==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/region-config-resolver@3.972.3': - resolution: {integrity: sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/signature-v4-multi-region@3.994.0': - resolution: {integrity: sha512-8y04Lv497KKd7f2TVlm2RaKQaNfnY17ZH8d3m+7sW/3R3BhZvHgWQZyqTb/vcN2ERz1YAnWx6woJyB3ZNFvakw==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/token-providers@3.993.0': - resolution: {integrity: sha512-+35g4c+8r7sB9Sjp1KPdM8qxGn6B/shBjJtEUN4e+Edw9UEQlZKIzioOGu3UAbyE0a/s450LdLZr4wbJChtmww==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/types@3.973.1': - resolution: {integrity: sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/util-arn-parser@3.972.2': - resolution: {integrity: sha512-VkykWbqMjlSgBFDyrY3nOSqupMc6ivXuGmvci6Q3NnLq5kC+mKQe2QBZ4nrWRE/jqOxeFP2uYzLtwncYYcvQDg==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/util-endpoints@3.993.0': - resolution: {integrity: sha512-j6vioBeRZ4eHX4SWGvGPpwGg/xSOcK7f1GL0VM+rdf3ZFTIsUEhCFmD78B+5r2PgztcECSzEfvHQX01k8dPQPw==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/util-endpoints@3.994.0': - resolution: {integrity: sha512-L2obUBw4ACMMd1F/SG5LdfPyZ0xJNs9Maifwr3w0uWO+4YvHmk9FfRskfSfE/SLZ9S387oSZ+1xiP7BfVCP/Og==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/util-locate-window@3.965.4': - resolution: {integrity: sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog==} - engines: {node: '>=20.0.0'} - - '@aws-sdk/util-user-agent-browser@3.972.3': - resolution: {integrity: sha512-JurOwkRUcXD/5MTDBcqdyQ9eVedtAsZgw5rBwktsPTN7QtPiS2Ld1jkJepNgYoCufz1Wcut9iup7GJDoIHp8Fw==} - - '@aws-sdk/util-user-agent-node@3.972.9': - resolution: {integrity: sha512-JNswdsLdQemxqaSIBL2HRhsHPUBBziAgoi5RQv6/9avmE5g5RSdt1hWr3mHJ7OxqRYf+KeB11ExWbiqfrnoeaA==} - engines: {node: '>=20.0.0'} - peerDependencies: - aws-crt: '>=1.0.0' - peerDependenciesMeta: - aws-crt: - optional: true - - '@aws-sdk/xml-builder@3.972.5': - resolution: {integrity: sha512-mCae5Ys6Qm1LDu0qdGwx2UQ63ONUe+FHw908fJzLDqFKTDBK4LDZUqKWm4OkTCNFq19bftjsBSESIGLD/s3/rA==} - engines: {node: '>=20.0.0'} - - '@aws/lambda-invoke-store@0.2.3': - resolution: {integrity: sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==} + '@aws/lambda-invoke-store@0.3.0': + resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} engines: {node: '>=18.0.0'} '@babel/code-frame@7.29.0': @@ -1338,220 +1287,28 @@ packages: '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} - '@smithy/abort-controller@4.2.8': - resolution: {integrity: sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==} - engines: {node: '>=18.0.0'} - - '@smithy/chunked-blob-reader-native@4.2.1': - resolution: {integrity: sha512-lX9Ay+6LisTfpLid2zZtIhSEjHMZoAR5hHCR4H7tBz/Zkfr5ea8RcQ7Tk4mi0P76p4cN+Btz16Ffno7YHpKXnQ==} - engines: {node: '>=18.0.0'} - - '@smithy/chunked-blob-reader@5.2.0': - resolution: {integrity: sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA==} - engines: {node: '>=18.0.0'} - - '@smithy/config-resolver@4.4.6': - resolution: {integrity: sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==} - engines: {node: '>=18.0.0'} - - '@smithy/core@3.23.2': - resolution: {integrity: sha512-HaaH4VbGie4t0+9nY3tNBRSxVTr96wzIqexUa6C2qx3MPePAuz7lIxPxYtt1Wc//SPfJLNoZJzfdt0B6ksj2jA==} - engines: {node: '>=18.0.0'} - - '@smithy/credential-provider-imds@4.2.8': - resolution: {integrity: sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==} - engines: {node: '>=18.0.0'} - - '@smithy/eventstream-codec@4.2.8': - resolution: {integrity: sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw==} - engines: {node: '>=18.0.0'} - - '@smithy/eventstream-serde-browser@4.2.8': - resolution: {integrity: sha512-MTfQT/CRQz5g24ayXdjg53V0mhucZth4PESoA5IhvaWVDTOQLfo8qI9vzqHcPsdd2v6sqfTYqF5L/l+pea5Uyw==} - engines: {node: '>=18.0.0'} - - '@smithy/eventstream-serde-config-resolver@4.3.8': - resolution: {integrity: sha512-ah12+luBiDGzBruhu3efNy1IlbwSEdNiw8fOZksoKoWW1ZHvO/04MQsdnws/9Aj+5b0YXSSN2JXKy/ClIsW8MQ==} - engines: {node: '>=18.0.0'} - - '@smithy/eventstream-serde-node@4.2.8': - resolution: {integrity: sha512-cYpCpp29z6EJHa5T9WL0KAlq3SOKUQkcgSoeRfRVwjGgSFl7Uh32eYGt7IDYCX20skiEdRffyDpvF2efEZPC0A==} - engines: {node: '>=18.0.0'} - - '@smithy/eventstream-serde-universal@4.2.8': - resolution: {integrity: sha512-iJ6YNJd0bntJYnX6s52NC4WFYcZeKrPUr1Kmmr5AwZcwCSzVpS7oavAmxMR7pMq7V+D1G4s9F5NJK0xwOsKAlQ==} - engines: {node: '>=18.0.0'} - - '@smithy/fetch-http-handler@5.3.9': - resolution: {integrity: sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==} - engines: {node: '>=18.0.0'} - - '@smithy/hash-blob-browser@4.2.9': - resolution: {integrity: sha512-m80d/iicI7DlBDxyQP6Th7BW/ejDGiF0bgI754+tiwK0lgMkcaIBgvwwVc7OFbY4eUzpGtnig52MhPAEJ7iNYg==} - engines: {node: '>=18.0.0'} - - '@smithy/hash-node@4.2.8': - resolution: {integrity: sha512-7ZIlPbmaDGxVoxErDZnuFG18WekhbA/g2/i97wGj+wUBeS6pcUeAym8u4BXh/75RXWhgIJhyC11hBzig6MljwA==} - engines: {node: '>=18.0.0'} - - '@smithy/hash-stream-node@4.2.8': - resolution: {integrity: sha512-v0FLTXgHrTeheYZFGhR+ehX5qUm4IQsjAiL9qehad2cyjMWcN2QG6/4mSwbSgEQzI7jwfoXj7z4fxZUx/Mhj2w==} - engines: {node: '>=18.0.0'} - - '@smithy/invalid-dependency@4.2.8': - resolution: {integrity: sha512-N9iozRybwAQ2dn9Fot9kI6/w9vos2oTXLhtK7ovGqwZjlOcxu6XhPlpLpC+INsxktqHinn5gS2DXDjDF2kG5sQ==} - engines: {node: '>=18.0.0'} - - '@smithy/is-array-buffer@2.2.0': - resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} - engines: {node: '>=14.0.0'} - - '@smithy/is-array-buffer@4.2.0': - resolution: {integrity: sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==} - engines: {node: '>=18.0.0'} - - '@smithy/md5-js@4.2.8': - resolution: {integrity: sha512-oGMaLj4tVZzLi3itBa9TCswgMBr7k9b+qKYowQ6x1rTyTuO1IU2YHdHUa+891OsOH+wCsH7aTPRsTJO3RMQmjQ==} - engines: {node: '>=18.0.0'} - - '@smithy/middleware-content-length@4.2.8': - resolution: {integrity: sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A==} - engines: {node: '>=18.0.0'} - - '@smithy/middleware-endpoint@4.4.16': - resolution: {integrity: sha512-L5GICFCSsNhbJ5JSKeWFGFy16Q2OhoBizb3X2DrxaJwXSEujVvjG9Jt386dpQn2t7jINglQl0b4K/Su69BdbMA==} - engines: {node: '>=18.0.0'} - - '@smithy/middleware-retry@4.4.33': - resolution: {integrity: sha512-jLqZOdJhtIL4lnA9hXnAG6GgnJlo1sD3FqsTxm9wSfjviqgWesY/TMBVnT84yr4O0Vfe0jWoXlfFbzsBVph3WA==} - engines: {node: '>=18.0.0'} - - '@smithy/middleware-serde@4.2.9': - resolution: {integrity: sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==} - engines: {node: '>=18.0.0'} - - '@smithy/middleware-stack@4.2.8': - resolution: {integrity: sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA==} - engines: {node: '>=18.0.0'} - - '@smithy/node-config-provider@4.3.8': - resolution: {integrity: sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==} - engines: {node: '>=18.0.0'} - - '@smithy/node-http-handler@4.4.10': - resolution: {integrity: sha512-u4YeUwOWRZaHbWaebvrs3UhwQwj+2VNmcVCwXcYTvPIuVyM7Ex1ftAj+fdbG/P4AkBwLq/+SKn+ydOI4ZJE9PA==} - engines: {node: '>=18.0.0'} - - '@smithy/property-provider@4.2.8': - resolution: {integrity: sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==} - engines: {node: '>=18.0.0'} - - '@smithy/protocol-http@5.3.8': - resolution: {integrity: sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ==} - engines: {node: '>=18.0.0'} - - '@smithy/querystring-builder@4.2.8': - resolution: {integrity: sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==} - engines: {node: '>=18.0.0'} - - '@smithy/querystring-parser@4.2.8': - resolution: {integrity: sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA==} - engines: {node: '>=18.0.0'} - - '@smithy/service-error-classification@4.2.8': - resolution: {integrity: sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ==} - engines: {node: '>=18.0.0'} - - '@smithy/shared-ini-file-loader@4.4.3': - resolution: {integrity: sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg==} - engines: {node: '>=18.0.0'} - - '@smithy/signature-v4@5.3.8': - resolution: {integrity: sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==} - engines: {node: '>=18.0.0'} - - '@smithy/smithy-client@4.11.5': - resolution: {integrity: sha512-xixwBRqoeP2IUgcAl3U9dvJXc+qJum4lzo3maaJxifsZxKUYLfVfCXvhT4/jD01sRrHg5zjd1cw2Zmjr4/SuKQ==} + '@smithy/core@3.31.1': + resolution: {integrity: sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg==} engines: {node: '>=18.0.0'} - '@smithy/types@4.12.0': - resolution: {integrity: sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==} + '@smithy/credential-provider-imds@4.4.16': + resolution: {integrity: sha512-QfuLWAkLzptffFW980AFeHZFdqds2B64rpEd3uJ6lgs3xVn9QegGMUgUcj+4d7dRrAsya3r58ZKpku97WcFb4w==} engines: {node: '>=18.0.0'} - '@smithy/url-parser@4.2.8': - resolution: {integrity: sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==} + '@smithy/fetch-http-handler@5.6.13': + resolution: {integrity: sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A==} engines: {node: '>=18.0.0'} - '@smithy/util-base64@4.3.0': - resolution: {integrity: sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==} + '@smithy/node-http-handler@4.9.13': + resolution: {integrity: sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==} engines: {node: '>=18.0.0'} - '@smithy/util-body-length-browser@4.2.0': - resolution: {integrity: sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==} + '@smithy/signature-v4@5.6.12': + resolution: {integrity: sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ==} engines: {node: '>=18.0.0'} - '@smithy/util-body-length-node@4.2.1': - resolution: {integrity: sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==} - engines: {node: '>=18.0.0'} - - '@smithy/util-buffer-from@2.2.0': - resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} - engines: {node: '>=14.0.0'} - - '@smithy/util-buffer-from@4.2.0': - resolution: {integrity: sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==} - engines: {node: '>=18.0.0'} - - '@smithy/util-config-provider@4.2.0': - resolution: {integrity: sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==} - engines: {node: '>=18.0.0'} - - '@smithy/util-defaults-mode-browser@4.3.32': - resolution: {integrity: sha512-092sjYfFMQ/iaPH798LY/OJFBcYu0sSK34Oy9vdixhsU36zlZu8OcYjF3TD4e2ARupyK7xaxPXl+T0VIJTEkkg==} - engines: {node: '>=18.0.0'} - - '@smithy/util-defaults-mode-node@4.2.35': - resolution: {integrity: sha512-miz/ggz87M8VuM29y7jJZMYkn7+IErM5p5UgKIf8OtqVs/h2bXr1Bt3uTsREsI/4nK8a0PQERbAPsVPVNIsG7Q==} - engines: {node: '>=18.0.0'} - - '@smithy/util-endpoints@3.2.8': - resolution: {integrity: sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw==} - engines: {node: '>=18.0.0'} - - '@smithy/util-hex-encoding@4.2.0': - resolution: {integrity: sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==} - engines: {node: '>=18.0.0'} - - '@smithy/util-middleware@4.2.8': - resolution: {integrity: sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A==} - engines: {node: '>=18.0.0'} - - '@smithy/util-retry@4.2.8': - resolution: {integrity: sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg==} - engines: {node: '>=18.0.0'} - - '@smithy/util-stream@4.5.12': - resolution: {integrity: sha512-D8tgkrmhAX/UNeCZbqbEO3uqyghUnEmmoO9YEvRuwxjlkKKUE7FOgCJnqpTlQPe9MApdWPky58mNQQHbnCzoNg==} - engines: {node: '>=18.0.0'} - - '@smithy/util-uri-escape@4.2.0': - resolution: {integrity: sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==} - engines: {node: '>=18.0.0'} - - '@smithy/util-utf8@2.3.0': - resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} - engines: {node: '>=14.0.0'} - - '@smithy/util-utf8@4.2.0': - resolution: {integrity: sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==} - engines: {node: '>=18.0.0'} - - '@smithy/util-waiter@4.2.8': - resolution: {integrity: sha512-n+lahlMWk+aejGuax7DPWtqav8HYnWxQwR+LCG2BgCUmaGcTe9qZCFsmw8TMg9iG75HOwhrJCX9TCJRLH+Yzqg==} - engines: {node: '>=18.0.0'} - - '@smithy/uuid@1.1.0': - resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==} + '@smithy/types@4.16.1': + resolution: {integrity: sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==} engines: {node: '>=18.0.0'} '@stablelib/base64@1.0.1': @@ -1570,6 +1327,9 @@ packages: '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@ts-morph/common@0.28.1': + resolution: {integrity: sha512-W74iWf7ILp1ZKNYXY5qbddNaml7e9Sedv5lvU1V8lftlitkc9Pq1A+jlH23ltDgWYeZFFEqGCD1Ies9hqu3O+g==} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -1977,9 +1737,6 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} - await-lock@2.2.2: - resolution: {integrity: sha512-aDczADvlvTGajTDjcjpJMqRkOF6Qdz3YbPZm/PyW6tKPkx2hlYBzxMhEywM/tU72HrVZjgl5VCdRuMlA7pZ8Gw==} - axios@1.13.5: resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==} @@ -2067,9 +1824,6 @@ packages: resolution: {integrity: sha512-pbnl5XzGBdrFU/wT4jqmJVPn2B6UHPBOhzMQkY/SPUPB6QtUXtmBHBIwCbXJol93mOpGMnQyP/+BB19q04xj7g==} engines: {node: '>=4'} - better-sqlite3@10.1.0: - resolution: {integrity: sha512-hqpHJaCfKEZFaAWdMh6crdzRWyzQzfP6Ih8TYI0vFn01a6ZTDSbJIMXN+6AMBaBOh99DzUy8l3PsV9R3qnJDng==} - better-sqlite3@12.6.2: resolution: {integrity: sha512-8VYKM3MjCa9WcaSAI3hzwhmyHVlH8tiGFwf0RlTsZPWJ1I5MkzjiudCo4KC4DxOaL/53A5B1sI/IbldNFDbsKA==} engines: {node: 20.x || 22.x || 23.x || 24.x || 25.x} @@ -2149,6 +1903,10 @@ packages: resolution: {integrity: sha512-b3tFPA9pUr2zCUiCfRd2+wok2/LBSNUMKOuRRok+WlvvAgEt/PlbgPTsZUcwCOs53IJvLgTp0eotwtosE6njug==} hasBin: true + cborg@4.5.8: + resolution: {integrity: sha512-6/viltD51JklRhq4L7jC3zgy6gryuG5xfZ3kzpE+PravtyeQLeQmCYLREhQH7pWENg5pY4Yu/XCd6a7dKScVlw==} + hasBin: true + chai@6.2.2: resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} engines: {node: '>=18'} @@ -2173,10 +1931,17 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@9.0.1: + resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} + engines: {node: '>=20'} + cluster-key-slot@1.1.2: resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} engines: {node: '>=0.10.0'} + code-block-writer@13.0.3: + resolution: {integrity: sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -2397,6 +2162,9 @@ packages: elliptic@6.6.1: resolution: {integrity: sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==} + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -2456,6 +2224,10 @@ packages: engines: {node: '>=18'} hasBin: true + escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} @@ -2587,8 +2359,8 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} - eventemitter3@4.0.7: - resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==} + eventemitter3@5.0.4: + resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} @@ -2638,10 +2410,6 @@ packages: fast-sha256@1.3.0: resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} - fast-xml-parser@5.3.6: - resolution: {integrity: sha512-QNI3sAvSvaOiaMl8FYU4trnEzCwiRr8XMWgAHzlrWpTSj+QaCSvOf1h82OEP1s4hiAXhnbXSyFWCf4ldZzZRVA==} - hasBin: true - fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -2764,6 +2532,10 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-east-asian-width@1.6.0: + resolution: {integrity: sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -3144,14 +2916,14 @@ packages: knuth-shuffle-seeded@1.0.6: resolution: {integrity: sha512-9pFH0SplrfyKyojCLxZfMcvkhf5hH0d+UwR9nTVJ/DDQJGuzcXjTwB7TP7sDfehSudlGGaOLblmEWqv04ERVWg==} - kysely@0.22.0: - resolution: {integrity: sha512-ZE3qWtnqLOalodzfK5QUEcm7AEulhxsPNuKaGFsC3XiqO92vMLm+mAHk/NnbSIOtC4RmGm0nsv700i8KDp1gfQ==} - engines: {node: '>=14.0.0'} - kysely@0.28.11: resolution: {integrity: sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==} engines: {node: '>=20.0.0'} + kysely@0.29.4: + resolution: {integrity: sha512-y5mVgQNkMbs1eK9Xyc0pmNdabN2wHhRYY/5r4W5HrUT1rYCEPeVNSj1RUJeSDKT3U0p+mXCvLgkrFuIafYI6BA==} + engines: {node: '>=22.0.0'} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -3297,6 +3069,9 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + multiformats@13.4.2: + resolution: {integrity: sha512-eh6eHCrRi1+POZ3dA+Dq1C6jhP1GNtr9CRINMb67OKzqW9I5DUuZM/3jLPlzhgpGeiNUlEGEbkCYChXMCc/8DQ==} + multiformats@9.9.0: resolution: {integrity: sha512-HoMUjhH9T8DDBNT+6xzkrd9ga/XiBI4xLr58LJACwK6G3HTOPeMz4nB4KJs33L2BelrIJa7P0VuNaVF3hMYfjg==} @@ -3479,14 +3254,18 @@ packages: resolution: {integrity: sha512-y3b8Kpd8OAN444hxfBbFfj1FY/RjtTd8tzYwhUqNYXx0fXx2iX4maP4Qr6qhIKbQXI02wTLAda4fYUbDagTUFw==} engines: {node: '>=6'} - p-queue@6.6.2: - resolution: {integrity: sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==} - engines: {node: '>=8'} + p-queue@8.1.1: + resolution: {integrity: sha512-aNZ+VfjobsWryoiPnEApGGmf5WmNsCo9xu8dfaYamG5qaLP7ClhLN6NgsFe6SwJ2UbLEBK5dv9x8Mn5+RVhMWQ==} + engines: {node: '>=18'} p-timeout@3.2.0: resolution: {integrity: sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==} engines: {node: '>=8'} + p-timeout@6.1.4: + resolution: {integrity: sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg==} + engines: {node: '>=14.16'} + p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} @@ -3513,6 +3292,9 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -3567,8 +3349,8 @@ packages: pino-abstract-transport@3.0.0: resolution: {integrity: sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==} - pino-http@8.6.1: - resolution: {integrity: sha512-J0hiJgUExtBXP2BjrK4VB305tHXS31sCmWJ9XJo2wPkLHa1NFPuW4V9wjG27PAc2fmBCigiNhQKpvrx+kntBPA==} + pino-http@11.0.0: + resolution: {integrity: sha512-wqg5XIAGRRIWtTk8qPGxkbrfiwEWz1lgedVLvhLALudKXvg1/L2lTFgTGPJ4Z2e3qcRmxoFxDuSdMdMGNM6I1g==} pino-std-serializers@6.2.2: resolution: {integrity: sha512-cHjPPsE+vhj/tnhCy/wiMh3M3z3h/j15zHQX+S9GkTBgqJuTuJzYJ4gUyACLhDaJ7kk9ba9iRDmbH2tJU03OiA==} @@ -3982,6 +3764,14 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.2.2: + resolution: {integrity: sha512-GaPUh5gfdrYzqeVNZvUfT23vYYxXzKYidUcnMtJg/3rxRV63EFZy3k6xfKlmfeJD0176lnUV/Usr3XcwSvFzpg==} + engines: {node: '>=20'} + string.prototype.trim@1.2.10: resolution: {integrity: sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==} engines: {node: '>= 0.4'} @@ -4013,9 +3803,6 @@ packages: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} - strnum@2.1.2: - resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==} - strtok3@6.3.0: resolution: {integrity: sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==} engines: {node: '>=10'} @@ -4095,10 +3882,6 @@ packages: resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} engines: {node: '>=14.0.0'} - tlds@1.261.0: - resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==} - hasBin: true - to-regex-range@5.0.1: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} @@ -4127,6 +3910,9 @@ packages: resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} engines: {node: '>=6.10'} + ts-morph@27.0.2: + resolution: {integrity: sha512-fhUhgeljcrdZ+9DZND1De1029PrE+cMkIP7ooqkLRTrRLTqcki2AstsyJm0vRNbTbVCNJ0idGlbBrfqc7/nA8w==} + tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} @@ -4200,6 +3986,9 @@ packages: uint8arrays@3.0.0: resolution: {integrity: sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==} + uint8arrays@5.1.1: + resolution: {integrity: sha512-9muQwa4wZG4dKi9gMAIBtnk2Pw87SRpvWTH6lOGm19V2Uqxr4uomUf2PGqPnWc+qs06sN8owUU4jfcoWOcfwVQ==} + unbox-primitive@1.1.0: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} @@ -4211,6 +4000,14 @@ packages: resolution: {integrity: sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==} engines: {node: '>=18.17'} + undici@7.29.0: + resolution: {integrity: sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==} + engines: {node: '>=20.18.1'} + + undici@8.10.0: + resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==} + engines: {node: '>=22.19.0'} + unicode-segmenter@0.14.5: resolution: {integrity: sha512-jHGmj2LUuqDcX3hqY12Ql+uhUTn8huuxNZGq7GvtF6bSybzH3aFgedYu/KTzQStEgt1Ra2F3HxadNXsNjb3m3g==} @@ -4377,6 +4174,10 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + wrap-ansi@9.0.2: + resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} + engines: {node: '>=18'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -4396,11 +4197,23 @@ packages: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} + y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + yaml@2.8.3: resolution: {integrity: sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==} engines: {node: '>= 14.6'} hasBin: true + yargs-parser@22.0.0: + resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + + yargs@18.1.0: + resolution: {integrity: sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==} + engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -4416,13 +4229,13 @@ packages: snapshots: - '@atproto-labs/did-resolver@0.2.6': + '@atproto-labs/did-resolver@0.3.6': dependencies: - '@atproto-labs/fetch': 0.2.3 - '@atproto-labs/pipe': 0.1.1 - '@atproto-labs/simple-store': 0.3.0 - '@atproto-labs/simple-store-memory': 0.1.4 - '@atproto/did': 0.3.0 + '@atproto-labs/fetch': 0.3.5 + '@atproto-labs/pipe': 0.2.4 + '@atproto-labs/simple-store': 0.5.0 + '@atproto-labs/simple-store-memory': 0.2.5 + '@atproto/did': 0.5.4 zod: 3.25.76 '@atproto-labs/fetch-node@0.2.0': @@ -4432,66 +4245,68 @@ snapshots: ipaddr.js: 2.3.0 undici: 6.23.0 + '@atproto-labs/fetch-node@0.3.6': + dependencies: + '@atproto-labs/fetch': 0.3.5 + '@atproto-labs/pipe': 0.2.4 + ipaddr.js: 2.3.0 + undici_v6: undici@6.23.0 + undici_v7: undici@7.29.0 + undici_v8: undici@8.10.0 + '@atproto-labs/fetch@0.2.3': dependencies: '@atproto-labs/pipe': 0.1.1 + '@atproto-labs/fetch@0.3.5': + dependencies: + '@atproto-labs/pipe': 0.2.4 + '@atproto-labs/pipe@0.1.1': {} - '@atproto-labs/simple-store-memory@0.1.4': + '@atproto-labs/pipe@0.2.4': {} + + '@atproto-labs/simple-store-memory@0.2.5': dependencies: - '@atproto-labs/simple-store': 0.3.0 + '@atproto-labs/simple-store': 0.5.0 lru-cache: 10.4.3 - '@atproto-labs/simple-store-redis@0.0.1(ioredis@5.9.3)': + '@atproto-labs/simple-store-redis@0.1.5(ioredis@5.9.3)': dependencies: - '@atproto-labs/simple-store': 0.3.0 + '@atproto-labs/simple-store': 0.5.0 ioredis: 5.9.3 - '@atproto-labs/simple-store@0.3.0': {} + '@atproto-labs/simple-store@0.5.0': {} - '@atproto-labs/xrpc-utils@0.0.24': + '@atproto-labs/xrpc-utils@0.1.12': dependencies: - '@atproto/xrpc': 0.7.7 - '@atproto/xrpc-server': 0.10.14 + '@atproto/xrpc': 0.8.8 + '@atproto/xrpc-server': 0.11.13 transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - '@atproto/api@0.18.21': - dependencies: - '@atproto/common-web': 0.4.17 - '@atproto/lexicon': 0.6.1 - '@atproto/syntax': 0.4.3 - '@atproto/xrpc': 0.7.7 - await-lock: 2.2.2 - multiformats: 9.9.0 - tlds: 1.261.0 - zod: 3.25.76 - - '@atproto/aws@0.2.31': + '@atproto/aws@0.3.9': dependencies: - '@atproto/common': 0.5.13 - '@atproto/common-web': 0.4.17 - '@atproto/crypto': 0.4.5 - '@atproto/repo': 0.8.12 - '@aws-sdk/client-cloudfront': 3.994.0 - '@aws-sdk/client-kms': 3.994.0 - '@aws-sdk/client-s3': 3.994.0 - '@aws-sdk/lib-storage': 3.879.0(@aws-sdk/client-s3@3.994.0) + '@atproto/common': 0.7.3 + '@atproto/common-web': 0.5.7 + '@atproto/crypto': 0.5.4 + '@atproto/repo': 0.10.7 + '@aws-sdk/client-cloudfront': 3.1101.0 + '@aws-sdk/client-kms': 3.1101.0 + '@aws-sdk/client-s3': 3.1101.0 + '@aws-sdk/lib-storage': 3.1101.0(@aws-sdk/client-s3@3.1101.0) '@noble/curves': 1.9.7 key-encoder: 2.0.3 - multiformats: 9.9.0 - uint8arrays: 3.0.0 - transitivePeerDependencies: - - aws-crt + multiformats: 13.4.2 + uint8arrays: 5.1.1 - '@atproto/common-web@0.4.17': + '@atproto/common-web@0.5.7': dependencies: - '@atproto/lex-data': 0.0.12 - '@atproto/lex-json': 0.0.12 - '@atproto/syntax': 0.4.3 + '@atproto/lex-data': 0.1.6 + '@atproto/lex-json': 0.1.5 + '@atproto/syntax': 0.7.2 zod: 3.25.76 '@atproto/common@0.1.1': @@ -4501,14 +4316,13 @@ snapshots: pino: 8.21.0 zod: 3.25.76 - '@atproto/common@0.5.13': + '@atproto/common@0.7.3': dependencies: - '@atproto/common-web': 0.4.17 - '@atproto/lex-cbor': 0.0.13 - '@atproto/lex-data': 0.0.12 - iso-datestring-validator: 2.2.2 - multiformats: 9.9.0 - pino: 8.21.0 + '@atproto/common-web': 0.5.7 + '@atproto/lex-cbor': 0.1.5 + '@atproto/lex-data': 0.1.6 + multiformats: 13.4.2 + pino: 10.3.1 '@atproto/crypto@0.1.0': dependencies: @@ -4524,113 +4338,144 @@ snapshots: '@noble/hashes': 1.8.0 uint8arrays: 3.0.0 - '@atproto/did@0.3.0': + '@atproto/crypto@0.5.4': + dependencies: + '@noble/curves': 1.9.7 + '@noble/hashes': 1.8.0 + uint8arrays: 5.1.1 + + '@atproto/did@0.5.4': dependencies: zod: 3.25.76 - '@atproto/identity@0.4.12': + '@atproto/identity@0.5.7': dependencies: - '@atproto/common-web': 0.4.17 - '@atproto/crypto': 0.4.5 + '@atproto/common-web': 0.5.7 + '@atproto/crypto': 0.5.4 - '@atproto/jwk-jose@0.1.11': + '@atproto/jwk-jose@0.2.4': dependencies: - '@atproto/jwk': 0.6.0 + '@atproto/jwk': 0.7.4 jose: 5.10.0 - '@atproto/jwk@0.6.0': + '@atproto/jwk@0.7.4': dependencies: - multiformats: 9.9.0 + multiformats: 13.4.2 zod: 3.25.76 - '@atproto/lex-cbor@0.0.13': + '@atproto/lex-builder@0.1.9': dependencies: - '@atproto/lex-data': 0.0.12 + '@atproto/lex-document': 0.1.7 + '@atproto/lex-schema': 0.2.3 + prettier: 3.8.1 + ts-morph: 27.0.2 tslib: 2.8.1 - '@atproto/lex-client@0.0.13': + '@atproto/lex-cbor@0.1.5': dependencies: - '@atproto/lex-data': 0.0.12 - '@atproto/lex-json': 0.0.12 - '@atproto/lex-schema': 0.0.13 + '@atproto/lex-data': 0.1.6 + cborg: 4.5.8 tslib: 2.8.1 - '@atproto/lex-data@0.0.12': + '@atproto/lex-client@0.3.1': dependencies: - multiformats: 9.9.0 + '@atproto/lex-data': 0.1.6 + '@atproto/lex-json': 0.1.5 + '@atproto/lex-schema': 0.2.3 + tslib: 2.8.1 + + '@atproto/lex-data@0.1.6': + dependencies: + multiformats: 13.4.2 tslib: 2.8.1 - uint8arrays: 3.0.0 unicode-segmenter: 0.14.5 - '@atproto/lex-document@0.0.14': + '@atproto/lex-document@0.1.7': dependencies: - '@atproto/lex-schema': 0.0.13 + '@atproto/lex-schema': 0.2.3 core-js: 3.48.0 tslib: 2.8.1 - '@atproto/lex-json@0.0.12': + '@atproto/lex-installer@0.1.11': dependencies: - '@atproto/lex-data': 0.0.12 + '@atproto/lex-builder': 0.1.9 + '@atproto/lex-cbor': 0.1.5 + '@atproto/lex-data': 0.1.6 + '@atproto/lex-document': 0.1.7 + '@atproto/lex-resolver': 0.2.4 + '@atproto/lex-schema': 0.2.3 + '@atproto/syntax': 0.7.2 tslib: 2.8.1 - '@atproto/lex-resolver@0.0.15': + '@atproto/lex-json@0.1.5': dependencies: - '@atproto-labs/did-resolver': 0.2.6 - '@atproto/crypto': 0.4.5 - '@atproto/lex-client': 0.0.13 - '@atproto/lex-data': 0.0.12 - '@atproto/lex-document': 0.0.14 - '@atproto/lex-schema': 0.0.13 - '@atproto/repo': 0.8.12 - '@atproto/syntax': 0.4.3 + '@atproto/lex-data': 0.1.6 tslib: 2.8.1 - '@atproto/lex-schema@0.0.13': + '@atproto/lex-resolver@0.2.4': dependencies: - '@atproto/lex-data': 0.0.12 - '@atproto/syntax': 0.4.3 + '@atproto-labs/did-resolver': 0.3.6 + '@atproto/crypto': 0.5.4 + '@atproto/lex-client': 0.3.1 + '@atproto/lex-data': 0.1.6 + '@atproto/lex-document': 0.1.7 + '@atproto/lex-schema': 0.2.3 + '@atproto/repo': 0.10.7 + '@atproto/syntax': 0.7.2 tslib: 2.8.1 - '@atproto/lexicon@0.6.1': + '@atproto/lex-schema@0.2.3': dependencies: - '@atproto/common-web': 0.4.17 - '@atproto/syntax': 0.4.3 - iso-datestring-validator: 2.2.2 - multiformats: 9.9.0 - zod: 3.25.76 + '@atproto/lex-data': 0.1.6 + '@atproto/syntax': 0.7.2 + '@standard-schema/spec': 1.1.0 + tslib: 2.8.1 - '@atproto/oauth-provider-api@0.3.7': + '@atproto/lex@0.3.2': dependencies: - '@atproto/jwk': 0.6.0 - '@atproto/oauth-types': 0.6.2 - - '@atproto/oauth-provider-frontend@0.2.9': - optionalDependencies: - '@atproto/oauth-provider-api': 0.3.7 - - '@atproto/oauth-provider-ui@0.4.3': - optionalDependencies: - '@atproto/oauth-provider-api': 0.3.7 + '@atproto/lex-builder': 0.1.9 + '@atproto/lex-client': 0.3.1 + '@atproto/lex-data': 0.1.6 + '@atproto/lex-installer': 0.1.11 + '@atproto/lex-json': 0.1.5 + '@atproto/lex-schema': 0.2.3 + tslib: 2.8.1 + yargs: 18.1.0 - '@atproto/oauth-provider@0.15.10': + '@atproto/lexicon@0.7.9': dependencies: - '@atproto-labs/fetch': 0.2.3 - '@atproto-labs/fetch-node': 0.2.0 - '@atproto-labs/pipe': 0.1.1 - '@atproto-labs/simple-store': 0.3.0 - '@atproto-labs/simple-store-memory': 0.1.4 - '@atproto/common': 0.5.13 - '@atproto/did': 0.3.0 - '@atproto/jwk': 0.6.0 - '@atproto/jwk-jose': 0.1.11 - '@atproto/lex-document': 0.0.14 - '@atproto/lex-resolver': 0.0.15 - '@atproto/oauth-provider-api': 0.3.7 - '@atproto/oauth-provider-frontend': 0.2.9 - '@atproto/oauth-provider-ui': 0.4.3 - '@atproto/oauth-scopes': 0.3.1 - '@atproto/oauth-types': 0.6.3 - '@atproto/syntax': 0.4.3 + '@atproto/common-web': 0.5.7 + '@atproto/syntax': 0.7.2 + multiformats: 13.4.2 + zod: 3.25.76 + + '@atproto/oauth-provider-api@0.7.7': + dependencies: + '@atproto/jwk': 0.7.4 + '@atproto/oauth-types': 0.7.5 + '@atproto/syntax': 0.7.2 + + '@atproto/oauth-provider-ui@0.8.9': + dependencies: + '@atproto/oauth-provider-api': 0.7.7 + + '@atproto/oauth-provider@0.21.1': + dependencies: + '@atproto-labs/fetch-node': 0.3.6 + '@atproto-labs/pipe': 0.2.4 + '@atproto-labs/simple-store': 0.5.0 + '@atproto-labs/simple-store-memory': 0.2.5 + '@atproto/common': 0.7.3 + '@atproto/did': 0.5.4 + '@atproto/jwk': 0.7.4 + '@atproto/jwk-jose': 0.2.4 + '@atproto/lex-document': 0.1.7 + '@atproto/lex-resolver': 0.2.4 + '@atproto/oauth-provider-api': 0.7.7 + '@atproto/oauth-provider-ui': 0.8.9 + '@atproto/oauth-scopes': 0.5.7 + '@atproto/oauth-types': 0.7.5 + '@atproto/syntax': 0.7.2 '@hapi/accept': 6.0.3 '@hapi/address': 5.1.1 '@hapi/bourne': 3.0.0 @@ -4645,48 +4490,42 @@ snapshots: transitivePeerDependencies: - supports-color - '@atproto/oauth-scopes@0.3.1': - dependencies: - '@atproto/did': 0.3.0 - '@atproto/syntax': 0.4.3 - - '@atproto/oauth-types@0.6.2': + '@atproto/oauth-scopes@0.5.7': dependencies: - '@atproto/did': 0.3.0 - '@atproto/jwk': 0.6.0 - zod: 3.25.76 + '@atproto/did': 0.5.4 + '@atproto/syntax': 0.7.2 - '@atproto/oauth-types@0.6.3': + '@atproto/oauth-types@0.7.5': dependencies: - '@atproto/did': 0.3.0 - '@atproto/jwk': 0.6.0 + '@atproto/did': 0.5.4 + '@atproto/jwk': 0.7.4 zod: 3.25.76 - '@atproto/pds@0.4.211': - dependencies: - '@atproto-labs/fetch-node': 0.2.0 - '@atproto-labs/simple-store': 0.3.0 - '@atproto-labs/simple-store-memory': 0.1.4 - '@atproto-labs/simple-store-redis': 0.0.1(ioredis@5.9.3) - '@atproto-labs/xrpc-utils': 0.0.24 - '@atproto/api': 0.18.21 - '@atproto/aws': 0.2.31 - '@atproto/common': 0.5.13 - '@atproto/crypto': 0.4.5 - '@atproto/identity': 0.4.12 - '@atproto/lex-cbor': 0.0.13 - '@atproto/lex-data': 0.0.12 - '@atproto/lexicon': 0.6.1 - '@atproto/oauth-provider': 0.15.10 - '@atproto/oauth-scopes': 0.3.1 - '@atproto/repo': 0.8.12 - '@atproto/syntax': 0.4.3 - '@atproto/xrpc': 0.7.7 - '@atproto/xrpc-server': 0.10.14 + '@atproto/pds@0.5.23': + dependencies: + '@atproto-labs/fetch-node': 0.3.6 + '@atproto-labs/simple-store': 0.5.0 + '@atproto-labs/simple-store-memory': 0.2.5 + '@atproto-labs/simple-store-redis': 0.1.5(ioredis@5.9.3) + '@atproto-labs/xrpc-utils': 0.1.12 + '@atproto/aws': 0.3.9 + '@atproto/common': 0.7.3 + '@atproto/crypto': 0.5.4 + '@atproto/did': 0.5.4 + '@atproto/identity': 0.5.7 + '@atproto/lex': 0.3.2 + '@atproto/lex-cbor': 0.1.5 + '@atproto/lex-data': 0.1.6 + '@atproto/lex-json': 0.1.5 + '@atproto/oauth-provider': 0.21.1 + '@atproto/oauth-scopes': 0.5.7 + '@atproto/repo': 0.10.7 + '@atproto/syntax': 0.7.2 + '@atproto/xrpc': 0.8.8 + '@atproto/xrpc-server': 0.11.13 '@did-plc/lib': 0.0.4 '@hapi/address': 5.1.1 - better-sqlite3: 10.1.0 - bytes: 3.1.2 + better-sqlite3: 12.6.2 compression: 1.8.1 cors: 2.8.6 disposable-email-domains-js: 1.21.0 @@ -4699,33 +4538,30 @@ snapshots: ioredis: 5.9.3 jose: 5.10.0 key-encoder: 2.0.3 - kysely: 0.22.0 - multiformats: 9.9.0 + kysely: 0.29.4 nodemailer: 6.10.1 nodemailer-html-to-text: 3.2.0 - p-queue: 6.6.2 - pino: 8.21.0 - pino-http: 8.6.1 + p-queue: 8.1.1 + pino: 10.3.1 + pino-http: 11.0.0 typed-emitter: 2.1.0 - uint8arrays: 3.0.0 - undici: 6.23.0 + uint8arrays: 5.1.1 + undici: 8.10.0 zod: 3.25.76 transitivePeerDependencies: - - aws-crt - bufferutil - debug - supports-color - utf-8-validate - '@atproto/repo@0.8.12': + '@atproto/repo@0.10.7': dependencies: - '@atproto/common': 0.5.13 - '@atproto/common-web': 0.4.17 - '@atproto/crypto': 0.4.5 - '@atproto/lexicon': 0.6.1 - '@ipld/dag-cbor': 7.0.3 - multiformats: 9.9.0 - uint8arrays: 3.0.0 + '@atproto/common': 0.7.3 + '@atproto/common-web': 0.5.7 + '@atproto/crypto': 0.5.4 + '@atproto/lex-cbor': 0.1.5 + '@atproto/lex-data': 0.1.6 + '@atproto/syntax': 0.7.2 varint: 6.0.0 zod: 3.25.76 @@ -4733,632 +4569,242 @@ snapshots: dependencies: tslib: 2.8.1 - '@atproto/ws-client@0.0.4': + '@atproto/syntax@0.7.2': dependencies: - '@atproto/common': 0.5.13 - ws: 8.19.0 - transitivePeerDependencies: + iso-datestring-validator: 2.2.2 + tslib: 2.8.1 + + '@atproto/ws-client@0.1.8': + dependencies: + '@atproto/common': 0.7.3 + ws: 8.19.0 + transitivePeerDependencies: - bufferutil - utf-8-validate - '@atproto/xrpc-server@0.10.14': - dependencies: - '@atproto/common': 0.5.13 - '@atproto/crypto': 0.4.5 - '@atproto/lex-cbor': 0.0.13 - '@atproto/lex-data': 0.0.12 - '@atproto/lexicon': 0.6.1 - '@atproto/ws-client': 0.0.4 - '@atproto/xrpc': 0.7.7 + '@atproto/xrpc-server@0.11.13': + dependencies: + '@atproto/common': 0.7.3 + '@atproto/crypto': 0.5.4 + '@atproto/lex-cbor': 0.1.5 + '@atproto/lex-client': 0.3.1 + '@atproto/lex-data': 0.1.6 + '@atproto/lex-json': 0.1.5 + '@atproto/lex-schema': 0.2.3 + '@atproto/lexicon': 0.7.9 + '@atproto/ws-client': 0.1.8 + '@atproto/xrpc': 0.8.8 express: 4.22.1 http-errors: 2.0.1 mime-types: 2.1.35 rate-limiter-flexible: 2.4.2 ws: 8.19.0 - zod: 3.25.76 transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - '@atproto/xrpc@0.7.7': + '@atproto/xrpc@0.8.8': dependencies: - '@atproto/lexicon': 0.6.1 + '@atproto/lexicon': 0.7.9 zod: 3.25.76 - '@aws-crypto/crc32@5.2.0': + '@aws-sdk/checksums@3.1000.24': dependencies: - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.1 + '@aws-sdk/core': 3.977.4 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-crypto/crc32c@5.2.0': + '@aws-sdk/client-cloudfront@3.1101.0': dependencies: - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.1 + '@aws-sdk/core': 3.977.4 + '@aws-sdk/credential-provider-node': 3.972.76 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/fetch-http-handler': 5.6.13 + '@smithy/node-http-handler': 4.9.13 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-crypto/sha1-browser@5.2.0': + '@aws-sdk/client-kms@3.1101.0': dependencies: - '@aws-crypto/supports-web-crypto': 5.2.0 - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-locate-window': 3.965.4 - '@smithy/util-utf8': 2.3.0 + '@aws-sdk/core': 3.977.4 + '@aws-sdk/credential-provider-node': 3.972.76 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/fetch-http-handler': 5.6.13 + '@smithy/node-http-handler': 4.9.13 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-crypto/sha256-browser@5.2.0': - dependencies: - '@aws-crypto/sha256-js': 5.2.0 - '@aws-crypto/supports-web-crypto': 5.2.0 - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-locate-window': 3.965.4 - '@smithy/util-utf8': 2.3.0 + '@aws-sdk/client-s3@3.1101.0': + dependencies: + '@aws-sdk/checksums': 3.1000.24 + '@aws-sdk/core': 3.977.4 + '@aws-sdk/credential-provider-node': 3.972.76 + '@aws-sdk/middleware-sdk-s3': 3.972.70 + '@aws-sdk/signature-v4-multi-region': 3.996.43 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/fetch-http-handler': 5.6.13 + '@smithy/node-http-handler': 4.9.13 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-crypto/sha256-js@5.2.0': + '@aws-sdk/core@3.977.4': dependencies: - '@aws-crypto/util': 5.2.0 - '@aws-sdk/types': 3.973.1 + '@aws-sdk/types': 3.974.2 + '@aws-sdk/xml-builder': 3.972.37 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.31.1 + '@smithy/signature-v4': 5.6.12 + '@smithy/types': 4.16.1 + bowser: 2.14.1 tslib: 2.8.1 - '@aws-crypto/supports-web-crypto@5.2.0': + '@aws-sdk/credential-provider-env@3.972.65': dependencies: + '@aws-sdk/core': 3.977.4 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-crypto/util@5.2.0': + '@aws-sdk/credential-provider-http@3.972.67': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/util-utf8': 2.3.0 + '@aws-sdk/core': 3.977.4 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/fetch-http-handler': 5.6.13 + '@smithy/node-http-handler': 4.9.13 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/client-cloudfront@3.994.0': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.11 - '@aws-sdk/credential-provider-node': 3.972.10 - '@aws-sdk/middleware-host-header': 3.972.3 - '@aws-sdk/middleware-logger': 3.972.3 - '@aws-sdk/middleware-recursion-detection': 3.972.3 - '@aws-sdk/middleware-user-agent': 3.972.11 - '@aws-sdk/region-config-resolver': 3.972.3 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-endpoints': 3.994.0 - '@aws-sdk/util-user-agent-browser': 3.972.3 - '@aws-sdk/util-user-agent-node': 3.972.9 - '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.23.2 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/hash-node': 4.2.8 - '@smithy/invalid-dependency': 4.2.8 - '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.16 - '@smithy/middleware-retry': 4.4.33 - '@smithy/middleware-serde': 4.2.9 - '@smithy/middleware-stack': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.10 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.32 - '@smithy/util-defaults-mode-node': 4.2.35 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/util-stream': 4.5.12 - '@smithy/util-utf8': 4.2.0 - '@smithy/util-waiter': 4.2.8 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/client-kms@3.994.0': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.11 - '@aws-sdk/credential-provider-node': 3.972.10 - '@aws-sdk/middleware-host-header': 3.972.3 - '@aws-sdk/middleware-logger': 3.972.3 - '@aws-sdk/middleware-recursion-detection': 3.972.3 - '@aws-sdk/middleware-user-agent': 3.972.11 - '@aws-sdk/region-config-resolver': 3.972.3 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-endpoints': 3.994.0 - '@aws-sdk/util-user-agent-browser': 3.972.3 - '@aws-sdk/util-user-agent-node': 3.972.9 - '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.23.2 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/hash-node': 4.2.8 - '@smithy/invalid-dependency': 4.2.8 - '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.16 - '@smithy/middleware-retry': 4.4.33 - '@smithy/middleware-serde': 4.2.9 - '@smithy/middleware-stack': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.10 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.32 - '@smithy/util-defaults-mode-node': 4.2.35 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/client-s3@3.994.0': - dependencies: - '@aws-crypto/sha1-browser': 5.2.0 - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.11 - '@aws-sdk/credential-provider-node': 3.972.10 - '@aws-sdk/middleware-bucket-endpoint': 3.972.3 - '@aws-sdk/middleware-expect-continue': 3.972.3 - '@aws-sdk/middleware-flexible-checksums': 3.972.9 - '@aws-sdk/middleware-host-header': 3.972.3 - '@aws-sdk/middleware-location-constraint': 3.972.3 - '@aws-sdk/middleware-logger': 3.972.3 - '@aws-sdk/middleware-recursion-detection': 3.972.3 - '@aws-sdk/middleware-sdk-s3': 3.972.11 - '@aws-sdk/middleware-ssec': 3.972.3 - '@aws-sdk/middleware-user-agent': 3.972.11 - '@aws-sdk/region-config-resolver': 3.972.3 - '@aws-sdk/signature-v4-multi-region': 3.994.0 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-endpoints': 3.994.0 - '@aws-sdk/util-user-agent-browser': 3.972.3 - '@aws-sdk/util-user-agent-node': 3.972.9 - '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.23.2 - '@smithy/eventstream-serde-browser': 4.2.8 - '@smithy/eventstream-serde-config-resolver': 4.3.8 - '@smithy/eventstream-serde-node': 4.2.8 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/hash-blob-browser': 4.2.9 - '@smithy/hash-node': 4.2.8 - '@smithy/hash-stream-node': 4.2.8 - '@smithy/invalid-dependency': 4.2.8 - '@smithy/md5-js': 4.2.8 - '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.16 - '@smithy/middleware-retry': 4.4.33 - '@smithy/middleware-serde': 4.2.9 - '@smithy/middleware-stack': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.10 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.32 - '@smithy/util-defaults-mode-node': 4.2.35 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/util-stream': 4.5.12 - '@smithy/util-utf8': 4.2.0 - '@smithy/util-waiter': 4.2.8 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/client-sso@3.993.0': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.11 - '@aws-sdk/middleware-host-header': 3.972.3 - '@aws-sdk/middleware-logger': 3.972.3 - '@aws-sdk/middleware-recursion-detection': 3.972.3 - '@aws-sdk/middleware-user-agent': 3.972.11 - '@aws-sdk/region-config-resolver': 3.972.3 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-endpoints': 3.993.0 - '@aws-sdk/util-user-agent-browser': 3.972.3 - '@aws-sdk/util-user-agent-node': 3.972.9 - '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.23.2 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/hash-node': 4.2.8 - '@smithy/invalid-dependency': 4.2.8 - '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.16 - '@smithy/middleware-retry': 4.4.33 - '@smithy/middleware-serde': 4.2.9 - '@smithy/middleware-stack': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.10 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.32 - '@smithy/util-defaults-mode-node': 4.2.35 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/core@3.973.11': - dependencies: - '@aws-sdk/types': 3.973.1 - '@aws-sdk/xml-builder': 3.972.5 - '@smithy/core': 3.23.2 - '@smithy/node-config-provider': 4.3.8 - '@smithy/property-provider': 4.2.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/signature-v4': 5.3.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 - '@smithy/util-base64': 4.3.0 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-utf8': 4.2.0 + '@aws-sdk/credential-provider-ini@3.973.10': + dependencies: + '@aws-sdk/core': 3.977.4 + '@aws-sdk/credential-provider-env': 3.972.65 + '@aws-sdk/credential-provider-http': 3.972.67 + '@aws-sdk/credential-provider-login': 3.972.72 + '@aws-sdk/credential-provider-process': 3.972.65 + '@aws-sdk/credential-provider-sso': 3.973.9 + '@aws-sdk/credential-provider-web-identity': 3.972.71 + '@aws-sdk/nested-clients': 3.997.39 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/credential-provider-imds': 4.4.16 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/crc64-nvme@3.972.0': + '@aws-sdk/credential-provider-login@3.972.72': dependencies: - '@smithy/types': 4.12.0 + '@aws-sdk/core': 3.977.4 + '@aws-sdk/nested-clients': 3.997.39 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-env@3.972.9': - dependencies: - '@aws-sdk/core': 3.973.11 - '@aws-sdk/types': 3.973.1 - '@smithy/property-provider': 4.2.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/credential-provider-http@3.972.11': - dependencies: - '@aws-sdk/core': 3.973.11 - '@aws-sdk/types': 3.973.1 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/node-http-handler': 4.4.10 - '@smithy/property-provider': 4.2.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 - '@smithy/util-stream': 4.5.12 + '@aws-sdk/credential-provider-node@3.972.76': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.65 + '@aws-sdk/credential-provider-http': 3.972.67 + '@aws-sdk/credential-provider-ini': 3.973.10 + '@aws-sdk/credential-provider-process': 3.972.65 + '@aws-sdk/credential-provider-sso': 3.973.9 + '@aws-sdk/credential-provider-web-identity': 3.972.71 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/credential-provider-imds': 4.4.16 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-ini@3.972.9': - dependencies: - '@aws-sdk/core': 3.973.11 - '@aws-sdk/credential-provider-env': 3.972.9 - '@aws-sdk/credential-provider-http': 3.972.11 - '@aws-sdk/credential-provider-login': 3.972.9 - '@aws-sdk/credential-provider-process': 3.972.9 - '@aws-sdk/credential-provider-sso': 3.972.9 - '@aws-sdk/credential-provider-web-identity': 3.972.9 - '@aws-sdk/nested-clients': 3.993.0 - '@aws-sdk/types': 3.973.1 - '@smithy/credential-provider-imds': 4.2.8 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/credential-provider-login@3.972.9': + '@aws-sdk/credential-provider-process@3.972.65': dependencies: - '@aws-sdk/core': 3.973.11 - '@aws-sdk/nested-clients': 3.993.0 - '@aws-sdk/types': 3.973.1 - '@smithy/property-provider': 4.2.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/credential-provider-node@3.972.10': - dependencies: - '@aws-sdk/credential-provider-env': 3.972.9 - '@aws-sdk/credential-provider-http': 3.972.11 - '@aws-sdk/credential-provider-ini': 3.972.9 - '@aws-sdk/credential-provider-process': 3.972.9 - '@aws-sdk/credential-provider-sso': 3.972.9 - '@aws-sdk/credential-provider-web-identity': 3.972.9 - '@aws-sdk/types': 3.973.1 - '@smithy/credential-provider-imds': 4.2.8 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 + '@aws-sdk/core': 3.977.4 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/credential-provider-process@3.972.9': + '@aws-sdk/credential-provider-sso@3.973.9': dependencies: - '@aws-sdk/core': 3.973.11 - '@aws-sdk/types': 3.973.1 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 + '@aws-sdk/core': 3.977.4 + '@aws-sdk/nested-clients': 3.997.39 + '@aws-sdk/token-providers': 3.1100.0 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/credential-provider-sso@3.972.9': + '@aws-sdk/credential-provider-web-identity@3.972.71': dependencies: - '@aws-sdk/client-sso': 3.993.0 - '@aws-sdk/core': 3.973.11 - '@aws-sdk/token-providers': 3.993.0 - '@aws-sdk/types': 3.973.1 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 + '@aws-sdk/core': 3.977.4 + '@aws-sdk/nested-clients': 3.997.39 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/credential-provider-web-identity@3.972.9': - dependencies: - '@aws-sdk/core': 3.973.11 - '@aws-sdk/nested-clients': 3.993.0 - '@aws-sdk/types': 3.973.1 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - '@aws-sdk/lib-storage@3.879.0(@aws-sdk/client-s3@3.994.0)': + '@aws-sdk/lib-storage@3.1101.0(@aws-sdk/client-s3@3.1101.0)': dependencies: - '@aws-sdk/client-s3': 3.994.0 - '@smithy/abort-controller': 4.2.8 - '@smithy/middleware-endpoint': 4.4.16 - '@smithy/smithy-client': 4.11.5 + '@aws-sdk/client-s3': 3.1101.0 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 buffer: 5.6.0 events: 3.3.0 stream-browserify: 3.0.0 tslib: 2.8.1 - '@aws-sdk/middleware-bucket-endpoint@3.972.3': - dependencies: - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-arn-parser': 3.972.2 - '@smithy/node-config-provider': 4.3.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - '@smithy/util-config-provider': 4.2.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-expect-continue@3.972.3': - dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-flexible-checksums@3.972.9': - dependencies: - '@aws-crypto/crc32': 5.2.0 - '@aws-crypto/crc32c': 5.2.0 - '@aws-crypto/util': 5.2.0 - '@aws-sdk/core': 3.973.11 - '@aws-sdk/crc64-nvme': 3.972.0 - '@aws-sdk/types': 3.973.1 - '@smithy/is-array-buffer': 4.2.0 - '@smithy/node-config-provider': 4.3.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-stream': 4.5.12 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-host-header@3.972.3': - dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-location-constraint@3.972.3': - dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-logger@3.972.3': - dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-recursion-detection@3.972.3': - dependencies: - '@aws-sdk/types': 3.973.1 - '@aws/lambda-invoke-store': 0.2.3 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-sdk-s3@3.972.11': - dependencies: - '@aws-sdk/core': 3.973.11 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-arn-parser': 3.972.2 - '@smithy/core': 3.23.2 - '@smithy/node-config-provider': 4.3.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/signature-v4': 5.3.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 - '@smithy/util-config-provider': 4.2.0 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-stream': 4.5.12 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-ssec@3.972.3': - dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/middleware-user-agent@3.972.11': - dependencies: - '@aws-sdk/core': 3.973.11 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-endpoints': 3.993.0 - '@smithy/core': 3.23.2 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/nested-clients@3.993.0': - dependencies: - '@aws-crypto/sha256-browser': 5.2.0 - '@aws-crypto/sha256-js': 5.2.0 - '@aws-sdk/core': 3.973.11 - '@aws-sdk/middleware-host-header': 3.972.3 - '@aws-sdk/middleware-logger': 3.972.3 - '@aws-sdk/middleware-recursion-detection': 3.972.3 - '@aws-sdk/middleware-user-agent': 3.972.11 - '@aws-sdk/region-config-resolver': 3.972.3 - '@aws-sdk/types': 3.973.1 - '@aws-sdk/util-endpoints': 3.993.0 - '@aws-sdk/util-user-agent-browser': 3.972.3 - '@aws-sdk/util-user-agent-node': 3.972.9 - '@smithy/config-resolver': 4.4.6 - '@smithy/core': 3.23.2 - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/hash-node': 4.2.8 - '@smithy/invalid-dependency': 4.2.8 - '@smithy/middleware-content-length': 4.2.8 - '@smithy/middleware-endpoint': 4.4.16 - '@smithy/middleware-retry': 4.4.33 - '@smithy/middleware-serde': 4.2.9 - '@smithy/middleware-stack': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/node-http-handler': 4.4.10 - '@smithy/protocol-http': 5.3.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-body-length-node': 4.2.1 - '@smithy/util-defaults-mode-browser': 4.3.32 - '@smithy/util-defaults-mode-node': 4.2.35 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/region-config-resolver@3.972.3': - dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/config-resolver': 4.4.6 - '@smithy/node-config-provider': 4.3.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/signature-v4-multi-region@3.994.0': - dependencies: - '@aws-sdk/middleware-sdk-s3': 3.972.11 - '@aws-sdk/types': 3.973.1 - '@smithy/protocol-http': 5.3.8 - '@smithy/signature-v4': 5.3.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/token-providers@3.993.0': + '@aws-sdk/middleware-sdk-s3@3.972.70': dependencies: - '@aws-sdk/core': 3.973.11 - '@aws-sdk/nested-clients': 3.993.0 - '@aws-sdk/types': 3.973.1 - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - transitivePeerDependencies: - - aws-crt - - '@aws-sdk/types@3.973.1': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@aws-sdk/util-arn-parser@3.972.2': - dependencies: - tslib: 2.8.1 - - '@aws-sdk/util-endpoints@3.993.0': - dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-endpoints': 3.2.8 + '@aws-sdk/core': 3.977.4 + '@aws-sdk/signature-v4-multi-region': 3.996.43 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/util-endpoints@3.994.0': + '@aws-sdk/nested-clients@3.997.39': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-endpoints': 3.2.8 + '@aws-sdk/core': 3.977.4 + '@aws-sdk/signature-v4-multi-region': 3.996.43 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/fetch-http-handler': 5.6.13 + '@smithy/node-http-handler': 4.9.13 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/util-locate-window@3.965.4': + '@aws-sdk/signature-v4-multi-region@3.996.43': dependencies: + '@aws-sdk/types': 3.974.2 + '@smithy/signature-v4': 5.6.12 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/util-user-agent-browser@3.972.3': + '@aws-sdk/token-providers@3.1100.0': dependencies: - '@aws-sdk/types': 3.973.1 - '@smithy/types': 4.12.0 - bowser: 2.14.1 + '@aws-sdk/core': 3.977.4 + '@aws-sdk/nested-clients': 3.997.39 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/util-user-agent-node@3.972.9': + '@aws-sdk/types@3.974.2': dependencies: - '@aws-sdk/middleware-user-agent': 3.972.11 - '@aws-sdk/types': 3.973.1 - '@smithy/node-config-provider': 4.3.8 - '@smithy/types': 4.12.0 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/xml-builder@3.972.5': + '@aws-sdk/xml-builder@3.972.37': dependencies: - '@smithy/types': 4.12.0 - fast-xml-parser: 5.3.6 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws/lambda-invoke-store@0.2.3': {} + '@aws/lambda-invoke-store@0.3.0': {} '@babel/code-frame@7.29.0': dependencies: @@ -6132,341 +5578,36 @@ snapshots: '@rtsao/scc@1.1.0': {} - '@smithy/abort-controller@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/chunked-blob-reader-native@4.2.1': - dependencies: - '@smithy/util-base64': 4.3.0 - tslib: 2.8.1 - - '@smithy/chunked-blob-reader@5.2.0': - dependencies: - tslib: 2.8.1 - - '@smithy/config-resolver@4.4.6': - dependencies: - '@smithy/node-config-provider': 4.3.8 - '@smithy/types': 4.12.0 - '@smithy/util-config-provider': 4.2.0 - '@smithy/util-endpoints': 3.2.8 - '@smithy/util-middleware': 4.2.8 - tslib: 2.8.1 - - '@smithy/core@3.23.2': - dependencies: - '@smithy/middleware-serde': 4.2.9 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - '@smithy/util-base64': 4.3.0 - '@smithy/util-body-length-browser': 4.2.0 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-stream': 4.5.12 - '@smithy/util-utf8': 4.2.0 - '@smithy/uuid': 1.1.0 - tslib: 2.8.1 - - '@smithy/credential-provider-imds@4.2.8': - dependencies: - '@smithy/node-config-provider': 4.3.8 - '@smithy/property-provider': 4.2.8 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - tslib: 2.8.1 - - '@smithy/eventstream-codec@4.2.8': - dependencies: - '@aws-crypto/crc32': 5.2.0 - '@smithy/types': 4.12.0 - '@smithy/util-hex-encoding': 4.2.0 - tslib: 2.8.1 - - '@smithy/eventstream-serde-browser@4.2.8': - dependencies: - '@smithy/eventstream-serde-universal': 4.2.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/eventstream-serde-config-resolver@4.3.8': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/eventstream-serde-node@4.2.8': - dependencies: - '@smithy/eventstream-serde-universal': 4.2.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/eventstream-serde-universal@4.2.8': - dependencies: - '@smithy/eventstream-codec': 4.2.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/fetch-http-handler@5.3.9': - dependencies: - '@smithy/protocol-http': 5.3.8 - '@smithy/querystring-builder': 4.2.8 - '@smithy/types': 4.12.0 - '@smithy/util-base64': 4.3.0 - tslib: 2.8.1 - - '@smithy/hash-blob-browser@4.2.9': - dependencies: - '@smithy/chunked-blob-reader': 5.2.0 - '@smithy/chunked-blob-reader-native': 4.2.1 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/hash-node@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - '@smithy/util-buffer-from': 4.2.0 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - - '@smithy/hash-stream-node@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - - '@smithy/invalid-dependency@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/is-array-buffer@2.2.0': - dependencies: - tslib: 2.8.1 - - '@smithy/is-array-buffer@4.2.0': - dependencies: - tslib: 2.8.1 - - '@smithy/md5-js@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - - '@smithy/middleware-content-length@4.2.8': - dependencies: - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/middleware-endpoint@4.4.16': - dependencies: - '@smithy/core': 3.23.2 - '@smithy/middleware-serde': 4.2.9 - '@smithy/node-config-provider': 4.3.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - '@smithy/url-parser': 4.2.8 - '@smithy/util-middleware': 4.2.8 - tslib: 2.8.1 - - '@smithy/middleware-retry@4.4.33': - dependencies: - '@smithy/node-config-provider': 4.3.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/service-error-classification': 4.2.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-retry': 4.2.8 - '@smithy/uuid': 1.1.0 - tslib: 2.8.1 - - '@smithy/middleware-serde@4.2.9': - dependencies: - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/middleware-stack@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/node-config-provider@4.3.8': - dependencies: - '@smithy/property-provider': 4.2.8 - '@smithy/shared-ini-file-loader': 4.4.3 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/node-http-handler@4.4.10': - dependencies: - '@smithy/abort-controller': 4.2.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/querystring-builder': 4.2.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/property-provider@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/protocol-http@5.3.8': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/querystring-builder@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - '@smithy/util-uri-escape': 4.2.0 - tslib: 2.8.1 - - '@smithy/querystring-parser@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/service-error-classification@4.2.8': - dependencies: - '@smithy/types': 4.12.0 - - '@smithy/shared-ini-file-loader@4.4.3': - dependencies: - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/signature-v4@5.3.8': - dependencies: - '@smithy/is-array-buffer': 4.2.0 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - '@smithy/util-hex-encoding': 4.2.0 - '@smithy/util-middleware': 4.2.8 - '@smithy/util-uri-escape': 4.2.0 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - - '@smithy/smithy-client@4.11.5': - dependencies: - '@smithy/core': 3.23.2 - '@smithy/middleware-endpoint': 4.4.16 - '@smithy/middleware-stack': 4.2.8 - '@smithy/protocol-http': 5.3.8 - '@smithy/types': 4.12.0 - '@smithy/util-stream': 4.5.12 - tslib: 2.8.1 - - '@smithy/types@4.12.0': - dependencies: - tslib: 2.8.1 - - '@smithy/url-parser@4.2.8': - dependencies: - '@smithy/querystring-parser': 4.2.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/util-base64@4.3.0': - dependencies: - '@smithy/util-buffer-from': 4.2.0 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - - '@smithy/util-body-length-browser@4.2.0': - dependencies: - tslib: 2.8.1 - - '@smithy/util-body-length-node@4.2.1': - dependencies: - tslib: 2.8.1 - - '@smithy/util-buffer-from@2.2.0': - dependencies: - '@smithy/is-array-buffer': 2.2.0 - tslib: 2.8.1 - - '@smithy/util-buffer-from@4.2.0': - dependencies: - '@smithy/is-array-buffer': 4.2.0 - tslib: 2.8.1 - - '@smithy/util-config-provider@4.2.0': - dependencies: - tslib: 2.8.1 - - '@smithy/util-defaults-mode-browser@4.3.32': - dependencies: - '@smithy/property-provider': 4.2.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/util-defaults-mode-node@4.2.35': + '@smithy/core@3.31.1': dependencies: - '@smithy/config-resolver': 4.4.6 - '@smithy/credential-provider-imds': 4.2.8 - '@smithy/node-config-provider': 4.3.8 - '@smithy/property-provider': 4.2.8 - '@smithy/smithy-client': 4.11.5 - '@smithy/types': 4.12.0 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@smithy/util-endpoints@3.2.8': + '@smithy/credential-provider-imds@4.4.16': dependencies: - '@smithy/node-config-provider': 4.3.8 - '@smithy/types': 4.12.0 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@smithy/util-hex-encoding@4.2.0': + '@smithy/fetch-http-handler@5.6.13': dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@smithy/util-middleware@4.2.8': + '@smithy/node-http-handler@4.9.13': dependencies: - '@smithy/types': 4.12.0 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@smithy/util-retry@4.2.8': + '@smithy/signature-v4@5.6.12': dependencies: - '@smithy/service-error-classification': 4.2.8 - '@smithy/types': 4.12.0 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 tslib: 2.8.1 - '@smithy/util-stream@4.5.12': - dependencies: - '@smithy/fetch-http-handler': 5.3.9 - '@smithy/node-http-handler': 4.4.10 - '@smithy/types': 4.12.0 - '@smithy/util-base64': 4.3.0 - '@smithy/util-buffer-from': 4.2.0 - '@smithy/util-hex-encoding': 4.2.0 - '@smithy/util-utf8': 4.2.0 - tslib: 2.8.1 - - '@smithy/util-uri-escape@4.2.0': - dependencies: - tslib: 2.8.1 - - '@smithy/util-utf8@2.3.0': - dependencies: - '@smithy/util-buffer-from': 2.2.0 - tslib: 2.8.1 - - '@smithy/util-utf8@4.2.0': - dependencies: - '@smithy/util-buffer-from': 4.2.0 - tslib: 2.8.1 - - '@smithy/util-waiter@4.2.8': - dependencies: - '@smithy/abort-controller': 4.2.8 - '@smithy/types': 4.12.0 - tslib: 2.8.1 - - '@smithy/uuid@1.1.0': + '@smithy/types@4.16.1': dependencies: tslib: 2.8.1 @@ -6482,6 +5623,12 @@ snapshots: '@tokenizer/token@0.3.0': {} + '@ts-morph/common@0.28.1': + dependencies: + minimatch: 10.2.2 + path-browserify: 1.0.1 + tinyglobby: 0.2.15 + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -6939,8 +6086,6 @@ snapshots: dependencies: possible-typed-array-names: 1.1.0 - await-lock@2.2.2: {} - axios@1.13.5: dependencies: follow-redirects: 1.15.11 @@ -6989,11 +6134,6 @@ snapshots: dependencies: is-windows: 1.0.2 - better-sqlite3@10.1.0: - dependencies: - bindings: 1.5.0 - prebuild-install: 7.1.3 - better-sqlite3@12.6.2: dependencies: bindings: 1.5.0 @@ -7097,6 +6237,8 @@ snapshots: cborg@1.10.2: {} + cborg@4.5.8: {} + chai@6.2.2: {} chalk@4.1.2: @@ -7118,8 +6260,16 @@ snapshots: client-only@0.0.1: {} + cliui@9.0.1: + dependencies: + string-width: 7.2.0 + strip-ansi: 7.1.2 + wrap-ansi: 9.0.2 + cluster-key-slot@1.1.2: {} + code-block-writer@13.0.3: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -7316,6 +6466,8 @@ snapshots: minimalistic-assert: 1.0.1 minimalistic-crypto-utils: 1.0.1 + emoji-regex@10.6.0: {} + emoji-regex@8.0.0: {} emoji-regex@9.2.2: {} @@ -7450,6 +6602,8 @@ snapshots: '@esbuild/win32-ia32': 0.27.3 '@esbuild/win32-x64': 0.27.3 + escalade@3.2.0: {} + escape-html@1.0.3: {} escape-string-regexp@1.0.5: {} @@ -7604,7 +6758,7 @@ snapshots: event-target-shim@5.0.1: {} - eventemitter3@4.0.7: {} + eventemitter3@5.0.4: {} events@3.3.0: {} @@ -7674,10 +6828,6 @@ snapshots: fast-sha256@1.3.0: {} - fast-xml-parser@5.3.6: - dependencies: - strnum: 2.1.2 - fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -7797,6 +6947,8 @@ snapshots: get-caller-file@2.0.5: {} + get-east-asian-width@1.6.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -8207,10 +7359,10 @@ snapshots: dependencies: seed-random: 2.2.0 - kysely@0.22.0: {} - kysely@0.28.11: {} + kysely@0.29.4: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -8319,6 +7471,8 @@ snapshots: ms@2.1.3: {} + multiformats@13.4.2: {} + multiformats@9.9.0: {} mz@2.7.0: @@ -8498,15 +7652,17 @@ snapshots: p-map@2.1.0: {} - p-queue@6.6.2: + p-queue@8.1.1: dependencies: - eventemitter3: 4.0.7 - p-timeout: 3.2.0 + eventemitter3: 5.0.4 + p-timeout: 6.1.4 p-timeout@3.2.0: dependencies: p-finally: 1.0.0 + p-timeout@6.1.4: {} + p-try@2.2.0: {} p-wait-for@3.2.0: @@ -8531,6 +7687,8 @@ snapshots: parseurl@1.3.3: {} + path-browserify@1.0.1: {} + path-exists@4.0.0: {} path-key@3.1.1: {} @@ -8572,12 +7730,12 @@ snapshots: dependencies: split2: 4.2.0 - pino-http@8.6.1: + pino-http@11.0.0: dependencies: get-caller-file: 2.0.5 - pino: 8.21.0 - pino-std-serializers: 6.2.2 - process-warning: 3.0.0 + pino: 10.3.1 + pino-std-serializers: 7.1.0 + process-warning: 5.0.0 pino-std-serializers@6.2.2: {} @@ -9104,6 +8262,17 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.1.2 + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.6.0 + strip-ansi: 7.1.2 + + string-width@8.2.2: + dependencies: + get-east-asian-width: 1.6.0 + strip-ansi: 7.1.2 + string.prototype.trim@1.2.10: dependencies: call-bind: 1.0.9 @@ -9143,8 +8312,6 @@ snapshots: strip-json-comments@2.0.1: {} - strnum@2.1.2: {} - strtok3@6.3.0: dependencies: '@tokenizer/token': 0.3.0 @@ -9217,8 +8384,6 @@ snapshots: tinyrainbow@3.0.3: {} - tlds@1.261.0: {} - to-regex-range@5.0.1: dependencies: is-number: 7.0.0 @@ -9240,6 +8405,11 @@ snapshots: ts-dedent@2.2.0: {} + ts-morph@27.0.2: + dependencies: + '@ts-morph/common': 0.28.1 + code-block-writer: 13.0.3 + tsconfig-paths@3.15.0: dependencies: '@types/json5': 0.0.29 @@ -9334,6 +8504,10 @@ snapshots: dependencies: multiformats: 9.9.0 + uint8arrays@5.1.1: + dependencies: + multiformats: 13.4.2 + unbox-primitive@1.1.0: dependencies: call-bound: 1.0.4 @@ -9345,6 +8519,10 @@ snapshots: undici@6.23.0: {} + undici@7.29.0: {} + + undici@8.10.0: {} + unicode-segmenter@0.14.5: {} unicorn-magic@0.4.0: {} @@ -9557,14 +8735,33 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.2 + wrap-ansi@9.0.2: + dependencies: + ansi-styles: 6.2.3 + string-width: 7.2.0 + strip-ansi: 7.1.2 + wrappy@1.0.2: {} ws@8.19.0: {} xmlbuilder@15.1.1: {} + y18n@5.0.8: {} + yaml@2.8.3: {} + yargs-parser@22.0.0: {} + + yargs@18.1.0: + dependencies: + cliui: 9.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + string-width: 8.2.2 + y18n: 5.0.8 + yargs-parser: 22.0.0 + yocto-queue@0.1.0: {} yup@1.7.1: From 16098dcfaa5852fd8fc72b11f4ea3c18ef33d7c7 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Mon, 3 Aug 2026 20:09:22 +0100 Subject: [PATCH 51/84] refactor(pds-core): repoint oauth-provider imports to 0.21 subpaths Bead atproto-pd5.3. oauth-provider 0.20.0 removed the bare barrel entrypoint (confirmed absent in 0.21.1: exports are ./errors ./constants ./store ./hooks ./provider ./verifier ./middleware ./utils). Repoint each pds-core import: - HandleUnavailableError -> @atproto/oauth-provider/errors - DEVICE_ID_*/SESSION_ID_* consts -> @atproto/oauth-provider/constants - DeviceAccount, DeviceId (types) -> @atproto/oauth-provider/store - OAuthProvider (type) -> @atproto/oauth-provider/provider No BrandingInput/CustomizationInput references exist in pds-core, so no rename needed. All 4 module-not-found (TS2307) errors are resolved. The remaining build errors are strong did/handle typing + the changed Account shape, fixed in bead atproto-pd5.5. Co-Authored-By: Claude Opus 4.8 --- packages/pds-core/src/auth-ui-guard.ts | 5 +++-- packages/pds-core/src/index.ts | 2 +- packages/pds-core/src/lib/device-accounts.ts | 9 +++------ 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/packages/pds-core/src/auth-ui-guard.ts b/packages/pds-core/src/auth-ui-guard.ts index a053b24b..c7f72aea 100644 --- a/packages/pds-core/src/auth-ui-guard.ts +++ b/packages/pds-core/src/auth-ui-guard.ts @@ -33,13 +33,14 @@ * See docs/design/session-reuse-bugs.md for the full failure-mode taxonomy. */ import type { NextFunction, Request, Response } from 'express' -import type { DeviceAccount, OAuthProvider } from '@atproto/oauth-provider' +import type { DeviceAccount } from '@atproto/oauth-provider/store' +import type { OAuthProvider } from '@atproto/oauth-provider/provider' import { DEVICE_ID_BYTES_LENGTH, DEVICE_ID_PREFIX, SESSION_ID_BYTES_LENGTH, SESSION_ID_PREFIX, -} from '@atproto/oauth-provider' +} from '@atproto/oauth-provider/constants' import type { Logger } from 'pino' import { parsePromptTokens as parsePromptTokensShared, diff --git a/packages/pds-core/src/index.ts b/packages/pds-core/src/index.ts index 1b361356..f52db9ae 100644 --- a/packages/pds-core/src/index.ts +++ b/packages/pds-core/src/index.ts @@ -35,7 +35,7 @@ const atprotoPdsPkg: { version: string } = JSON.parse( 'utf8', ), ) -import { HandleUnavailableError } from '@atproto/oauth-provider' +import { HandleUnavailableError } from '@atproto/oauth-provider/errors' import { generateRandomHandle, createLogger, diff --git a/packages/pds-core/src/lib/device-accounts.ts b/packages/pds-core/src/lib/device-accounts.ts index e3a4d553..8442b90f 100644 --- a/packages/pds-core/src/lib/device-accounts.ts +++ b/packages/pds-core/src/lib/device-accounts.ts @@ -11,17 +11,14 @@ * return null — never partial data — so callers don't accidentally trust * a stale half-state. */ -import type { - DeviceAccount, - DeviceId, - OAuthProvider, -} from '@atproto/oauth-provider' +import type { DeviceAccount, DeviceId } from '@atproto/oauth-provider/store' +import type { OAuthProvider } from '@atproto/oauth-provider/provider' import { DEVICE_ID_BYTES_LENGTH, DEVICE_ID_PREFIX, SESSION_ID_BYTES_LENGTH, SESSION_ID_PREFIX, -} from '@atproto/oauth-provider' +} from '@atproto/oauth-provider/constants' import type { Logger } from 'pino' const DEVICE_ID_RE = new RegExp( From b0aa58c11ac2a254909062496af09e9e5ed2bf0d Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Mon, 3 Aug 2026 20:20:57 +0100 Subject: [PATCH 52/84] fix(pds-core): adopt strongly-typed Account did/handle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bead atproto-pd5.5. oauth-provider-api 0.7 retyped Account: the OIDC-style `sub`/`preferred_username` string fields became branded `did: DidString` and `handle?: HandleString`, and pds/oauth-provider getAccount/createAccount now require branded DidString/HandleString/AtIdentifierString rather than plain strings. - add @atproto/syntax ^0.7.2 (direct dep) for the isValid* type guards - import Account type; drop the `let account: any` escape hatch — type it Account | undefined and narrow before use - account.sub -> account.did at every read site - brand handles via isValidHandle before createAccount / getAccount: - constructed chosen handle: invalid => 500 (would be a construction bug) - generated random handle: invalid => throw (retryable) - brand lookup identifiers via isValidHandle / isValidAtIdentifier, preserving behaviour and improving one case: - /_internal/account-by-handle: invalid id => { email: null } (= not found) - /_internal/check-handle: invalid handle => { exists: true } so signup blocks it up front instead of failing later at account creation - hosted-domain resolution: invalid handle => 404 (= not found) - auth-ui-guard login_hint match: account.sub/preferred_username -> account.did/handle; update the binding test fixture to the new shape pds-core build is clean and all 299 tests pass on Node 22. Co-Authored-By: Claude Opus 4.8 --- packages/pds-core/package.json | 1 + .../src/__tests__/auth-ui-guard.test.ts | 10 +-- packages/pds-core/src/auth-ui-guard.ts | 5 +- packages/pds-core/src/index.ts | 62 +++++++++++++++++-- pnpm-lock.yaml | 3 + 5 files changed, 68 insertions(+), 13 deletions(-) diff --git a/packages/pds-core/package.json b/packages/pds-core/package.json index 57c67c50..1abf66e4 100644 --- a/packages/pds-core/package.json +++ b/packages/pds-core/package.json @@ -17,6 +17,7 @@ "@atproto/oauth-provider": "^0.21.1", "@atproto/oauth-provider-ui": "^0.8.9", "@atproto/pds": "^0.5.23", + "@atproto/syntax": "^0.7.2", "@certified-app/shared": "workspace:*", "@did-plc/lib": "^0.0.4", "dotenv": "^16.3.1", diff --git a/packages/pds-core/src/__tests__/auth-ui-guard.test.ts b/packages/pds-core/src/__tests__/auth-ui-guard.test.ts index df213699..a5af01af 100644 --- a/packages/pds-core/src/__tests__/auth-ui-guard.test.ts +++ b/packages/pds-core/src/__tests__/auth-ui-guard.test.ts @@ -778,11 +778,11 @@ describe('createAuthUiGuard', () => { // --------------------------------------------------------------------------- // Helper: build a binding fixture good enough for the guard's matchesHint - // logic. Only `account.sub` and `account.preferred_username` are read. - function binding(sub: string, pu: string) { + // logic. Only `account.did` and `account.handle` are read. + function binding(did: string, handle: string) { return { - account: { sub, preferred_username: pu }, - } as unknown as { account: { sub: string; preferred_username: string } } + account: { did, handle }, + } as unknown as { account: { did: string; handle: string } } } // urn-prefixed request_uri so loadStoredPar actually attempts the read @@ -878,7 +878,7 @@ describe('createAuthUiGuard', () => { // one stale and one fresh. The checkLoginRequired predicate marks // only `did:plc:stale` as loginRequired. const onlyStaleIsLoginRequired = (b: unknown): boolean => - (b as { account: { sub: string } }).account.sub === 'did:plc:stale' + (b as { account: { did: string } }).account.did === 'did:plc:stale' const TWO_BINDINGS = [ binding('did:plc:stale', 'stale.example'), binding('did:plc:fresh', 'fresh.example'), diff --git a/packages/pds-core/src/auth-ui-guard.ts b/packages/pds-core/src/auth-ui-guard.ts index c7f72aea..cc89ebe6 100644 --- a/packages/pds-core/src/auth-ui-guard.ts +++ b/packages/pds-core/src/auth-ui-guard.ts @@ -381,8 +381,9 @@ function filterCandidateBindings( ): Binding[] { if (!loginHint) return bindings const matched = bindings.filter( - ({ account }) => - account.sub === loginHint || account.preferred_username === loginHint, + // Account is now strongly typed (oauth-provider-api 0.7): sub -> did, + // preferred_username -> handle (handle is optional). + ({ account }) => account.did === loginHint || account.handle === loginHint, ) return matched.length === 1 ? matched : bindings } diff --git a/packages/pds-core/src/index.ts b/packages/pds-core/src/index.ts index f52db9ae..b5a37590 100644 --- a/packages/pds-core/src/index.ts +++ b/packages/pds-core/src/index.ts @@ -36,6 +36,12 @@ const atprotoPdsPkg: { version: string } = JSON.parse( ), ) import { HandleUnavailableError } from '@atproto/oauth-provider/errors' +import type { Account } from '@atproto/oauth-provider/store' +import { + isValidHandle, + isValidAtIdentifier, + type DidString, +} from '@atproto/syntax' import { generateRandomHandle, createLogger, @@ -326,16 +332,29 @@ async function main() { // the HMAC-signed callback; by the time we reach here, email is the verified primary. const existingAccount = await pds.ctx.accountManager.getAccountByEmail(email) - let did: string | undefined = existingAccount?.did + // existingAccount.did is already branded DidString (ActorAccount). + let did: DidString | undefined = existingAccount?.did - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- @atproto/oauth-provider Account type not exported - let account: any + let account: Account | undefined if (did) { // Existing account const accountData = await provider.accountManager.getAccount(did) account = accountData.account } else if (chosenHandle) { + // chosenHandle is `${localPart}.${handleDomain}`, already validated via + // validateLocalPart above. Brand it as HandleString for the strongly + // typed account APIs; a failure here means the constructed handle is + // malformed (a bug), so fail loudly rather than proceed. + if (!isValidHandle(chosenHandle)) { + logger.error( + { handle: chosenHandle }, + 'constructed handle failed validation', + ) + res.status(500).send('Invalid handle') + return + } + // User chose a handle — pre-check existence before attempting createAccount. // This avoids treating non-collision errors (datastore failures, invite-code // misconfiguration, etc.) as handle collisions. @@ -374,7 +393,7 @@ async function main() { inviteCode: process.env.EPDS_INVITE_CODE, }, ) - did = account.sub + did = account.did logger.info( { did, email, handle: chosenHandle }, 'Created account with chosen handle', @@ -414,6 +433,13 @@ async function main() { for (let attempt = 0; attempt < 3; attempt++) { try { const randomHandle = generateRandomHandle(handleDomain) + // generateRandomHandle always yields `${local}.${domain}`; brand it + // for the strongly typed createAccount input. + if (!isValidHandle(randomHandle)) { + throw new Error( + `generated handle failed validation: ${randomHandle}`, + ) + } account = await provider.accountManager.createAccount( deviceId, deviceMetadata, @@ -431,7 +457,7 @@ async function main() { inviteCode: process.env.EPDS_INVITE_CODE, }, ) - did = account.sub + did = account.did logger.info({ did, email, handle: randomHandle }, 'Created account') break } catch (createErr: unknown) { @@ -445,7 +471,13 @@ async function main() { } // Step 4: Bind account to device session (for future SSO). - await provider.accountManager.upsertDeviceAccount(deviceId, account.sub) + if (!account) { + // Unreachable: every branch above either assigns account or returns/throws. + logger.error({ email }, 'account unexpectedly unset after resolution') + res.status(500).send('Account resolution failed') + return + } + await provider.accountManager.upsertDeviceAccount(deviceId, account.did) // Step 5: Determine whether to skip consent on sign-up. // Consent is skipped only when ALL of these hold: @@ -1002,6 +1034,11 @@ async function main() { res.status(400).json({ error: 'Missing handle' }) return } + if (!isValidAtIdentifier(handle)) { + // A syntactically invalid identifier cannot match any account. + res.json({ email: null }) + return + } try { const account = await pds.ctx.accountManager.getAccount(handle) res.json({ email: account?.email ?? null }) @@ -1024,6 +1061,13 @@ async function main() { res.status(400).json({ error: 'missing handle param' }) return } + if (!isValidHandle(handle)) { + // A syntactically invalid handle can never be registered, so report it as + // unavailable rather than "free" (which would let signup proceed and then + // fail at account creation). + res.json({ exists: true }) + return + } try { const account = await pds.ctx.accountManager.getAccount(handle) res.json({ exists: !!account }) @@ -1190,6 +1234,12 @@ async function checkHandleRoute( message: 'handles are not provided on this domain', }) } + if (!isValidHandle(domain)) { + return res.status(404).json({ + error: 'NotFound', + message: 'handle not found for this domain', + }) + } const account = await pds.ctx.accountManager.getAccount(domain) if (!account) { return res.status(404).json({ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c0b8257e..a56903ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -152,6 +152,9 @@ importers: '@atproto/pds': specifier: ^0.5.23 version: 0.5.23 + '@atproto/syntax': + specifier: ^0.7.2 + version: 0.7.2 '@certified-app/shared': specifier: workspace:* version: link:../shared From 9a064fdce12438e3ad0881b7080046906bc05e37 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Mon, 3 Aug 2026 20:29:50 +0100 Subject: [PATCH 53/84] fix(pds-core): match chooser enrichment to oauth-provider-ui 0.8 markup Bead atproto-pd5.6. oauth-provider-ui 0.4->0.8 restructured the account chooser and changed its copy (strings are now runtime-resolved i18n message ids, so the served DOM text changed): - "Another account" rebind: the aria-label changed from "Login to account that is not listed" to "Sign in to an account that is not listed", and the visible text from "Another account" to "Select another account". Match either variant (aria-label list + text list) so the rebind survives this change and degrades rather than breaks on the next copy tweak. Verified both new strings are present in the installed 0.8.9 bundle. - "Sign up" hide: 0.8 dropped the signup affordance from the account- selector page entirely (no "Sign up"/"Create account" string in the account-page bundle), so hideSignup() is now a no-op. Kept as a cheap guard and documented the change. client-css-injection.ts needs no change: it keys off `` and the CSP style-src header, both markup-version-independent. The email-beside-handle enrichment matches on handle/DID text content, not aria-labels, so it is unaffected. pds-core build clean, all 299 tests pass on Node 22 (chooser test updated to assert the new + legacy selector variants). Co-Authored-By: Claude Opus 4.8 --- .../src/__tests__/chooser-enrichment.test.ts | 10 ++++-- packages/pds-core/src/chooser-enrichment.ts | 33 ++++++++++++++----- 2 files changed, 31 insertions(+), 12 deletions(-) diff --git a/packages/pds-core/src/__tests__/chooser-enrichment.test.ts b/packages/pds-core/src/__tests__/chooser-enrichment.test.ts index 53ae36bf..3539f393 100644 --- a/packages/pds-core/src/__tests__/chooser-enrichment.test.ts +++ b/packages/pds-core/src/__tests__/chooser-enrichment.test.ts @@ -507,9 +507,13 @@ describe('buildChooserEnrichmentScript sign-up hide + another-account rebind', ( // Capture-phase is essential — React's delegated root-level click // listener fires in bubble phase, so a bubble listener on the button // would run AFTER React swaps to upstream's stock sign-in component. - expect(script).toContain( - '\'[role="button"][aria-label="Login to account that is not listed"]\'', - ) + // oauth-provider-ui 0.8 changed the copy, so the script matches both + // the current and previous aria-label / visible-text variants. + expect(script).toContain('Sign in to an account that is not listed') + expect(script).toContain('Login to account that is not listed') + expect(script).toContain('Select another account') + expect(script).toContain('Another account') + expect(script).toContain('[role="button"][aria-label="') expect(script).toContain('e.preventDefault()') expect(script).toContain('e.stopImmediatePropagation()') expect(script).toContain('window.location.href') diff --git a/packages/pds-core/src/chooser-enrichment.ts b/packages/pds-core/src/chooser-enrichment.ts index 871a72f6..d3396734 100644 --- a/packages/pds-core/src/chooser-enrichment.ts +++ b/packages/pds-core/src/chooser-enrichment.ts @@ -238,6 +238,11 @@ export function buildChooserEnrichmentScript(): string { // its compiled bundle. Match by exact text content; the button lives // inside #root alongside the chooser list. Idempotent via // dataset.epdsHidden so the MutationObserver doesn't thrash. + // + // NOTE: oauth-provider-ui 0.8 dropped the "Sign up" button from the + // account-selector page (no "Sign up"/"Create account" string in the + // account-page bundle), so this is currently a no-op. Kept as a cheap + // guard in case a signup affordance returns to the chooser upstream. function hideSignup() { var root = document.getElementById('root'); if (!root) return; @@ -269,19 +274,29 @@ export function buildChooserEnrichmentScript(): string { if (!root) return; // Upstream @atproto/oauth-provider-ui renders this as a // div-with-role, NOT a native button: - //

- // Another account + //
+ // Select another account //
- // The aria-label is more stable across upstream copy changes than - // the visible text, so match on that with a text-content fallback - // scoped to anything with role=button (div OR button). - var btn = root.querySelector( - '[role="button"][aria-label="Login to account that is not listed"]', - ); + // The aria-label copy changed in oauth-provider-ui 0.8 ("Login to + // account…" -> "Sign in to an account…", visible text "Another + // account" -> "Select another account"). Match either aria-label, + // then fall back to either visible-text variant scoped to role=button + // (div OR button) so a future copy tweak degrades rather than breaks. + var ARIA_LABELS = [ + 'Sign in to an account that is not listed', + 'Login to account that is not listed', + ]; + var TEXTS = ['Select another account', 'Another account']; + var btn = null; + for (var a = 0; a < ARIA_LABELS.length && !btn; a++) { + btn = root.querySelector( + '[role="button"][aria-label="' + ARIA_LABELS[a] + '"]', + ); + } if (!btn) { var candidates = root.querySelectorAll('[role="button"]'); for (var i = 0; i < candidates.length; i++) { - if ((candidates[i].textContent || '').trim() === 'Another account') { + if (TEXTS.indexOf((candidates[i].textContent || '').trim()) >= 0) { btn = candidates[i]; break; } From ebd214effb0072b5280f2902083f8d0ab1366e4d Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Mon, 3 Aug 2026 20:54:56 +0100 Subject: [PATCH 54/84] docs(changeset): atproto upstream upgrade + Node 22 requirement Bead atproto-pd5.8. Document the upstream bump (pds 0.5.23, oauth-provider 0.21.1, oauth-provider-ui 0.8.9) and the operator-facing Node 22 requirement for the release changelog. Full validation on Node 22: recursive build clean, typecheck clean, 1084 unit tests pass, coverage thresholds hold (stmts 58.4 / branch 57.6 / funcs 71.9 / lines 57.2), pds-core boots as ESM. e2e deferred to CI (needs a live stack). Co-Authored-By: Claude Opus 4.8 --- .changeset/atproto-upstream-upgrade.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 .changeset/atproto-upstream-upgrade.md diff --git a/.changeset/atproto-upstream-upgrade.md b/.changeset/atproto-upstream-upgrade.md new file mode 100644 index 00000000..8bea327c --- /dev/null +++ b/.changeset/atproto-upstream-upgrade.md @@ -0,0 +1,15 @@ +--- +'ePDS': minor +--- + +Signing in and managing your account now run on the latest AT Protocol server, which requires operators to upgrade to Node.js 22. + +**Affects:** End users, Operators + +**End users:** the account and sign-in screens now come from a newer version of the underlying AT Protocol software, bringing email verification, changing your handle, and deactivating or deleting your account through the account-management interface. The passwordless email-code sign-in you already use is unchanged. + +**Operators:** + +- ePDS now requires **Node.js 22** (previously 20). Deployments pinned to Node 20 must upgrade; the Docker images already use `node:22-alpine`. `engines.node` is now `>=22`. +- The bundled AT Protocol packages moved to their latest releases: `@atproto/pds` 0.5.23, `@atproto/oauth-provider` 0.21.1, `@atproto/oauth-provider-ui` 0.8.9. +- No configuration changes are required, but the native `better-sqlite3` module must be built for the Node 22 ABI — a clean `pnpm install` on Node 22 handles this; an in-place Node version switch needs `pnpm rebuild better-sqlite3`. From d785908e624990fd95a330634d6a65f4adb1a5bf Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Mon, 3 Aug 2026 22:07:08 +0100 Subject: [PATCH 55/84] fix(pds-core): replace __dirname with import.meta.url under ESM MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ESM migration missed a `__dirname` at the favicon/public-dir setup (index.ts). `__dirname` is a CommonJS global and is undefined in pure ESM, so the pds-core container crashed at startup with `ReferenceError: __dirname is not defined` — after config parsing, which is why the local docker boot-test (which aborted earlier on missing blobstore config) and the vitest suite (vitest provides __dirname) both missed it. It surfaced only on the Railway deploy, where full env let startup reach the crash and the healthcheck then failed. Derive the module directory from `fileURLToPath(import.meta.url)` instead. Verified in-container on Node 22: the image now boots to "ePDS running" (listening on :3000) with valid env; no __dirname reference remains in the compiled dist. Build + 299 pds-core tests still green. Co-Authored-By: Claude Opus 4.8 --- packages/pds-core/src/index.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/pds-core/src/index.ts b/packages/pds-core/src/index.ts index b5a37590..2added7e 100644 --- a/packages/pds-core/src/index.ts +++ b/packages/pds-core/src/index.ts @@ -25,6 +25,7 @@ applyPdsPortFallback() import type * as http from 'node:http' import { randomBytes } from 'node:crypto' import * as path from 'node:path' +import { fileURLToPath } from 'node:url' import { PDS, envToCfg, envToSecrets, readEnv } from '@atproto/pds' import { readFileSync } from 'node:fs' import { createRequire } from 'node:module' @@ -941,7 +942,9 @@ async function main() { // pds-core-rendered error page and the /preview/consent shell can // reference the Certified favicon without a cross-origin request to // the auth-service host. - const publicDir = path.resolve(__dirname, '..', 'public') + // ESM has no __dirname; derive this module's directory from import.meta.url. + const moduleDir = path.dirname(fileURLToPath(import.meta.url)) + const publicDir = path.resolve(moduleDir, '..', 'public') pds.app.get('/favicon.ico', (_req, res) => { res.sendFile(path.join(publicDir, 'favicon.svg')) }) From c7faa3514ae20e84ea63a06b24fd9164852dfcf2 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Mon, 3 Aug 2026 23:34:11 +0100 Subject: [PATCH 56/84] test(pds-core): extract + cover internal handle-guard decisions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Recover the coverage the strong-typing change (bead .5) dropped: the branded isValid* guards on the /_internal handle-lookup endpoints lived inline in the untested main() and pulled overall coverage down. Extract them into src/lib/identifier-guards.ts as type predicates (canLookUpAccountByHandle / canCheckHandle / canResolveHandle) — following the upstream-favicon.ts extract-and-test pattern — so the per-endpoint decision is documented and unit-tested, while the `x is HandleString`/`AtIdentifierString` predicate form preserves the narrowing getAccount() needs. Covers the non-obvious case (an invalid handle on the availability check reads as taken, not free) and the DID-vs-handle distinction between endpoints. 12 new tests; pds-core suite now 311 passing. Build clean. Co-Authored-By: Claude Opus 4.8 --- .../src/__tests__/identifier-guards.test.ts | 62 +++++++++++++++++++ packages/pds-core/src/index.ts | 15 ++--- .../pds-core/src/lib/identifier-guards.ts | 50 +++++++++++++++ 3 files changed, 120 insertions(+), 7 deletions(-) create mode 100644 packages/pds-core/src/__tests__/identifier-guards.test.ts create mode 100644 packages/pds-core/src/lib/identifier-guards.ts diff --git a/packages/pds-core/src/__tests__/identifier-guards.test.ts b/packages/pds-core/src/__tests__/identifier-guards.test.ts new file mode 100644 index 00000000..8f7f1836 --- /dev/null +++ b/packages/pds-core/src/__tests__/identifier-guards.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import { + canLookUpAccountByHandle, + canCheckHandle, + canResolveHandle, +} from '../lib/identifier-guards.js' + +describe('canLookUpAccountByHandle', () => { + it('accepts a valid handle', () => { + expect(canLookUpAccountByHandle('alice.example.com')).toBe(true) + }) + + it('accepts a valid DID', () => { + expect(canLookUpAccountByHandle('did:plc:abc123')).toBe(true) + }) + + it('rejects a syntactically invalid identifier', () => { + expect(canLookUpAccountByHandle('not a handle')).toBe(false) + }) + + it('rejects an empty string', () => { + expect(canLookUpAccountByHandle('')).toBe(false) + }) + + it('rejects a bare word with no dot and no did: prefix', () => { + expect(canLookUpAccountByHandle('alice')).toBe(false) + }) +}) + +describe('canCheckHandle', () => { + it('accepts a valid handle', () => { + expect(canCheckHandle('alice.example.com')).toBe(true) + }) + + it('rejects a DID (this endpoint only accepts handles)', () => { + // A DID is not a HandleString, so availability-check treats it as invalid + // — which the endpoint reports as "taken", never "free". + expect(canCheckHandle('did:plc:abc123')).toBe(false) + }) + + it('rejects a syntactically invalid handle', () => { + expect(canCheckHandle('has spaces.example.com')).toBe(false) + }) + + it('rejects a handle with no dot', () => { + expect(canCheckHandle('alice')).toBe(false) + }) +}) + +describe('canResolveHandle', () => { + it('accepts a valid hosted handle', () => { + expect(canResolveHandle('alice.hosted.example')).toBe(true) + }) + + it('rejects a syntactically invalid handle', () => { + expect(canResolveHandle('bad handle')).toBe(false) + }) + + it('rejects an empty domain', () => { + expect(canResolveHandle('')).toBe(false) + }) +}) diff --git a/packages/pds-core/src/index.ts b/packages/pds-core/src/index.ts index 2added7e..f4913ae3 100644 --- a/packages/pds-core/src/index.ts +++ b/packages/pds-core/src/index.ts @@ -38,11 +38,12 @@ const atprotoPdsPkg: { version: string } = JSON.parse( ) import { HandleUnavailableError } from '@atproto/oauth-provider/errors' import type { Account } from '@atproto/oauth-provider/store' +import { isValidHandle, type DidString } from '@atproto/syntax' import { - isValidHandle, - isValidAtIdentifier, - type DidString, -} from '@atproto/syntax' + canLookUpAccountByHandle, + canCheckHandle, + canResolveHandle, +} from './lib/identifier-guards.js' import { generateRandomHandle, createLogger, @@ -1037,7 +1038,7 @@ async function main() { res.status(400).json({ error: 'Missing handle' }) return } - if (!isValidAtIdentifier(handle)) { + if (!canLookUpAccountByHandle(handle)) { // A syntactically invalid identifier cannot match any account. res.json({ email: null }) return @@ -1064,7 +1065,7 @@ async function main() { res.status(400).json({ error: 'missing handle param' }) return } - if (!isValidHandle(handle)) { + if (!canCheckHandle(handle)) { // A syntactically invalid handle can never be registered, so report it as // unavailable rather than "free" (which would let signup proceed and then // fail at account creation). @@ -1237,7 +1238,7 @@ async function checkHandleRoute( message: 'handles are not provided on this domain', }) } - if (!isValidHandle(domain)) { + if (!canResolveHandle(domain)) { return res.status(404).json({ error: 'NotFound', message: 'handle not found for this domain', diff --git a/packages/pds-core/src/lib/identifier-guards.ts b/packages/pds-core/src/lib/identifier-guards.ts new file mode 100644 index 00000000..0abcb1aa --- /dev/null +++ b/packages/pds-core/src/lib/identifier-guards.ts @@ -0,0 +1,50 @@ +/** + * Branded-identifier guards for the `/_internal` handle-lookup endpoints. + * + * Upstream's account APIs now require branded `HandleString` / + * `AtIdentifierString` values (oauth-provider-api 0.7 / @atproto/syntax), so + * each endpoint validates its input before calling accountManager. The guard + * decision differs by endpoint, and one case is non-obvious (an invalid handle + * on the availability check must read as *taken*, not *free*). Extracting the + * decisions here — following the `upstream-favicon.ts` pattern — keeps them + * unit-testable without standing up the PDS. + * + * These are type predicates: a `true` result narrows the argument to the + * branded type the accountManager call requires, so callers get both the + * documented decision and the compile-time narrowing. + */ +import { + isValidHandle, + isValidAtIdentifier, + type HandleString, + type AtIdentifierString, +} from '@atproto/syntax' + +/** + * `/_internal/account-by-handle` accepts a handle OR a DID. An invalid + * identifier cannot match any account, so a `false` result short-circuits the + * endpoint to `{ email: null }` (its not-found response). + */ +export function canLookUpAccountByHandle( + identifier: string, +): identifier is AtIdentifierString { + return isValidAtIdentifier(identifier) +} + +/** + * `/_internal/check-handle` (signup availability) accepts only a handle. A + * `false` result means the handle can never be registered, so the endpoint + * reports it as *unavailable* (`{ exists: true }`) rather than free — + * otherwise signup proceeds and then fails at account creation. + */ +export function canCheckHandle(handle: string): handle is HandleString { + return isValidHandle(handle) +} + +/** + * Hosted-domain handle resolution accepts only a handle. A `false` result + * resolves to 404, the same as an absent account. + */ +export function canResolveHandle(handle: string): handle is HandleString { + return isValidHandle(handle) +} From d38639975e8153d25b98985b2bf6ee364f8a14fa Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 4 Aug 2026 00:52:21 +0100 Subject: [PATCH 57/84] test(pds-core): cover constructed-handle branding guard Extend the identifier-guard extraction to the createAccount handle-branding sites (chosen + random handle) via isValidConstructedHandle, moving the last inline isValidHandle calls out of the untested main() and into the tested lib/identifier-guards module. index.ts no longer imports isValidHandle directly. +4 tests (16 total in identifier-guards). Build clean, full suite 1100 passing. Co-Authored-By: Claude Opus 4.8 --- .../src/__tests__/identifier-guards.test.ts | 19 +++++++++++++++++++ packages/pds-core/src/index.ts | 7 ++++--- .../pds-core/src/lib/identifier-guards.ts | 14 ++++++++++++++ 3 files changed, 37 insertions(+), 3 deletions(-) diff --git a/packages/pds-core/src/__tests__/identifier-guards.test.ts b/packages/pds-core/src/__tests__/identifier-guards.test.ts index 8f7f1836..d84cdcf4 100644 --- a/packages/pds-core/src/__tests__/identifier-guards.test.ts +++ b/packages/pds-core/src/__tests__/identifier-guards.test.ts @@ -3,6 +3,7 @@ import { canLookUpAccountByHandle, canCheckHandle, canResolveHandle, + isValidConstructedHandle, } from '../lib/identifier-guards.js' describe('canLookUpAccountByHandle', () => { @@ -60,3 +61,21 @@ describe('canResolveHandle', () => { expect(canResolveHandle('')).toBe(false) }) }) + +describe('isValidConstructedHandle', () => { + it('accepts a well-formed `${local}.${domain}` handle', () => { + expect(isValidConstructedHandle('alice.example.com')).toBe(true) + }) + + it('accepts a generated random-style handle', () => { + expect(isValidConstructedHandle('brave-otter-42.example.com')).toBe(true) + }) + + it('rejects a handle with no domain suffix (construction bug)', () => { + expect(isValidConstructedHandle('alice')).toBe(false) + }) + + it('rejects a handle containing whitespace', () => { + expect(isValidConstructedHandle('alice bob.example.com')).toBe(false) + }) +}) diff --git a/packages/pds-core/src/index.ts b/packages/pds-core/src/index.ts index f4913ae3..8c36d9c6 100644 --- a/packages/pds-core/src/index.ts +++ b/packages/pds-core/src/index.ts @@ -38,11 +38,12 @@ const atprotoPdsPkg: { version: string } = JSON.parse( ) import { HandleUnavailableError } from '@atproto/oauth-provider/errors' import type { Account } from '@atproto/oauth-provider/store' -import { isValidHandle, type DidString } from '@atproto/syntax' +import type { DidString } from '@atproto/syntax' import { canLookUpAccountByHandle, canCheckHandle, canResolveHandle, + isValidConstructedHandle, } from './lib/identifier-guards.js' import { generateRandomHandle, @@ -348,7 +349,7 @@ async function main() { // validateLocalPart above. Brand it as HandleString for the strongly // typed account APIs; a failure here means the constructed handle is // malformed (a bug), so fail loudly rather than proceed. - if (!isValidHandle(chosenHandle)) { + if (!isValidConstructedHandle(chosenHandle)) { logger.error( { handle: chosenHandle }, 'constructed handle failed validation', @@ -437,7 +438,7 @@ async function main() { const randomHandle = generateRandomHandle(handleDomain) // generateRandomHandle always yields `${local}.${domain}`; brand it // for the strongly typed createAccount input. - if (!isValidHandle(randomHandle)) { + if (!isValidConstructedHandle(randomHandle)) { throw new Error( `generated handle failed validation: ${randomHandle}`, ) diff --git a/packages/pds-core/src/lib/identifier-guards.ts b/packages/pds-core/src/lib/identifier-guards.ts index 0abcb1aa..570ed008 100644 --- a/packages/pds-core/src/lib/identifier-guards.ts +++ b/packages/pds-core/src/lib/identifier-guards.ts @@ -48,3 +48,17 @@ export function canCheckHandle(handle: string): handle is HandleString { export function canResolveHandle(handle: string): handle is HandleString { return isValidHandle(handle) } + +/** + * A handle constructed for account creation — either `${localPart}.${domain}` + * from the user's chosen local part (already validated by `validateLocalPart`) + * or one produced by `generateRandomHandle`. Both should always be valid; a + * `false` result means the construction is buggy, and the caller fails loudly + * (500 for the chosen handle, throw+retry for the random one) rather than + * passing an unbranded string to `createAccount`. + */ +export function isValidConstructedHandle( + handle: string, +): handle is HandleString { + return isValidHandle(handle) +} From 12147f11fd36a38449aef40a3c5e7b9a9fcf730b Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 4 Aug 2026 00:57:17 +0100 Subject: [PATCH 58/84] fix(pds-core): tighten Node floor to 22.19.0 and align tsconfig module Address PR review (Copilot, CodeRabbit): - engines.node >=22 -> >=22.19.0 (root + pds-core) and .nvmrc 22 -> 22.19.0: the lockfile pulls undici@8.10.0 (via @atproto) which declares engines.node >=22.19.0, so >=22 would allow installs on 22.0-22.18 that break at runtime. Changeset updated to state the 22.19 floor and why. - pds-core tsconfig module/moduleResolution NodeNext -> Node16, matching the repo convention (root, auth-service, shared all use Node16). Node16 fully supports the pure-ESM build; verified tsc --build clean. Build + typecheck clean, 315 pds-core tests pass on Node 22. Co-Authored-By: Claude Opus 4.8 --- .changeset/atproto-upstream-upgrade.md | 4 ++-- .nvmrc | 2 +- package.json | 2 +- packages/pds-core/package.json | 2 +- packages/pds-core/tsconfig.json | 4 ++-- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.changeset/atproto-upstream-upgrade.md b/.changeset/atproto-upstream-upgrade.md index 8bea327c..9de9d840 100644 --- a/.changeset/atproto-upstream-upgrade.md +++ b/.changeset/atproto-upstream-upgrade.md @@ -2,7 +2,7 @@ 'ePDS': minor --- -Signing in and managing your account now run on the latest AT Protocol server, which requires operators to upgrade to Node.js 22. +Signing in and managing your account now run on the latest AT Protocol server, which requires operators to upgrade to Node.js 22.19 or newer. **Affects:** End users, Operators @@ -10,6 +10,6 @@ Signing in and managing your account now run on the latest AT Protocol server, w **Operators:** -- ePDS now requires **Node.js 22** (previously 20). Deployments pinned to Node 20 must upgrade; the Docker images already use `node:22-alpine`. `engines.node` is now `>=22`. +- ePDS now requires **Node.js 22.19.0 or newer** (previously 20). Deployments pinned to Node 20 must upgrade; the Docker images already use `node:22-alpine`. `engines.node` is now `>=22.19.0` (the floor is set by a transitive `undici@8` runtime dependency, which fails on Node 22.0–22.18). - The bundled AT Protocol packages moved to their latest releases: `@atproto/pds` 0.5.23, `@atproto/oauth-provider` 0.21.1, `@atproto/oauth-provider-ui` 0.8.9. - No configuration changes are required, but the native `better-sqlite3` module must be built for the Node 22 ABI — a clean `pnpm install` on Node 22 handles this; an in-place Node version switch needs `pnpm rebuild better-sqlite3`. diff --git a/.nvmrc b/.nvmrc index 2bd5a0a9..e2228113 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -22 +22.19.0 diff --git a/package.json b/package.json index 080e8717..6a1a3fec 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "description": "ePDS — extended Personal Data Server for AT Protocol with passwordless OTP authentication", "license": "MIT", "engines": { - "node": ">=22" + "node": ">=22.19.0" }, "packageManager": "pnpm@9.15.9", "//workspaces": "This field is only read by @manypkg/get-packages (used by Changesets) to put us into single-package mode. pnpm ignores it because pnpm-workspace.yaml takes precedence for actual workspace resolution. Do not remove without reading docs/PUBLISHING.md.", diff --git a/packages/pds-core/package.json b/packages/pds-core/package.json index 1abf66e4..4fd06ad0 100644 --- a/packages/pds-core/package.json +++ b/packages/pds-core/package.json @@ -5,7 +5,7 @@ "main": "dist/index.js", "types": "dist/index.d.ts", "engines": { - "node": ">=22" + "node": ">=22.19.0" }, "scripts": { "build": "tsc --build", diff --git a/packages/pds-core/tsconfig.json b/packages/pds-core/tsconfig.json index 3f5441d1..229fed07 100644 --- a/packages/pds-core/tsconfig.json +++ b/packages/pds-core/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", + "module": "Node16", + "moduleResolution": "Node16", "lib": ["ES2022"], "strict": true, "esModuleInterop": true, From 4338d768d2e01987ac05601cb3033f952afb94e4 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 4 Aug 2026 00:59:30 +0100 Subject: [PATCH 59/84] refactor(pds-core): group imports before module init Address CodeRabbit review: the `atprotoPdsPkg` version-read sat between the `node:` import group and the external `@atproto/*` imports. Move the two `node:` imports (fs, module) up with the other built-ins, keep all static imports together, and relocate the `atprotoPdsPkg` initialization below the import block. `dotenv.config()` / `applyPdsPortFallback()` stay at the top by design (they must run before readEnv, per the existing comments). No behaviour change; build + typecheck clean, all pds-core tests pass. Co-Authored-By: Claude Opus 4.8 --- packages/pds-core/src/index.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/pds-core/src/index.ts b/packages/pds-core/src/index.ts index 8c36d9c6..16e8a807 100644 --- a/packages/pds-core/src/index.ts +++ b/packages/pds-core/src/index.ts @@ -26,16 +26,9 @@ import type * as http from 'node:http' import { randomBytes } from 'node:crypto' import * as path from 'node:path' import { fileURLToPath } from 'node:url' -import { PDS, envToCfg, envToSecrets, readEnv } from '@atproto/pds' import { readFileSync } from 'node:fs' import { createRequire } from 'node:module' -/* v8 ignore next 4 -- module-level init, only testable via e2e */ -const atprotoPdsPkg: { version: string } = JSON.parse( - readFileSync( - createRequire(import.meta.url).resolve('@atproto/pds/package.json'), - 'utf8', - ), -) +import { PDS, envToCfg, envToSecrets, readEnv } from '@atproto/pds' import { HandleUnavailableError } from '@atproto/oauth-provider/errors' import type { Account } from '@atproto/oauth-provider/store' import type { DidString } from '@atproto/syntax' @@ -81,6 +74,14 @@ import { loadDeviceAccountEmails } from './lib/device-accounts.js' import { handleCallbackError } from './lib/epds-callback-error.js' import { installTestHooks } from './lib/test-hooks.js' +/* v8 ignore next 4 -- module-level init, only testable via e2e */ +const atprotoPdsPkg: { version: string } = JSON.parse( + readFileSync( + createRequire(import.meta.url).resolve('@atproto/pds/package.json'), + 'utf8', + ), +) + const logger = createLogger('pds-core') /** From d70111a6df9198a4c293a218b57b84ef861625a2 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 4 Aug 2026 11:17:29 +0100 Subject: [PATCH 60/84] fix(pds-core): realistic PLC DID in guard tests; correct changeset email claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address review (aspiers): - identifier-guards.test.ts: use a proper `did:plc:` value (did:plc:7iza6de2dwap2sbkpav7c6c6) instead of the abc123 placeholder at both fixture sites. Still a valid DID per @atproto/syntax, so the assertions are unchanged — just realistic. - changeset: email verification is already provided by ePDS's auth-service, so it is not an upstream gain. Reframe it as unchanged (alongside OTP sign-in) rather than listing it as new; keep handle change + deactivate/delete, which the upstream account-manager UI genuinely adds. Build + 315 pds-core tests pass. Co-Authored-By: Claude Opus 4.8 --- .changeset/atproto-upstream-upgrade.md | 2 +- packages/pds-core/src/__tests__/identifier-guards.test.ts | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.changeset/atproto-upstream-upgrade.md b/.changeset/atproto-upstream-upgrade.md index 9de9d840..ea5a5ea9 100644 --- a/.changeset/atproto-upstream-upgrade.md +++ b/.changeset/atproto-upstream-upgrade.md @@ -6,7 +6,7 @@ Signing in and managing your account now run on the latest AT Protocol server, w **Affects:** End users, Operators -**End users:** the account and sign-in screens now come from a newer version of the underlying AT Protocol software, bringing email verification, changing your handle, and deactivating or deleting your account through the account-management interface. The passwordless email-code sign-in you already use is unchanged. +**End users:** the account and sign-in screens now come from a newer version of the underlying AT Protocol software, which adds changing your handle and deactivating or deleting your account through the account-management interface. The passwordless email-code sign-in and email verification you already use through ePDS are unchanged. **Operators:** diff --git a/packages/pds-core/src/__tests__/identifier-guards.test.ts b/packages/pds-core/src/__tests__/identifier-guards.test.ts index d84cdcf4..3fd8487d 100644 --- a/packages/pds-core/src/__tests__/identifier-guards.test.ts +++ b/packages/pds-core/src/__tests__/identifier-guards.test.ts @@ -12,7 +12,9 @@ describe('canLookUpAccountByHandle', () => { }) it('accepts a valid DID', () => { - expect(canLookUpAccountByHandle('did:plc:abc123')).toBe(true) + expect(canLookUpAccountByHandle('did:plc:7iza6de2dwap2sbkpav7c6c6')).toBe( + true, + ) }) it('rejects a syntactically invalid identifier', () => { @@ -36,7 +38,7 @@ describe('canCheckHandle', () => { it('rejects a DID (this endpoint only accepts handles)', () => { // A DID is not a HandleString, so availability-check treats it as invalid // — which the endpoint reports as "taken", never "free". - expect(canCheckHandle('did:plc:abc123')).toBe(false) + expect(canCheckHandle('did:plc:7iza6de2dwap2sbkpav7c6c6')).toBe(false) }) it('rejects a syntactically invalid handle', () => { From b76a56e51effd6dc28e2bc03e9d2a27165ec3765 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 11 Aug 2026 16:12:43 +0100 Subject: [PATCH 61/84] docs(agents): record the Coveralls thresholds and why they can't be in-repo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Coverage is gated twice — by vitest.config.ts and by Coveralls — and only the first is version-controlled. The Coveralls floor and decrease thresholds exist solely on the repo's Coveralls Settings page, so nothing in the tree records what they are or why. Document both values next to the ratcheting policy they complement, and warn against the .coveralls.yml approach attempted in #240: keys such as coverage_threshold_for_failure and coverage_decreased_threshold are silently ignored. The docs list no such keys, coveralls-ruby parses only repo_token / repo_secret_token / service_name, and coverallsapp/github-action@v2 neither reads the file nor exposes a threshold input — so the file never even reaches Coveralls while looking like version-controlled policy. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index 11c28450..c7d73c39 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -185,6 +185,30 @@ thresholds: { **Never lower thresholds.** If a change removes tested code (e.g. deleting a feature), add tests for other code to compensate. +### Coveralls Thresholds (Not Version-Controlled) + +Coverage is gated twice: by `vitest.config.ts` above, and by Coveralls. +The two measure different things, so a PR can pass one and fail the other. + +Coveralls' thresholds **cannot** be set from a file in this repo. They live +only on the Coveralls repo Settings page, under "Status Updates": + +| Setting | Value | Rationale | +| --------------------------------------- | ----- | ------------------------------------------------------------ | +| Coverage threshold for failure | 55% | Backstop below the vitest floors, so vitest trips first | +| Coverage decrease threshold for failure | 0.5% | Catches drift; tolerates rounding noise and honest refactors | + +The decrease threshold is the one thing vitest cannot express: vitest checks +absolute floors only, so nothing else catches a series of small drops. + +Do **not** add a `.coveralls.yml` with keys such as +`coverage_threshold_for_failure` or `coverage_decreased_threshold`. They are +silently ignored — the docs list no such keys, `coveralls-ruby` parses only +`repo_token` / `repo_secret_token` / `service_name`, and the +`coverallsapp/github-action@v2` used in `.github/workflows/ci.yml` neither +reads the file nor exposes a threshold input. Such a file looks like +version-controlled policy while configuring nothing. + ## Docker ```bash From 534647da664abe07cf543759f94b8286dc7b81a6 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 5 Aug 2026 18:05:23 +0100 Subject: [PATCH 62/84] feat(auth): sign epds_handle_mode through the callback hop The chooser and consent screens need to know whether the account's handle was user-chosen or server-generated, so they can decide which identifier to show as primary. That mode was resolved on the way in but lost on the auth-service -> pds-core hop, so the approval step could fall back to showing a generated random handle. Carry epds_handle_mode across the hop, and sign it rather than appending it afterwards: the parameter decides what the approval screen shows, so leaving it unsigned would let the browser flip the presentation of a flow it does not own. Invalid values are dropped at the boundary instead of being forwarded. Adding a field to the signed payload changes the HMAC input, so auth-service and pds-core must be deployed together. In-flight callbacks signed by the old auth-service will fail verification on the new pds-core, and the user retries the sign-in. Also extracts resolveCompleteIdentity() from the /auth/complete handler. Unrelated to handle mode, but the recovery-email lookup was already nested three levels deep in a function this change had to touch anyway. Split out of #148. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/handle-mode-through-approval.md | 9 + .../__tests__/callback-handle-mode.test.ts | 239 ++++++++++++++++++ .../auth-service/src/routes/choose-handle.ts | 1 + packages/auth-service/src/routes/complete.ts | 74 ++++-- .../__tests__/epds-callback-authorize.test.ts | 33 +++ packages/pds-core/src/index.ts | 32 ++- .../src/lib/epds-callback-authorize.ts | 33 +++ packages/shared/src/__tests__/crypto.test.ts | 50 ++++ packages/shared/src/crypto.ts | 9 +- 9 files changed, 442 insertions(+), 38 deletions(-) create mode 100644 .changeset/handle-mode-through-approval.md create mode 100644 packages/auth-service/src/__tests__/callback-handle-mode.test.ts create mode 100644 packages/pds-core/src/__tests__/epds-callback-authorize.test.ts create mode 100644 packages/pds-core/src/lib/epds-callback-authorize.ts diff --git a/.changeset/handle-mode-through-approval.md b/.changeset/handle-mode-through-approval.md new file mode 100644 index 00000000..2936e07c --- /dev/null +++ b/.changeset/handle-mode-through-approval.md @@ -0,0 +1,9 @@ +--- +'ePDS': patch +--- + +The approval and account-chooser screens now honour the handle mode your app asked for, instead of falling back to the server default. + +**Affects:** Client app developers + +**Client app developers:** no action needed — `epds_handle_mode` is resolved exactly as before (the `/oauth/authorize` query parameter, then your OAuth client metadata, then the server default). Previously the resolved mode was lost on the way to the approval step, so a flow that asked for a chosen handle could still show a generated one there. The mode is now carried through `/oauth/epds-callback` and applied on the approval and chooser screens. Values that are not one of the recognised modes are ignored rather than forwarded. diff --git a/packages/auth-service/src/__tests__/callback-handle-mode.test.ts b/packages/auth-service/src/__tests__/callback-handle-mode.test.ts new file mode 100644 index 00000000..6d63e114 --- /dev/null +++ b/packages/auth-service/src/__tests__/callback-handle-mode.test.ts @@ -0,0 +1,239 @@ +import { randomBytes } from 'node:crypto' +import type { AddressInfo } from 'node:net' +import express from 'express' +import cookieParser from 'cookie-parser' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + verifyCallback, + type CallbackParams, + type HandleMode, +} from '@certified-app/shared' +import type { AuthServiceContext } from '../context.js' +import { createChooseHandleRouter } from '../routes/choose-handle.js' +import { createCompleteRouter } from '../routes/complete.js' + +const mocks = vi.hoisted(() => ({ + getDidByEmail: vi.fn(), + pingParRequest: vi.fn(), + resolveRecoveryEmail: vi.fn(), + resolveClientBranding: vi.fn(), +})) + +vi.mock('../lib/get-did-by-email.js', () => ({ + getDidByEmail: mocks.getDidByEmail, +})) + +vi.mock('../lib/ping-par-request.js', () => ({ + pingParRequest: mocks.pingParRequest, +})) + +vi.mock('../lib/resolve-recovery-email.js', () => ({ + resolveRecoveryEmail: mocks.resolveRecoveryEmail, +})) + +vi.mock('../lib/client-metadata.js', () => ({ + resolveClientBranding: mocks.resolveClientBranding, +})) + +const AUTH_FLOW_COOKIE = 'epds_auth_flow' +const realFetch = globalThis.fetch.bind(globalThis) + +function makeCtx(handleMode: HandleMode | null): AuthServiceContext { + return { + config: { + pdsPublicUrl: 'https://pds.example', + pdsHostname: 'pds.example', + epdsCallbackSecret: 'test-callback-secret', + trustedClients: [], + }, + db: { + getAuthFlow: vi.fn(() => ({ + flowId: 'flow-1', + requestUri: 'urn:ietf:params:oauth:request_uri:req-123', + clientId: 'https://app.example/client.json', + handleMode, + })), + deleteAuthFlow: vi.fn(), + }, + } as unknown as AuthServiceContext +} + +function makeAuth() { + return { + api: { + getSession: vi.fn(() => + Promise.resolve({ user: { email: 'Alice@example.com' } }), + ), + }, + } +} + +async function startApp( + ctx: AuthServiceContext, + auth: ReturnType, +): Promise<{ baseUrl: string; close: () => Promise }> { + const app = express() + app.disable('x-powered-by') + app.use(cookieParser()) + app.use(express.urlencoded({ extended: false })) + app.use(createCompleteRouter(ctx, auth)) + app.use(createChooseHandleRouter(ctx, auth)) + + const server = app.listen(0) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.once('listening', () => { + resolve() + }) + }) + server.unref() + const port = (server.address() as AddressInfo).port + return { + baseUrl: `http://127.0.0.1:${port}`, + close: () => + new Promise((resolve) => { + server.close(() => { + resolve() + }) + }), + } +} + +function parseRedirect(res: globalThis.Response): URL { + const location = res.headers.get('location') + if (!location) throw new Error('Missing redirect location') + return new URL(location) +} + +function normalizeFetchUrl(input: Parameters[0]): URL { + if (input instanceof URL) return input + if (typeof input === 'string') return new URL(input) + return new URL(input.url) +} + +function verifySignedCallbackUrl(url: URL): boolean { + const callbackParams: CallbackParams = { + request_uri: url.searchParams.get('request_uri') ?? '', + email: url.searchParams.get('email') ?? '', + approved: url.searchParams.get('approved') ?? '', + new_account: url.searchParams.get('new_account') ?? '', + ...(url.searchParams.has('handle') + ? { handle: url.searchParams.get('handle') ?? '' } + : {}), + // /auth/complete signs client_id too (so a dead-PAR clean exit can still + // reach the right client); omitting it here would fail verification. + ...(url.searchParams.has('client_id') + ? { client_id: url.searchParams.get('client_id') ?? '' } + : {}), + ...(url.searchParams.has('epds_handle_mode') + ? { epds_handle_mode: url.searchParams.get('epds_handle_mode') ?? '' } + : {}), + } + + return verifyCallback( + callbackParams, + url.searchParams.get('ts') ?? '', + url.searchParams.get('sig') ?? '', + 'test-callback-secret', + ) +} + +async function fetchCompleteRedirect(handleMode: HandleMode | null) { + const app = await startApp(makeCtx(handleMode), makeAuth()) + try { + const res = await fetch(`${app.baseUrl}/auth/complete`, { + redirect: 'manual', + headers: { cookie: `${AUTH_FLOW_COOKIE}=flow-1` }, + }) + + expect(res.status).toBe(303) + const url = parseRedirect(res) + expect(url.pathname).toBe('/oauth/epds-callback') + return url + } finally { + await app.close() + } +} + +describe('auth-service epds-callback handle mode threading', () => { + let priorEnv: { pdsInternalUrl?: string; internalSecret?: string } + + beforeEach(() => { + priorEnv = { + pdsInternalUrl: process.env.PDS_INTERNAL_URL, + internalSecret: process.env.EPDS_INTERNAL_SECRET, + } + process.env.PDS_INTERNAL_URL = 'http://pds.internal' // NOSONAR test-only internal mocked URL + process.env.EPDS_INTERNAL_SECRET = 'test-internal-secret' + mocks.getDidByEmail.mockReset() + mocks.pingParRequest.mockReset() + mocks.resolveRecoveryEmail.mockReset() + mocks.resolveClientBranding.mockReset() + mocks.pingParRequest.mockResolvedValue({ ok: true }) + mocks.resolveRecoveryEmail.mockResolvedValue(null) + mocks.resolveClientBranding.mockResolvedValue({ + customCss: null, + customFaviconUrl: null, + customFaviconUrlDark: null, + }) + vi.stubGlobal( + 'fetch', + vi.fn((input: Parameters[0], init?: RequestInit) => { + const url = normalizeFetchUrl(input) + if (url.hostname === '127.0.0.1') return realFetch(input, init) + return Promise.resolve({ + ok: true, + json: () => Promise.resolve({ exists: false }), + }) + }), + ) + }) + + afterEach(() => { + if (priorEnv.pdsInternalUrl === undefined) + delete process.env.PDS_INTERNAL_URL + else process.env.PDS_INTERNAL_URL = priorEnv.pdsInternalUrl + if (priorEnv.internalSecret === undefined) + delete process.env.EPDS_INTERNAL_SECRET + else process.env.EPDS_INTERNAL_SECRET = priorEnv.internalSecret + vi.unstubAllGlobals() + }) + + it('includes the stored canonical handle mode for random-mode new users', async () => { + mocks.getDidByEmail.mockResolvedValue(null) + const url = await fetchCompleteRedirect('random') + expect(url.searchParams.get('epds_handle_mode')).toBe('random') + expect(url.searchParams.has('handle')).toBe(false) + expect(verifySignedCallbackUrl(url)).toBe(true) + }) + + it('includes the stored canonical handle mode for existing users', async () => { + mocks.getDidByEmail.mockResolvedValue(randomBytes(16).toString('hex')) + const url = await fetchCompleteRedirect('picker-with-random') + expect(url.searchParams.get('epds_handle_mode')).toBe('picker-with-random') + }) + + it('includes the stored canonical handle mode for chosen-handle callbacks', async () => { + mocks.getDidByEmail.mockResolvedValue(null) + const app = await startApp(makeCtx('picker'), makeAuth()) + try { + const res = await fetch(`${app.baseUrl}/auth/choose-handle`, { + method: 'POST', + redirect: 'manual', + headers: { + cookie: `${AUTH_FLOW_COOKIE}=flow-1`, + 'content-type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ handle: 'Alice1' }), + }) + + expect(res.status).toBe(303) + const url = parseRedirect(res) + expect(url.pathname).toBe('/oauth/epds-callback') + expect(url.searchParams.get('epds_handle_mode')).toBe('picker') + expect(url.searchParams.get('handle')).toBe('alice1') + } finally { + await app.close() + } + }) +}) diff --git a/packages/auth-service/src/routes/choose-handle.ts b/packages/auth-service/src/routes/choose-handle.ts index 73e502ca..a986ca87 100644 --- a/packages/auth-service/src/routes/choose-handle.ts +++ b/packages/auth-service/src/routes/choose-handle.ts @@ -404,6 +404,7 @@ export function createChooseHandleRouter( approved: '1', new_account: '1', handle: normalizedLocal, + epds_handle_mode: flow.handleMode ?? '', } if (flow.clientId) callbackParams.client_id = flow.clientId const { sig, ts } = signCallback( diff --git a/packages/auth-service/src/routes/complete.ts b/packages/auth-service/src/routes/complete.ts index 3c9d9cab..da5c1c44 100644 --- a/packages/auth-service/src/routes/complete.ts +++ b/packages/auth-service/src/routes/complete.ts @@ -57,6 +57,10 @@ const AUTH_FLOW_COOKIE = 'epds_auth_flow' * the same `params.handle ?? ''` shape; the sentinel is pinned by * tests in packages/shared/src/__tests__/crypto.test.ts. * + * `epds_handle_mode` is signed too rather than appended afterwards, so + * the browser cannot flip the chooser/consent presentation mode on the + * hop to pds-core without invalidating the signature. + * * Exported so it can be unit-tested without standing up the full * /auth/complete route. */ @@ -65,6 +69,7 @@ export function buildEpdsCallbackUrl(args: { flowClientId: string | null email: string isNewAccount: boolean + flowHandleMode?: string | null pdsPublicUrl: string epdsCallbackSecret: string }): string { @@ -75,11 +80,41 @@ export function buildEpdsCallbackUrl(args: { new_account: args.isNewAccount ? '1' : '0', } if (args.flowClientId) callbackParams.client_id = args.flowClientId + if (args.flowHandleMode) callbackParams.epds_handle_mode = args.flowHandleMode const { sig, ts } = signCallback(callbackParams, args.epdsCallbackSecret) const params = new URLSearchParams({ ...callbackParams, ts, sig }) return `${args.pdsPublicUrl}/oauth/epds-callback?${params.toString()}` } +async function resolveCompleteIdentity( + email: string, + flowId: string, + ctx: AuthServiceContext, + pdsUrl: string, + internalSecret: string, +): Promise<{ email: string; did: string | null }> { + const did = await getDidByEmail(email, pdsUrl, internalSecret) + if (did) return { email, did } + + // Recovery path: session email is a backup email, not a primary. Resolve + // the backup-email -> DID mapping (auth-service-owned) and then DID -> + // primary email via pds-core's internal API, so the downstream callback + // signs the user's real account email, not the recovery address. + const recovered = await resolveRecoveryEmail( + email, + ctx, + pdsUrl, + internalSecret, + ) + if (!recovered) return { email, did: null } + + logger.info( + { flowId, did: recovered.did }, + 'Recovery: translated backup email to primary email via DID', + ) + return { email: recovered.email, did: recovered.did } +} + export function createCompleteRouter( ctx: AuthServiceContext, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- better-auth instance has no exported type @@ -127,7 +162,11 @@ export function createCompleteRouter( */ async function redirectNewUserRandomMode( res: Response, - flow: { requestUri: string; clientId: string | null }, + flow: { + requestUri: string + clientId: string | null + handleMode: string | null + }, email: string, flowId: string, ): Promise { @@ -143,6 +182,7 @@ export function createCompleteRouter( flowClientId: flow.clientId, email, isNewAccount: true, + flowHandleMode: flow.handleMode, pdsPublicUrl: ctx.config.pdsPublicUrl, epdsCallbackSecret: ctx.config.epdsCallbackSecret, }) @@ -204,31 +244,16 @@ export function createCompleteRouter( return } - let email = session.user.email.toLowerCase() + const sessionEmail = session.user.email.toLowerCase() // Step 4: Check whether this is a new user (no PDS account for email). - let did = await getDidByEmail(email, pdsUrl, internalSecret) - - // Recovery path: session email is a backup email, not a primary. Resolve - // the backup-email → DID mapping (auth-service-owned) and then DID → - // primary email via pds-core's internal API, so the downstream callback - // signs the user's real account email, not the recovery address. - if (!did) { - const recovered = await resolveRecoveryEmail( - email, - ctx, - pdsUrl, - internalSecret, - ) - if (recovered) { - logger.info( - { flowId, did: recovered.did }, - 'Recovery: translated backup email to primary email via DID', - ) - email = recovered.email - did = recovered.did - } - } + const { email, did } = await resolveCompleteIdentity( + sessionEmail, + flowId, + ctx, + pdsUrl, + internalSecret, + ) const isNewAccount = !did @@ -264,6 +289,7 @@ export function createCompleteRouter( flowClientId: flow.clientId, email, isNewAccount: false, + flowHandleMode: flow.handleMode, pdsPublicUrl: ctx.config.pdsPublicUrl, epdsCallbackSecret: ctx.config.epdsCallbackSecret, }) diff --git a/packages/pds-core/src/__tests__/epds-callback-authorize.test.ts b/packages/pds-core/src/__tests__/epds-callback-authorize.test.ts new file mode 100644 index 00000000..dad5103b --- /dev/null +++ b/packages/pds-core/src/__tests__/epds-callback-authorize.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from 'vitest' +import { buildPostCallbackAuthorizeUrl } from '../lib/epds-callback-authorize.js' + +describe('buildPostCallbackAuthorizeUrl', () => { + it('preserves a valid display-only epds_handle_mode on the final authorize redirect', () => { + const url = buildPostCallbackAuthorizeUrl({ + pdsUrl: 'https://pds.example', + requestUri: 'urn:ietf:params:oauth:request_uri:req-123', + clientId: 'https://app.example/client.json', + handleMode: 'random', + }) + + expect(url.pathname).toBe('/oauth/authorize') + expect(url.searchParams.get('request_uri')).toBe( + 'urn:ietf:params:oauth:request_uri:req-123', + ) + expect(url.searchParams.get('client_id')).toBe( + 'https://app.example/client.json', + ) + expect(url.searchParams.get('epds_handle_mode')).toBe('random') + }) + + it('drops invalid epds_handle_mode values from the final authorize redirect', () => { + const url = buildPostCallbackAuthorizeUrl({ + pdsUrl: 'https://pds.example', + requestUri: 'urn:ietf:params:oauth:request_uri:req-123', + clientId: 'https://app.example/client.json', + handleMode: 'garbage', + }) + + expect(url.searchParams.has('epds_handle_mode')).toBe(false) + }) +}) diff --git a/packages/pds-core/src/index.ts b/packages/pds-core/src/index.ts index 48ea356a..ec9281cf 100644 --- a/packages/pds-core/src/index.ts +++ b/packages/pds-core/src/index.ts @@ -67,6 +67,7 @@ import { createAuthUiGuard, parsePromptTokens } from './auth-ui-guard.js' import { loadDeviceAccountEmails } from './lib/device-accounts.js' import { handleCallbackError } from './lib/epds-callback-error.js' import { installTestHooks } from './lib/test-hooks.js' +import { buildPostCallbackAuthorizeUrl } from './lib/epds-callback-authorize.js' const logger = createLogger('pds-core') @@ -205,6 +206,7 @@ async function main() { const newAccountStr = req.query.new_account as string const handleParam = req.query.handle as string | undefined const clientIdParam = req.query.client_id as string | undefined + const handleModeParam = req.query.epds_handle_mode as string | undefined const signatureValid = verifyCallback( { request_uri: requestUri, @@ -213,6 +215,7 @@ async function main() { new_account: newAccountStr, handle: handleParam, client_id: clientIdParam, + epds_handle_mode: handleModeParam, }, ts, sig, @@ -592,9 +595,12 @@ async function main() { // - Checks checkConsentRequired() against actual OAuth scopes // - Auto-approves if no consent needed (SSO match, previously authorized scopes) // - Renders the upstream consent UI (consent-view.tsx) if consent is required - const authorizeUrl = new URL('/oauth/authorize', pdsUrl) - authorizeUrl.searchParams.set('request_uri', requestUri) - authorizeUrl.searchParams.set('client_id', clientId) + const authorizeUrl = buildPostCallbackAuthorizeUrl({ + pdsUrl, + requestUri, + clientId, + handleMode: req.query.epds_handle_mode, + }) res.setHeader('Cache-Control', 'no-store') res.redirect(303, authorizeUrl.toString()) @@ -775,19 +781,21 @@ async function main() { .map((s) => s.trim()) .filter(Boolean) + const resolveClientIdFromRequestUri = provider + ? async (requestUri: string) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- @atproto/oauth-provider requestManager not exported + const requestData = await (provider.requestManager as any).get( + requestUri, + ) + return requestData?.clientId as string | undefined + } + : undefined + installCssInjectionMiddleware(pds.app, stack, { trustedClients, resolveClientMetadata, getClientCss, - resolveClientIdFromRequestUri: provider - ? async (requestUri: string) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- @atproto/oauth-provider requestManager not exported - const requestData = await (provider.requestManager as any).get( - requestUri, - ) - return requestData?.clientId as string | undefined - } - : undefined, + resolveClientIdFromRequestUri, logger, }) diff --git a/packages/pds-core/src/lib/epds-callback-authorize.ts b/packages/pds-core/src/lib/epds-callback-authorize.ts new file mode 100644 index 00000000..809f6324 --- /dev/null +++ b/packages/pds-core/src/lib/epds-callback-authorize.ts @@ -0,0 +1,33 @@ +import { VALID_HANDLE_MODES, type HandleMode } from '@certified-app/shared' + +export function resolveCallbackHandleMode( + value: unknown, +): HandleMode | undefined { + return typeof value === 'string' && + (VALID_HANDLE_MODES as readonly string[]).includes(value) + ? (value as HandleMode) + : undefined +} + +/** + * Builds the post-/oauth/epds-callback redirect back to stock /oauth/authorize. + * This differs from auth-service callback builders, which create signed + * auth-service -> pds-core /oauth/epds-callback URLs. It is extracted from the + * old inline construction so epds_handle_mode forwarding and sanitization can be + * unit-tested. + */ +export function buildPostCallbackAuthorizeUrl(opts: { + pdsUrl: string + requestUri: string + clientId: string + handleMode: unknown +}): URL { + const authorizeUrl = new URL('/oauth/authorize', opts.pdsUrl) + authorizeUrl.searchParams.set('request_uri', opts.requestUri) + authorizeUrl.searchParams.set('client_id', opts.clientId) + + const handleMode = resolveCallbackHandleMode(opts.handleMode) + if (handleMode) authorizeUrl.searchParams.set('epds_handle_mode', handleMode) + + return authorizeUrl +} diff --git a/packages/shared/src/__tests__/crypto.test.ts b/packages/shared/src/__tests__/crypto.test.ts index f56a8b65..cd2a57c1 100644 --- a/packages/shared/src/__tests__/crypto.test.ts +++ b/packages/shared/src/__tests__/crypto.test.ts @@ -131,6 +131,36 @@ describe('generateRandomHandle', () => { }) }) +function makeCallbackParams(overrides: Partial = {}) { + return { + request_uri: 'urn:ietf:params:oauth:request_uri:test', + email: 'alice@example.com', + approved: '1', + new_account: '1', + ...overrides, + } satisfies CallbackParams +} + +function expectSignedCallbackToVerify(callbackParams: CallbackParams) { + const secret = 'test-secret' + const { sig, ts } = signCallback(callbackParams, secret) + expect(verifyCallback(callbackParams, ts, sig, secret)).toBe(true) +} + +function expectTamperedHandleModeToFail(callbackParams: CallbackParams) { + const secret = 'test-secret' + const { sig, ts } = signCallback(callbackParams, secret) + expect(verifyCallback(callbackParams, ts, sig, secret)).toBe(true) + expect( + verifyCallback( + { ...callbackParams, epds_handle_mode: 'random' }, + ts, + sig, + secret, + ), + ).toBe(false) +} + describe('signCallback / verifyCallback', () => { const secret = 'test-secret-32bytes-padding-here' const params: CallbackParams = { @@ -182,6 +212,7 @@ describe('signCallback / verifyCallback', () => { params.new_account, '', // handle sentinel (absent) '', // client_id sentinel (absent) + '', // epds_handle_mode sentinel (absent) staleTs, ].join('\n') const { createHmac } = await import('node:crypto') @@ -189,6 +220,24 @@ describe('signCallback / verifyCallback', () => { expect(verifyCallback(params, staleTs, staleSig, secret)).toBe(false) }) + it.each([ + { name: 'with handle', handle: 'alice' }, + { name: 'without handle', handle: undefined }, + ])( + 'signs and verifies callback with epds_handle_mode $name', + ({ handle }) => { + expectSignedCallbackToVerify( + makeCallbackParams({ handle, epds_handle_mode: 'picker' }), + ) + }, + ) + + it('rejects tampered epds_handle_mode', () => { + expectTamperedHandleModeToFail( + makeCallbackParams({ epds_handle_mode: 'picker' }), + ) + }) + it('rejects future timestamp', async () => { const futureTs = (Math.floor(Date.now() / 1000) + 60).toString() const payload = [ @@ -198,6 +247,7 @@ describe('signCallback / verifyCallback', () => { params.new_account, '', // handle sentinel (absent) '', // client_id sentinel (absent) + '', // epds_handle_mode sentinel (absent) futureTs, ].join('\n') const { createHmac } = await import('node:crypto') diff --git a/packages/shared/src/crypto.ts b/packages/shared/src/crypto.ts index d1a40092..722eab9a 100644 --- a/packages/shared/src/crypto.ts +++ b/packages/shared/src/crypto.ts @@ -74,15 +74,18 @@ export interface CallbackParams { new_account: string handle?: string // only set for new account creation with chosen handle client_id?: string // OAuth client this flow belongs to; carried only so a clean-exit redirect from the catch block on /oauth/epds-callback can recover the client's redirect_uri when the upstream PAR row is gone. Signed so an attacker cannot redirect a victim's flow at a different OAuth client. + epds_handle_mode?: string } /** * Sign the epds-callback redirect parameters with HMAC-SHA256. * Returns the hex signature and the Unix timestamp (seconds) used. * - * Payload: request_uri, email, approved, new_account, handle (empty when absent), client_id (empty when absent), and ts, joined by newlines. + * Payload: request_uri, email, approved, new_account, handle (empty when absent), + * client_id (empty when absent), epds_handle_mode (empty when absent), and ts, + * joined by newlines. * A timestamp is included so signatures expire (see verifyCallback). - * handle and client_id use empty string as sentinel when absent so existing flows still produce valid signatures and so the payload shape stays stable across releases. + * handle, client_id and epds_handle_mode use empty string as sentinel when absent so existing flows still produce valid signatures and so the payload shape stays stable across releases. */ export function signCallback( params: CallbackParams, @@ -96,6 +99,7 @@ export function signCallback( params.new_account, params.handle ?? '', // empty string when absent params.client_id ?? '', // empty string when absent + params.epds_handle_mode ?? '', // empty string when absent ts, ].join('\n') const sig = crypto.createHmac('sha256', secret).update(payload).digest('hex') @@ -129,6 +133,7 @@ export function verifyCallback( params.new_account, params.handle ?? '', // empty string when absent — matches signCallback sentinel params.client_id ?? '', // empty string when absent — matches signCallback sentinel + params.epds_handle_mode ?? '', // empty string when absent — matches signCallback sentinel ts, ].join('\n') const expected = crypto From 39248551ccaa1097cdfd569bbe4d6617ad0fed7f Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 4 Aug 2026 13:02:31 +0100 Subject: [PATCH 63/84] fix(pds-core): record emailConfirmedAt after OTP-verified sign-up Without this change, every ePDS account reports its email address as unverified. Accounts are created by /oauth/epds-callback, which only runs once auth-service has verified a one-time code sent to that address, but upstream populates emailConfirmedAt solely from confirmEmail()'s token flow -- a token the OTP flow never issues. The column therefore stays null, so the email_verified claim is always false, and upstream's requestEmailUpdate skips its confirmation-token gate entirely because that gate only engages when emailConfirmedAt is set. Stamp emailConfirmedAt on newly created accounts, at the point where both creation branches converge and the account is known to be new. The write is best-effort: the user has already proven ownership of the address, so a failed write is logged at warn rather than failing their sign-in. Write through the public, typed AccountManager.db rather than upstream's setEmailConfirmedAt helper, which is not re-exported from the package root and has no exports map -- reaching it means a deep import into dist/ that nothing guarantees across upgrades. The emitted statement is identical to upstream's. Accounts predating this are handled by an opt-in backfill script rather than a startup fixup. A null emailConfirmedAt cannot be proven to mean "OTP-verified but unrecorded": ePDS does not block upstream's com.atproto.server.createAccount XRPC route, so a deployment that provisioned accounts by other means must not have those addresses silently promoted to verified. Only the operator knows which case applies, so they choose when to run it, with --dry-run to preview. Verified against a real account.sqlite built by upstream's migrator: dry run writes nothing, the real run stamps only unconfirmed accounts with a genuine address, already-confirmed timestamps are preserved, and re-running is a no-op. Refs HYPER-219. --- .changeset/record-email-as-confirmed.md | 16 ++ packages/pds-core/package.json | 3 +- .../src/__tests__/email-confirmed.test.ts | 226 ++++++++++++++++++ .../pds-core/src/backfill-email-confirmed.ts | 56 +++++ packages/pds-core/src/index.ts | 17 ++ packages/pds-core/src/lib/email-confirmed.ts | 181 ++++++++++++++ 6 files changed, 498 insertions(+), 1 deletion(-) create mode 100644 .changeset/record-email-as-confirmed.md create mode 100644 packages/pds-core/src/__tests__/email-confirmed.test.ts create mode 100644 packages/pds-core/src/backfill-email-confirmed.ts create mode 100644 packages/pds-core/src/lib/email-confirmed.ts diff --git a/.changeset/record-email-as-confirmed.md b/.changeset/record-email-as-confirmed.md new file mode 100644 index 00000000..ed630326 --- /dev/null +++ b/.changeset/record-email-as-confirmed.md @@ -0,0 +1,16 @@ +--- +'ePDS': patch +--- + +Apps you sign in to are now told that your email address is confirmed. + +**Affects:** Client app developers, Operators + +**Client app developers:** the `email_verified` claim is now `true` for accounts created through the sign-in flow, instead of always `false`. If your app gated any behaviour on `email_verified` being true, that gate will now open for new accounts — and for existing accounts once the operator runs the backfill below. + +**Operators:** accounts created from now on are stamped automatically; no action required for them. Accounts created before this release keep `email_verified: false` until you backfill them: + +- `pnpm --filter @certified-app/pds-core backfill:email-confirmed --dry-run` reports how many accounts would change. +- Dropping `--dry-run` performs the update. It is idempotent, so re-running is safe. +- Run it with the same environment as the server, so it targets that deployment's account database. +- It is deliberately not automatic: it marks every account that has an email address but no confirmation timestamp, so if you provisioned any accounts outside the normal sign-in flow, their addresses would be marked confirmed without having been verified. Check the dry-run count before committing. diff --git a/packages/pds-core/package.json b/packages/pds-core/package.json index 4fd06ad0..958c5e59 100644 --- a/packages/pds-core/package.json +++ b/packages/pds-core/package.json @@ -10,7 +10,8 @@ "scripts": { "build": "tsc --build", "dev": "tsx watch src/index.ts", - "start": "node dist/index.js" + "start": "node dist/index.js", + "backfill:email-confirmed": "tsx src/backfill-email-confirmed.ts" }, "dependencies": { "@atproto/crypto": "^0.4.5", diff --git a/packages/pds-core/src/__tests__/email-confirmed.test.ts b/packages/pds-core/src/__tests__/email-confirmed.test.ts new file mode 100644 index 00000000..5668bde4 --- /dev/null +++ b/packages/pds-core/src/__tests__/email-confirmed.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + backfillEmailConfirmedAt, + markEmailConfirmed, + setEmailConfirmedAt, +} from '../lib/email-confirmed.js' + +/** Records the single-account UPDATE that setEmailConfirmedAt builds: + * which table, which values, which where-clause, and whether it was + * handed to executeWithRetry. */ +function makeFakeDb(opts: { failOnExecute?: boolean } = {}) { + const calls: { + table?: string + values?: { emailConfirmedAt: string } + where?: [string, string, string] + executed: number + } = { executed: 0 } + + const db = { + executeWithRetry: (query: { execute: () => Promise }) => { + calls.executed++ + // Must receive the fully built statement — guards against a + // chain that hands executeWithRetry something unexecutable. + expect(query.execute).toBeTypeOf('function') + if (opts.failOnExecute) return Promise.reject(new Error('db down')) + return Promise.resolve(undefined) + }, + db: { + updateTable: (table: string) => { + calls.table = table + return { + set: (values: { emailConfirmedAt: string }) => { + calls.values = values + return { + where: (column: string, op: string, value: string) => { + calls.where = [column, op, value] + return { execute: () => Promise.resolve(undefined) } + }, + } + }, + } + }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- structural fake standing in for AccountDb + } as any + + return { db, calls } +} + +const DID = 'did:plc:7iza6de2dwap2sbkpav7c6c6' + +describe('setEmailConfirmedAt', () => { + it('updates the account row for the given DID via executeWithRetry', async () => { + const { db, calls } = makeFakeDb() + + await setEmailConfirmedAt(db, DID, '2026-08-04T10:00:00.000Z') + + expect(calls.table).toBe('account') + expect(calls.values).toEqual({ + emailConfirmedAt: '2026-08-04T10:00:00.000Z', + }) + expect(calls.where).toEqual(['did', '=', DID]) + expect(calls.executed).toBe(1) + }) + + it('defaults emailConfirmedAt to an ISO timestamp of now', async () => { + const { db, calls } = makeFakeDb() + const before = Date.now() + + await setEmailConfirmedAt(db, DID) + + const written = calls.values?.emailConfirmedAt as string + expect(written).toMatch(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z$/) + const writtenMs = Date.parse(written) + expect(writtenMs).toBeGreaterThanOrEqual(before) + expect(writtenMs).toBeLessThanOrEqual(Date.now()) + }) + + it('propagates db failures to the caller', async () => { + const { db } = makeFakeDb({ failOnExecute: true }) + + await expect(setEmailConfirmedAt(db, DID)).rejects.toThrow('db down') + }) +}) + +describe('markEmailConfirmed', () => { + it('stamps the account and stays silent on success', async () => { + const { db, calls } = makeFakeDb() + const logger = { warn: vi.fn() } + + await markEmailConfirmed({ db, did: DID, logger }) + + expect(calls.where).toEqual(['did', '=', DID]) + expect(logger.warn).not.toHaveBeenCalled() + }) + + it('swallows db failures so sign-in is never blocked, logging at warn', async () => { + const { db } = makeFakeDb({ failOnExecute: true }) + const logger = { warn: vi.fn() } + + // Must resolve, not reject — the user has already proven ownership + // of the address, so bookkeeping failure must not fail their sign-in. + await expect( + markEmailConfirmed({ db, did: DID, logger }), + ).resolves.toBeUndefined() + + expect(logger.warn).toHaveBeenCalledTimes(1) + const [context, message] = logger.warn.mock.calls[0] + expect(context).toMatchObject({ did: DID }) + expect((context as { err: Error }).err.message).toBe('db down') + expect(message).toMatch(/emailConfirmedAt/) + }) +}) + +/** Records the backfill's SELECT/UPDATE filters so the tests can + * assert the criteria, not just the row count. */ +function makeBackfillDb(opts: { candidates: string[] }) { + const selectWheres: [string, string, unknown][] = [] + const updateWheres: [string, string, unknown][] = [] + let updateValues: { emailConfirmedAt: string } | undefined + let updateExecuted = 0 + + const chain = ( + sink: [string, string, unknown][], + onExecute: () => Promise, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- self-referential fluent fake + ): any => ({ + select: () => chain(sink, onExecute), + where: (column: string, op: string, value: unknown) => { + sink.push([column, op, value]) + return chain(sink, onExecute) + }, + execute: onExecute, + }) + + const db = { + db: { + selectFrom: (table: string) => { + expect(table).toBe('account') + return chain(selectWheres, () => + Promise.resolve(opts.candidates.map((did) => ({ did }))), + ) + }, + updateTable: (table: string) => { + expect(table).toBe('account') + return { + set: (values: { emailConfirmedAt: string }) => { + updateValues = values + return chain(updateWheres, () => { + updateExecuted++ + return Promise.resolve(undefined) + }) + }, + } + }, + }, + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- structural fake standing in for AccountDb + } as any + + return { + db, + inspect: () => ({ + selectWheres, + updateWheres, + updateValues, + updateExecuted, + }), + } +} + +describe('backfillEmailConfirmedAt', () => { + it('stamps every unconfirmed account that has an email', async () => { + const { db, inspect } = makeBackfillDb({ + candidates: ['did:plc:aaa', 'did:plc:bbb'], + }) + + const result = await backfillEmailConfirmedAt({ + db, + emailConfirmedAt: '2026-08-04T10:00:00.000Z', + }) + + expect(result).toEqual({ candidates: 2, updated: 2, dryRun: false }) + const { updateValues, updateExecuted } = inspect() + expect(updateValues).toEqual({ + emailConfirmedAt: '2026-08-04T10:00:00.000Z', + }) + expect(updateExecuted).toBe(1) + }) + + it('only targets rows with a null emailConfirmedAt and a real email', async () => { + const { db, inspect } = makeBackfillDb({ candidates: ['did:plc:aaa'] }) + + await backfillEmailConfirmedAt({ db }) + + // Accounts with no address have nothing to confirm, and already + // stamped rows must be excluded so re-running is a no-op. + const expected = [ + ['emailConfirmedAt', 'is', null], + ['email', 'is not', null], + ['email', '!=', ''], + ] + expect(inspect().selectWheres).toEqual(expected) + expect(inspect().updateWheres).toEqual(expected) + }) + + it('reports candidates without writing when dryRun is set', async () => { + const { db, inspect } = makeBackfillDb({ + candidates: ['did:plc:aaa', 'did:plc:bbb', 'did:plc:ccc'], + }) + + const result = await backfillEmailConfirmedAt({ db, dryRun: true }) + + expect(result).toEqual({ candidates: 3, updated: 0, dryRun: true }) + expect(inspect().updateExecuted).toBe(0) + }) + + it('skips the UPDATE entirely when nothing needs backfilling', async () => { + const { db, inspect } = makeBackfillDb({ candidates: [] }) + + const result = await backfillEmailConfirmedAt({ db }) + + expect(result).toEqual({ candidates: 0, updated: 0, dryRun: false }) + expect(inspect().updateExecuted).toBe(0) + }) +}) diff --git a/packages/pds-core/src/backfill-email-confirmed.ts b/packages/pds-core/src/backfill-email-confirmed.ts new file mode 100644 index 00000000..40512a10 --- /dev/null +++ b/packages/pds-core/src/backfill-email-confirmed.ts @@ -0,0 +1,56 @@ +/** + * One-off operator script: stamp `emailConfirmedAt` on ePDS accounts + * created before that field was recorded at sign-up. + * + * Run it against a deployment's account database, with the same + * environment the server runs with: + * + * pnpm --filter @certified-app/pds-core backfill:email-confirmed --dry-run + * pnpm --filter @certified-app/pds-core backfill:email-confirmed + * + * Not wired into startup on purpose — see the rationale on + * `backfillEmailConfirmedAt`. + * + * Builds the PDS via the same `PDS.create()` the server uses (rather + * than opening account.sqlite directly) so the database location, + * migrations and connection settings come from the deployment's own + * config, and so we stay on `@atproto/pds`'s public entry point — + * its account-db helpers are not exported from the package root. + * `create()` wires up the context without binding a port; `start()` + * is never called, so nothing is served. + */ +import { PDS, envToCfg, envToSecrets, readEnv } from '@atproto/pds' +import { backfillEmailConfirmedAt } from './lib/email-confirmed.js' + +async function main(): Promise { + const dryRun = process.argv.includes('--dry-run') + + const env = readEnv() + const cfg = envToCfg(env) + const secrets = envToSecrets(env) + + const pds = await PDS.create(cfg, secrets) + try { + const result = await backfillEmailConfirmedAt({ + db: pds.ctx.accountManager.db, + dryRun, + }) + const where = cfg.db.accountDbLoc + if (result.dryRun) { + process.stdout.write( + `[dry run] ${result.candidates} account(s) in ${where} would be marked email-confirmed.\n`, + ) + } else { + process.stdout.write( + `Marked ${result.updated} account(s) in ${where} as email-confirmed.\n`, + ) + } + } finally { + await pds.destroy() + } +} + +main().catch((err: unknown) => { + process.stderr.write(`Backfill failed: ${String(err)}\n`) + process.exit(1) +}) diff --git a/packages/pds-core/src/index.ts b/packages/pds-core/src/index.ts index 7f4043f6..bbf4c8d6 100644 --- a/packages/pds-core/src/index.ts +++ b/packages/pds-core/src/index.ts @@ -72,6 +72,7 @@ import { createUpstreamFaviconMiddleware } from './upstream-favicon.js' import { createAuthUiGuard, parsePromptTokens } from './auth-ui-guard.js' import { loadDeviceAccountEmails } from './lib/device-accounts.js' import { handleCallbackError } from './lib/epds-callback-error.js' +import { markEmailConfirmed } from './lib/email-confirmed.js' import { installTestHooks } from './lib/test-hooks.js' import { buildPostCallbackAuthorizeUrl } from './lib/epds-callback-authorize.js' @@ -486,6 +487,22 @@ async function main() { } await provider.accountManager.upsertDeviceAccount(deviceId, account.did) + // Step 4b: Record that the email is confirmed. Reaching this + // point means auth-service verified a one-time code sent to + // `email`, but upstream only populates `emailConfirmedAt` from + // confirmEmail()'s token flow, so without this the address stays + // marked unverified forever. Only new accounts need it — + // existing ones were stamped when they were created (or by the + // one-off backfill script for accounts predating this). + // Best-effort: never fails the sign-in. + if (!existingAccount) { + await markEmailConfirmed({ + db: pds.ctx.accountManager.db, + did: account.sub, + logger, + }) + } + // Step 5: Determine whether to skip consent on sign-up. // Consent is skipped only when ALL of these hold: // a) This is a brand-new account (not an existing user) diff --git a/packages/pds-core/src/lib/email-confirmed.ts b/packages/pds-core/src/lib/email-confirmed.ts new file mode 100644 index 00000000..63269370 --- /dev/null +++ b/packages/pds-core/src/lib/email-confirmed.ts @@ -0,0 +1,181 @@ +/** + * Marks ePDS accounts as email-confirmed. + * + * Every ePDS account is created by /oauth/epds-callback, which only + * runs after auth-service has verified a one-time code sent to that + * address. The email is therefore *already* verified by the time the + * account row exists — but the PDS `account` table's + * `emailConfirmedAt` column stays null, because upstream only ever + * populates it from `confirmEmail()`, which demands a + * `confirm_email` token the OTP flow never issues. + * + * Leaving it null has two observable consequences: + * 1. `email_verified` is false in every OIDC/token claim, because + * upstream's oauth-store derives it as `emailConfirmedAt != null`. + * Relying parties see a verified address reported as unverified. + * 2. Upstream's `requestEmailUpdate` only demands a confirmation + * token when `emailConfirmedAt` is set, so a null value skips + * the verification gate on email change entirely. + * + * Lives in its own module so the write can be unit-tested against a + * fake db without booting a real PDS, matching the extraction + * pattern used by the other lib/ modules. + * + * ## Why write via Kysely rather than upstream's helper + * + * `@atproto/pds` does not re-export `setEmailConfirmedAt` from its + * package root, and its package.json declares no `exports` map — so + * the only way to call it directly is a deep import into `dist/`, + * which nothing guarantees across upgrades. `AccountManager.db` is + * public and typed (`readonly db: AccountDb`), so we issue the same + * statement upstream's helper issues, through a supported surface. + * Kept deliberately identical to upstream's implementation + * (`account-manager/helpers/account.ts`) so behaviour matches. + */ +import type { Logger } from 'pino' + +/** + * The slice of `AccountDb` we depend on: `executeWithRetry` (SQLite + * busy-retry wrapper) plus the Kysely instance. Structural typing + * against the real `AccountDb` keeps the fake in tests honest + * without importing PDS internals. + */ +export interface EmailConfirmedDb { + /** SQLite busy-retry wrapper; takes any executable Kysely statement. */ + executeWithRetry: (query: { execute: () => Promise }) => Promise + db: { + updateTable: (table: 'account') => { + set: (values: { emailConfirmedAt: string }) => { + where: ( + column: 'did', + op: '=', + value: string, + ) => { execute: () => Promise } + } + } + } +} + +/** + * Set `emailConfirmedAt` on a single account. + * + * Mirrors upstream `setEmailConfirmedAt(db, did, emailConfirmedAt)`. + * `emailConfirmedAt` defaults to now, which is the honest value for + * the OTP flow: the code was verified moments ago. + */ +export async function setEmailConfirmedAt( + db: EmailConfirmedDb, + did: string, + emailConfirmedAt: string = new Date().toISOString(), +): Promise { + await db.executeWithRetry( + db.db + .updateTable('account') + .set({ emailConfirmedAt }) + .where('did', '=', did), + ) +} + +/** + * The db slice the backfill needs: a Kysely instance able to run the + * bulk UPDATE and count the rows it would touch. + */ +export interface BackfillDb { + db: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- structural slice of Kysely's fluent builder; the real types come from AccountDb at the call site + selectFrom: (table: 'account') => any + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ditto + updateTable: (table: 'account') => any + } +} + +export interface BackfillResult { + /** Rows matching the backfill criteria before the update ran. */ + candidates: number + /** Rows actually updated; 0 when `dryRun` is set. */ + updated: number + /** True when no write was attempted. */ + dryRun: boolean +} + +/** + * One-off backfill for accounts created before this fix landed, whose + * `emailConfirmedAt` is still null. + * + * Deliberately NOT run automatically at startup. Whether a null + * `emailConfirmedAt` means "verified via OTP but never recorded" or + * "genuinely never verified" depends on how a given deployment was + * operated — ePDS does not block upstream's + * `com.atproto.server.createAccount` XRPC route, so an operator who + * provisioned accounts by other means must not have those addresses + * silently promoted to verified. Only the operator knows which case + * applies, so this is exposed as a script they choose to run. + * + * Restricted to rows with a real email address: an account with no + * address has nothing to confirm, and upstream reports + * `email_verified` as undefined rather than false for those. The + * column is `NOT NULL` in the PDS schema, so "no address" shows up + * as the empty string; the null check is kept alongside it as + * cheap insurance against that constraint being relaxed upstream. + * + * Idempotent — rows already stamped are excluded, so re-running is a + * no-op. Use `dryRun` to report the candidate count without writing. + */ +export async function backfillEmailConfirmedAt(opts: { + db: BackfillDb + emailConfirmedAt?: string + dryRun?: boolean +}): Promise { + const dryRun = opts.dryRun ?? false + const emailConfirmedAt = opts.emailConfirmedAt ?? new Date().toISOString() + + const rows = await opts.db.db + .selectFrom('account') + .select('did') + .where('emailConfirmedAt', 'is', null) + .where('email', 'is not', null) + .where('email', '!=', '') + .execute() + const candidates = rows.length + + if (dryRun || candidates === 0) { + return { candidates, updated: 0, dryRun } + } + + await opts.db.db + .updateTable('account') + .set({ emailConfirmedAt }) + .where('emailConfirmedAt', 'is', null) + .where('email', 'is not', null) + .where('email', '!=', '') + .execute() + + return { candidates, updated: candidates, dryRun } +} + +/** + * Best-effort variant used on the sign-in hot path. + * + * The email genuinely *is* verified at this point, so recording that + * fact is correct — but it is bookkeeping, not part of the sign-in + * contract. If the write fails (SQLite busy, disk error) the user + * has still proven ownership of the address, so failing their + * sign-in over it would be a strictly worse outcome than a stale + * `email_verified: false` claim that the next sign-in re-attempts. + * The error is logged at `warn` so it stays visible without tripping + * error-level alerting. + */ +export async function markEmailConfirmed(opts: { + db: EmailConfirmedDb + did: string + logger: Pick +}): Promise { + try { + await setEmailConfirmedAt(opts.db, opts.did) + } catch (err) { + opts.logger.warn( + { err, did: opts.did }, + 'Failed to set emailConfirmedAt after OTP-verified account creation', + ) + } +} From 55e2bfd6119621a20583325e1f0101c81fa1ddda Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 4 Aug 2026 13:08:12 +0100 Subject: [PATCH 64/84] test(pds-core): cover backfill arg parsing and report wording Coveralls flagged a -0.06% drop: backfill-email-confirmed.ts is an entry point, so none of its lines are exercised by the suite. The repo counts entry points rather than excluding them (index.ts sits at 0/372), so adding an exclusion would bend the project's coverage convention to flatter this branch. Move the two testable pieces -- --dry-run parsing and the completion line -- into lib/email-confirmed.ts, leaving the script as env loading, PDS.create and teardown. Both are worth asserting on their own terms: a near-miss flag such as --dry must not be read as --dry-run, since writing when the operator meant to preview is the one unrecoverable mistake here, and a zero-row run must say so rather than print nothing. Line coverage 57.37% -> 57.45%; 1093 -> 1098 tests. --- .../src/__tests__/email-confirmed.test.ts | 50 +++++++++++++++++++ .../pds-core/src/backfill-email-confirmed.ts | 21 ++++---- packages/pds-core/src/lib/email-confirmed.ts | 19 +++++++ 3 files changed, 78 insertions(+), 12 deletions(-) diff --git a/packages/pds-core/src/__tests__/email-confirmed.test.ts b/packages/pds-core/src/__tests__/email-confirmed.test.ts index 5668bde4..e35187c7 100644 --- a/packages/pds-core/src/__tests__/email-confirmed.test.ts +++ b/packages/pds-core/src/__tests__/email-confirmed.test.ts @@ -2,7 +2,9 @@ import { describe, expect, it, vi } from 'vitest' import { backfillEmailConfirmedAt, + formatBackfillReport, markEmailConfirmed, + parseDryRun, setEmailConfirmedAt, } from '../lib/email-confirmed.js' @@ -224,3 +226,51 @@ describe('backfillEmailConfirmedAt', () => { expect(inspect().updateExecuted).toBe(0) }) }) + +describe('parseDryRun', () => { + it('is off unless --dry-run is passed', () => { + expect(parseDryRun(['node', 'backfill.ts'])).toBe(false) + // A near-miss must not be treated as the flag: writing when the + // operator meant to preview is the one unrecoverable mistake here. + expect(parseDryRun(['node', 'backfill.ts', '--dry'])).toBe(false) + expect(parseDryRun(['node', 'backfill.ts', 'dry-run'])).toBe(false) + }) + + it('is on when --dry-run is passed, in any position', () => { + expect(parseDryRun(['node', 'backfill.ts', '--dry-run'])).toBe(true) + expect(parseDryRun(['--dry-run', 'node', 'backfill.ts'])).toBe(true) + }) +}) + +describe('formatBackfillReport', () => { + const LOCATION = '/data/account.sqlite' + + it('reports the candidate count and names the db on a dry run', () => { + const line = formatBackfillReport( + { candidates: 3, updated: 0, dryRun: true }, + LOCATION, + ) + + expect(line).toBe( + `[dry run] 3 account(s) in ${LOCATION} would be marked email-confirmed.`, + ) + }) + + it('reports what was actually written on a real run', () => { + const line = formatBackfillReport( + { candidates: 3, updated: 3, dryRun: false }, + LOCATION, + ) + + expect(line).toBe(`Marked 3 account(s) in ${LOCATION} as email-confirmed.`) + }) + + it('makes a no-op run unambiguous rather than silent', () => { + expect( + formatBackfillReport( + { candidates: 0, updated: 0, dryRun: false }, + LOCATION, + ), + ).toContain('0 account(s)') + }) +}) diff --git a/packages/pds-core/src/backfill-email-confirmed.ts b/packages/pds-core/src/backfill-email-confirmed.ts index 40512a10..16df22b9 100644 --- a/packages/pds-core/src/backfill-email-confirmed.ts +++ b/packages/pds-core/src/backfill-email-confirmed.ts @@ -20,10 +20,14 @@ * is never called, so nothing is served. */ import { PDS, envToCfg, envToSecrets, readEnv } from '@atproto/pds' -import { backfillEmailConfirmedAt } from './lib/email-confirmed.js' +import { + backfillEmailConfirmedAt, + formatBackfillReport, + parseDryRun, +} from './lib/email-confirmed.js' async function main(): Promise { - const dryRun = process.argv.includes('--dry-run') + const dryRun = parseDryRun(process.argv) const env = readEnv() const cfg = envToCfg(env) @@ -35,16 +39,9 @@ async function main(): Promise { db: pds.ctx.accountManager.db, dryRun, }) - const where = cfg.db.accountDbLoc - if (result.dryRun) { - process.stdout.write( - `[dry run] ${result.candidates} account(s) in ${where} would be marked email-confirmed.\n`, - ) - } else { - process.stdout.write( - `Marked ${result.updated} account(s) in ${where} as email-confirmed.\n`, - ) - } + process.stdout.write( + formatBackfillReport(result, cfg.db.accountDbLoc) + '\n', + ) } finally { await pds.destroy() } diff --git a/packages/pds-core/src/lib/email-confirmed.ts b/packages/pds-core/src/lib/email-confirmed.ts index 63269370..d9dbf928 100644 --- a/packages/pds-core/src/lib/email-confirmed.ts +++ b/packages/pds-core/src/lib/email-confirmed.ts @@ -153,6 +153,25 @@ export async function backfillEmailConfirmedAt(opts: { return { candidates, updated: candidates, dryRun } } +/** True when the operator asked to preview rather than write. */ +export function parseDryRun(argv: readonly string[]): boolean { + return argv.includes('--dry-run') +} + +/** + * The line the backfill script prints on completion. Split out from + * the script so the wording is covered by tests — the script itself + * is an entry point and never imported by one. + */ +export function formatBackfillReport( + result: BackfillResult, + location: string, +): string { + return result.dryRun + ? `[dry run] ${result.candidates} account(s) in ${location} would be marked email-confirmed.` + : `Marked ${result.updated} account(s) in ${location} as email-confirmed.` +} + /** * Best-effort variant used on the sign-in hot path. * From d63111ccfa9a739b4b58e298e939de7057d8d55c Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 4 Aug 2026 13:14:52 +0100 Subject: [PATCH 65/84] fix(pds-core): report rows the backfill UPDATE actually touched The reported `updated` count reused the earlier SELECT's row count, which assumes nothing changes between the two statements. A sign-in that stamps one of those rows in between leaves the UPDATE touching fewer rows than were counted, and this number is the operator's only evidence of what the script did. Read Kysely's UpdateResult.numUpdatedRows via executeTakeFirst() instead. The test double now mirrors the real surface -- SELECT via execute(), UPDATE via executeTakeFirst() returning a bigint count -- and rejects an UPDATE sent to execute(), so the mock cannot drift back into accepting an API the production code no longer uses. Verified against a real account.sqlite built by upstream's migrator: numUpdatedRows matches the rows actually written, and dry-run, idempotent-rerun and skip-empty-email behaviour are unchanged. Raised by Copilot in review on PR #234. --- .../src/__tests__/email-confirmed.test.ts | 48 ++++++++++++++++--- packages/pds-core/src/lib/email-confirmed.ts | 23 +++++++-- 2 files changed, 60 insertions(+), 11 deletions(-) diff --git a/packages/pds-core/src/__tests__/email-confirmed.test.ts b/packages/pds-core/src/__tests__/email-confirmed.test.ts index e35187c7..fa0a441f 100644 --- a/packages/pds-core/src/__tests__/email-confirmed.test.ts +++ b/packages/pds-core/src/__tests__/email-confirmed.test.ts @@ -117,23 +117,33 @@ describe('markEmailConfirmed', () => { /** Records the backfill's SELECT/UPDATE filters so the tests can * assert the criteria, not just the row count. */ -function makeBackfillDb(opts: { candidates: string[] }) { +function makeBackfillDb(opts: { + candidates: string[] + /** Rows the UPDATE reports touching; defaults to candidates.length + * so the common case needs no wiring. */ + numUpdatedRows?: number +}) { const selectWheres: [string, string, unknown][] = [] const updateWheres: [string, string, unknown][] = [] let updateValues: { emailConfirmedAt: string } | undefined let updateExecuted = 0 + // Mirrors the real Kysely surface: SELECTs are read with execute(), + // UPDATEs with executeTakeFirst() returning an UpdateResult whose + // numUpdatedRows is a bigint. const chain = ( sink: [string, string, unknown][], onExecute: () => Promise, + onExecuteTakeFirst?: () => Promise, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- self-referential fluent fake ): any => ({ - select: () => chain(sink, onExecute), + select: () => chain(sink, onExecute, onExecuteTakeFirst), where: (column: string, op: string, value: unknown) => { sink.push([column, op, value]) - return chain(sink, onExecute) + return chain(sink, onExecute, onExecuteTakeFirst) }, execute: onExecute, + executeTakeFirst: onExecuteTakeFirst, }) const db = { @@ -149,10 +159,19 @@ function makeBackfillDb(opts: { candidates: string[] }) { return { set: (values: { emailConfirmedAt: string }) => { updateValues = values - return chain(updateWheres, () => { - updateExecuted++ - return Promise.resolve(undefined) - }) + return chain( + updateWheres, + () => + Promise.reject(new Error('UPDATE must use executeTakeFirst')), + () => { + updateExecuted++ + return Promise.resolve({ + numUpdatedRows: BigInt( + opts.numUpdatedRows ?? opts.candidates.length, + ), + }) + }, + ) }, } }, @@ -225,6 +244,21 @@ describe('backfillEmailConfirmedAt', () => { expect(result).toEqual({ candidates: 0, updated: 0, dryRun: false }) expect(inspect().updateExecuted).toBe(0) }) + + it('reports the rows the UPDATE touched, not the rows the SELECT saw', async () => { + // The SELECT and UPDATE are separate statements: a sign-in that + // stamps one of these rows in between leaves the UPDATE touching + // fewer rows than were counted. Report the database's number, so + // the operator is not told more accounts changed than really did. + const { db } = makeBackfillDb({ + candidates: ['did:plc:aaa', 'did:plc:bbb', 'did:plc:ccc'], + numUpdatedRows: 2, + }) + + const result = await backfillEmailConfirmedAt({ db }) + + expect(result).toEqual({ candidates: 3, updated: 2, dryRun: false }) + }) }) describe('parseDryRun', () => { diff --git a/packages/pds-core/src/lib/email-confirmed.ts b/packages/pds-core/src/lib/email-confirmed.ts index d9dbf928..f7466c79 100644 --- a/packages/pds-core/src/lib/email-confirmed.ts +++ b/packages/pds-core/src/lib/email-confirmed.ts @@ -92,7 +92,11 @@ export interface BackfillDb { export interface BackfillResult { /** Rows matching the backfill criteria before the update ran. */ candidates: number - /** Rows actually updated; 0 when `dryRun` is set. */ + /** + * Rows the UPDATE actually touched, as reported by the database; + * 0 when `dryRun` is set. May be lower than `candidates` if + * something stamped a row between the two statements. + */ updated: number /** True when no write was attempted. */ dryRun: boolean @@ -142,15 +146,26 @@ export async function backfillEmailConfirmedAt(opts: { return { candidates, updated: 0, dryRun } } - await opts.db.db + // Report what the UPDATE actually touched rather than reusing the + // SELECT's count. The two are separate statements, so a sign-in + // that stamps one of these rows in between would make `candidates` + // an overstatement — and this number is the operator's only + // evidence of what the script did. + const result = await opts.db.db .updateTable('account') .set({ emailConfirmedAt }) .where('emailConfirmedAt', 'is', null) .where('email', 'is not', null) .where('email', '!=', '') - .execute() + .executeTakeFirst() - return { candidates, updated: candidates, dryRun } + return { + candidates, + // numUpdatedRows is a bigint; row counts here are far below + // Number.MAX_SAFE_INTEGER, so the narrowing is safe. + updated: Number(result?.numUpdatedRows ?? 0), + dryRun, + } } /** True when the operator asked to preview rather than write. */ From f3a1b0e2ddfe1b6a47777e3548aa0d19e2d1502b Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 4 Aug 2026 13:57:53 +0100 Subject: [PATCH 66/84] refactor(pds-core): confirm email via accountManager, not table writes AGENTS.md is explicit: "Do not directly read or modify @atproto/pds database tables -- use pds.ctx.accountManager.* methods." The Kysely UPDATE this PR introduced violated that, on my mistaken claim that no public API could do the job. There is one. createEmailToken() and confirmEmail() are both public AccountManager methods, and together they are the supported route to a confirmed address: confirmEmail validates the token, deletes it and sets emailConfirmedAt in a single transaction. createEmailToken only inserts the row and returns the token -- upstream's XRPC handlers do the mailing separately -- so nothing is sent to the user, which matters when they just proved ownership via the OTP. Also fix the retry gap this exposed. Confirmation was keyed on the account being new, so a failed write was never retried and the account stayed unconfirmed until an operator ran the backfill. It is now keyed on the account being *unconfirmed*, so a returning user whose earlier attempt failed is repaired on their next sign-in, while already-confirmed accounts still cost no writes. The backfill now confirms one account at a time through the same API rather than a single set-based UPDATE: more round-trips, but it keeps to the account-manager boundary and lets one bad account be reported without abandoning the run. Failures are listed by DID and the script exits non-zero. Enumerating accounts remains a direct read -- the manager exposes getAccounts(dids) but no "every account" query -- and is confined to this operator-invoked script. Dropping the Kysely fakes removes the `as any` casts from the tests; they now type against the local interface, so a signature change breaks them instead of passing silently. Verified against a real account.sqlite built by upstream's migrator: unconfirmed accounts are confirmed, already-confirmed ones untouched, empty-email skipped, re-runs find nothing, and zero email_token rows are left behind. Raised by CodeRabbit in review on PR #234. --- .changeset/record-email-as-confirmed.md | 5 +- .../src/__tests__/email-confirmed.test.ts | 379 +++++++++--------- .../pds-core/src/backfill-email-confirmed.ts | 35 +- packages/pds-core/src/index.ts | 21 +- packages/pds-core/src/lib/email-confirmed.ts | 254 ++++++------ 5 files changed, 348 insertions(+), 346 deletions(-) diff --git a/.changeset/record-email-as-confirmed.md b/.changeset/record-email-as-confirmed.md index ed630326..c28d12af 100644 --- a/.changeset/record-email-as-confirmed.md +++ b/.changeset/record-email-as-confirmed.md @@ -6,11 +6,12 @@ Apps you sign in to are now told that your email address is confirmed. **Affects:** Client app developers, Operators -**Client app developers:** the `email_verified` claim is now `true` for accounts created through the sign-in flow, instead of always `false`. If your app gated any behaviour on `email_verified` being true, that gate will now open for new accounts — and for existing accounts once the operator runs the backfill below. +**Client app developers:** the `email_verified` claim is now `true` for accounts that signed in through the emailed-code flow, instead of always `false`. If your app gated any behaviour on `email_verified` being true, that gate will now open for those accounts. -**Operators:** accounts created from now on are stamped automatically; no action required for them. Accounts created before this release keep `email_verified: false` until you backfill them: +**Operators:** new accounts are recorded as confirmed at sign-up, and existing accounts are repaired the next time their owner signs in — so most accounts need no action. Use the backfill for accounts whose owners have not signed in since upgrading: - `pnpm --filter @certified-app/pds-core backfill:email-confirmed --dry-run` reports how many accounts would change. - Dropping `--dry-run` performs the update. It is idempotent, so re-running is safe. - Run it with the same environment as the server, so it targets that deployment's account database. +- Accounts that cannot be confirmed are listed by DID at the end and the command exits non-zero, so a scripted run does not report success after partial failure. - It is deliberately not automatic: it marks every account that has an email address but no confirmation timestamp, so if you provisioned any accounts outside the normal sign-in flow, their addresses would be marked confirmed without having been verified. Check the dry-run count before committing. diff --git a/packages/pds-core/src/__tests__/email-confirmed.test.ts b/packages/pds-core/src/__tests__/email-confirmed.test.ts index fa0a441f..dd598ee8 100644 --- a/packages/pds-core/src/__tests__/email-confirmed.test.ts +++ b/packages/pds-core/src/__tests__/email-confirmed.test.ts @@ -2,262 +2,226 @@ import { describe, expect, it, vi } from 'vitest' import { backfillEmailConfirmedAt, + confirmAccountEmail, formatBackfillReport, markEmailConfirmed, + needsEmailConfirmation, parseDryRun, - setEmailConfirmedAt, + type EmailConfirmingAccountManager, } from '../lib/email-confirmed.js' -/** Records the single-account UPDATE that setEmailConfirmedAt builds: - * which table, which values, which where-clause, and whether it was - * handed to executeWithRetry. */ -function makeFakeDb(opts: { failOnExecute?: boolean } = {}) { +const DID = 'did:plc:7iza6de2dwap2sbkpav7c6c6' + +/** + * Fake AccountManager recording the mint/redeem pair. Typed against + * the real interface — no casts needed, so a signature change in + * `EmailConfirmingAccountManager` breaks these tests rather than + * silently passing. + */ +function makeAccountManager( + opts: { failOn?: 'createEmailToken' | 'confirmEmail' } = {}, +) { const calls: { - table?: string - values?: { emailConfirmedAt: string } - where?: [string, string, string] - executed: number - } = { executed: 0 } - - const db = { - executeWithRetry: (query: { execute: () => Promise }) => { - calls.executed++ - // Must receive the fully built statement — guards against a - // chain that hands executeWithRetry something unexecutable. - expect(query.execute).toBeTypeOf('function') - if (opts.failOnExecute) return Promise.reject(new Error('db down')) - return Promise.resolve(undefined) + minted: { did: string; purpose: string }[] + redeemed: { did: string; token: string }[] + } = { minted: [], redeemed: [] } + + let counter = 0 + + const accountManager: EmailConfirmingAccountManager = { + createEmailToken: (did, purpose) => { + if (opts.failOn === 'createEmailToken') { + return Promise.reject(new Error('db down')) + } + calls.minted.push({ did, purpose }) + return Promise.resolve(`TOKEN-${++counter}`) }, - db: { - updateTable: (table: string) => { - calls.table = table - return { - set: (values: { emailConfirmedAt: string }) => { - calls.values = values - return { - where: (column: string, op: string, value: string) => { - calls.where = [column, op, value] - return { execute: () => Promise.resolve(undefined) } - }, - } - }, - } - }, + confirmEmail: ({ did, token }) => { + if (opts.failOn === 'confirmEmail') { + return Promise.reject(new Error('token rejected')) + } + calls.redeemed.push({ did, token }) + return Promise.resolve() }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- structural fake standing in for AccountDb - } as any + } - return { db, calls } + return { accountManager, calls } } -const DID = 'did:plc:7iza6de2dwap2sbkpav7c6c6' - -describe('setEmailConfirmedAt', () => { - it('updates the account row for the given DID via executeWithRetry', async () => { - const { db, calls } = makeFakeDb() +describe('confirmAccountEmail', () => { + it('redeems the token it just minted, for the same DID', async () => { + const { accountManager, calls } = makeAccountManager() - await setEmailConfirmedAt(db, DID, '2026-08-04T10:00:00.000Z') + await confirmAccountEmail(accountManager, DID) - expect(calls.table).toBe('account') - expect(calls.values).toEqual({ - emailConfirmedAt: '2026-08-04T10:00:00.000Z', - }) - expect(calls.where).toEqual(['did', '=', DID]) - expect(calls.executed).toBe(1) + expect(calls.minted).toEqual([{ did: DID, purpose: 'confirm_email' }]) + // The token must be the minted one — redeeming anything else + // would leave the freshly created token row behind. + expect(calls.redeemed).toEqual([{ did: DID, token: 'TOKEN-1' }]) }) - it('defaults emailConfirmedAt to an ISO timestamp of now', async () => { - const { db, calls } = makeFakeDb() - const before = Date.now() - - await setEmailConfirmedAt(db, DID) + it('propagates a minting failure without attempting to redeem', async () => { + const { accountManager, calls } = makeAccountManager({ + failOn: 'createEmailToken', + }) - const written = calls.values?.emailConfirmedAt as string - expect(written).toMatch(/^\d{4}-\d{2}-\d{2}T[\d:.]+Z$/) - const writtenMs = Date.parse(written) - expect(writtenMs).toBeGreaterThanOrEqual(before) - expect(writtenMs).toBeLessThanOrEqual(Date.now()) + await expect(confirmAccountEmail(accountManager, DID)).rejects.toThrow( + 'db down', + ) + expect(calls.redeemed).toEqual([]) }) - it('propagates db failures to the caller', async () => { - const { db } = makeFakeDb({ failOnExecute: true }) + it('propagates a redemption failure', async () => { + const { accountManager } = makeAccountManager({ failOn: 'confirmEmail' }) - await expect(setEmailConfirmedAt(db, DID)).rejects.toThrow('db down') + await expect(confirmAccountEmail(accountManager, DID)).rejects.toThrow( + 'token rejected', + ) }) }) describe('markEmailConfirmed', () => { - it('stamps the account and stays silent on success', async () => { - const { db, calls } = makeFakeDb() + it('confirms the account and stays silent on success', async () => { + const { accountManager, calls } = makeAccountManager() const logger = { warn: vi.fn() } - await markEmailConfirmed({ db, did: DID, logger }) + await markEmailConfirmed({ accountManager, did: DID, logger }) - expect(calls.where).toEqual(['did', '=', DID]) + expect(calls.redeemed).toEqual([{ did: DID, token: 'TOKEN-1' }]) expect(logger.warn).not.toHaveBeenCalled() }) - it('swallows db failures so sign-in is never blocked, logging at warn', async () => { - const { db } = makeFakeDb({ failOnExecute: true }) + it('swallows failures so sign-in is never blocked, logging at warn', async () => { + const { accountManager } = makeAccountManager({ failOn: 'confirmEmail' }) const logger = { warn: vi.fn() } // Must resolve, not reject — the user has already proven ownership // of the address, so bookkeeping failure must not fail their sign-in. await expect( - markEmailConfirmed({ db, did: DID, logger }), + markEmailConfirmed({ accountManager, did: DID, logger }), ).resolves.toBeUndefined() expect(logger.warn).toHaveBeenCalledTimes(1) - const [context, message] = logger.warn.mock.calls[0] + const [context] = logger.warn.mock.calls[0] expect(context).toMatchObject({ did: DID }) - expect((context as { err: Error }).err.message).toBe('db down') - expect(message).toMatch(/emailConfirmedAt/) + expect((context as { err: Error }).err.message).toBe('token rejected') }) }) -/** Records the backfill's SELECT/UPDATE filters so the tests can - * assert the criteria, not just the row count. */ -function makeBackfillDb(opts: { - candidates: string[] - /** Rows the UPDATE reports touching; defaults to candidates.length - * so the common case needs no wiring. */ - numUpdatedRows?: number -}) { - const selectWheres: [string, string, unknown][] = [] - const updateWheres: [string, string, unknown][] = [] - let updateValues: { emailConfirmedAt: string } | undefined - let updateExecuted = 0 - - // Mirrors the real Kysely surface: SELECTs are read with execute(), - // UPDATEs with executeTakeFirst() returning an UpdateResult whose - // numUpdatedRows is a bigint. - const chain = ( - sink: [string, string, unknown][], - onExecute: () => Promise, - onExecuteTakeFirst?: () => Promise, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- self-referential fluent fake - ): any => ({ - select: () => chain(sink, onExecute, onExecuteTakeFirst), - where: (column: string, op: string, value: unknown) => { - sink.push([column, op, value]) - return chain(sink, onExecute, onExecuteTakeFirst) - }, - execute: onExecute, - executeTakeFirst: onExecuteTakeFirst, +describe('needsEmailConfirmation', () => { + it('is true for an account with an address and no confirmation', () => { + expect( + needsEmailConfirmation({ email: 'a@x.test', emailConfirmedAt: null }), + ).toBe(true) }) - const db = { - db: { - selectFrom: (table: string) => { - expect(table).toBe('account') - return chain(selectWheres, () => - Promise.resolve(opts.candidates.map((did) => ({ did }))), - ) - }, - updateTable: (table: string) => { - expect(table).toBe('account') - return { - set: (values: { emailConfirmedAt: string }) => { - updateValues = values - return chain( - updateWheres, - () => - Promise.reject(new Error('UPDATE must use executeTakeFirst')), - () => { - updateExecuted++ - return Promise.resolve({ - numUpdatedRows: BigInt( - opts.numUpdatedRows ?? opts.candidates.length, - ), - }) - }, - ) - }, - } - }, - }, - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- structural fake standing in for AccountDb - } as any - - return { - db, - inspect: () => ({ - selectWheres, - updateWheres, - updateValues, - updateExecuted, - }), - } -} - -describe('backfillEmailConfirmedAt', () => { - it('stamps every unconfirmed account that has an email', async () => { - const { db, inspect } = makeBackfillDb({ - candidates: ['did:plc:aaa', 'did:plc:bbb'], - }) + it('is false once confirmed, so re-runs and sign-ins skip the work', () => { + expect( + needsEmailConfirmation({ + email: 'a@x.test', + emailConfirmedAt: '2026-08-04T10:00:00.000Z', + }), + ).toBe(false) + }) - const result = await backfillEmailConfirmedAt({ - db, - emailConfirmedAt: '2026-08-04T10:00:00.000Z', - }) + it('is false when there is no address to confirm', () => { + // The column is NOT NULL in the PDS schema, so "no address" + // arrives as the empty string; null is covered as insurance + // against that constraint being relaxed upstream. + expect(needsEmailConfirmation({ email: '', emailConfirmedAt: null })).toBe( + false, + ) + expect( + needsEmailConfirmation({ email: null, emailConfirmedAt: null }), + ).toBe(false) + }) - expect(result).toEqual({ candidates: 2, updated: 2, dryRun: false }) - const { updateValues, updateExecuted } = inspect() - expect(updateValues).toEqual({ - emailConfirmedAt: '2026-08-04T10:00:00.000Z', - }) - expect(updateExecuted).toBe(1) + it('is false for a missing account', () => { + expect(needsEmailConfirmation(null)).toBe(false) }) +}) - it('only targets rows with a null emailConfirmedAt and a real email', async () => { - const { db, inspect } = makeBackfillDb({ candidates: ['did:plc:aaa'] }) +describe('backfillEmailConfirmedAt', () => { + const UNCONFIRMED = [ + { did: 'did:plc:aaa', email: 'a@x.test', emailConfirmedAt: null }, + { did: 'did:plc:bbb', email: 'b@x.test', emailConfirmedAt: null }, + ] + const CONFIRMED = { + did: 'did:plc:ccc', + email: 'c@x.test', + emailConfirmedAt: '2026-08-04T10:00:00.000Z', + } + const NO_EMAIL = { did: 'did:plc:ddd', email: '', emailConfirmedAt: null } - await backfillEmailConfirmedAt({ db }) + it('confirms only the accounts that need it', async () => { + const { accountManager, calls } = makeAccountManager() - // Accounts with no address have nothing to confirm, and already - // stamped rows must be excluded so re-running is a no-op. - const expected = [ - ['emailConfirmedAt', 'is', null], - ['email', 'is not', null], - ['email', '!=', ''], - ] - expect(inspect().selectWheres).toEqual(expected) - expect(inspect().updateWheres).toEqual(expected) + const result = await backfillEmailConfirmedAt({ + accountManager, + accounts: [...UNCONFIRMED, CONFIRMED, NO_EMAIL], + }) + + expect(result).toMatchObject({ candidates: 2, updated: 2, failed: 0 }) + expect(calls.redeemed.map((r) => r.did)).toEqual([ + 'did:plc:aaa', + 'did:plc:bbb', + ]) }) it('reports candidates without writing when dryRun is set', async () => { - const { db, inspect } = makeBackfillDb({ - candidates: ['did:plc:aaa', 'did:plc:bbb', 'did:plc:ccc'], - }) + const { accountManager, calls } = makeAccountManager() - const result = await backfillEmailConfirmedAt({ db, dryRun: true }) + const result = await backfillEmailConfirmedAt({ + accountManager, + accounts: [...UNCONFIRMED, CONFIRMED], + dryRun: true, + }) - expect(result).toEqual({ candidates: 3, updated: 0, dryRun: true }) - expect(inspect().updateExecuted).toBe(0) + expect(result).toEqual({ + candidates: 2, + updated: 0, + failed: 0, + failures: [], + dryRun: true, + }) + expect(calls.minted).toEqual([]) }) - it('skips the UPDATE entirely when nothing needs backfilling', async () => { - const { db, inspect } = makeBackfillDb({ candidates: [] }) + it('is a no-op when every account is already confirmed', async () => { + const { accountManager, calls } = makeAccountManager() - const result = await backfillEmailConfirmedAt({ db }) + const result = await backfillEmailConfirmedAt({ + accountManager, + accounts: [CONFIRMED], + }) - expect(result).toEqual({ candidates: 0, updated: 0, dryRun: false }) - expect(inspect().updateExecuted).toBe(0) + expect(result).toMatchObject({ candidates: 0, updated: 0, failed: 0 }) + expect(calls.minted).toEqual([]) }) - it('reports the rows the UPDATE touched, not the rows the SELECT saw', async () => { - // The SELECT and UPDATE are separate statements: a sign-in that - // stamps one of these rows in between leaves the UPDATE touching - // fewer rows than were counted. Report the database's number, so - // the operator is not told more accounts changed than really did. - const { db } = makeBackfillDb({ - candidates: ['did:plc:aaa', 'did:plc:bbb', 'did:plc:ccc'], - numUpdatedRows: 2, - }) + it('keeps going after a failure and reports which accounts failed', async () => { + // One unconfirmable account must not strand the rest of the run, + // and the operator needs the DIDs to chase them up. + let attempt = 0 + const accountManager: EmailConfirmingAccountManager = { + createEmailToken: () => Promise.resolve('TOKEN'), + confirmEmail: () => { + attempt++ + return attempt === 1 + ? Promise.reject(new Error('account deactivated')) + : Promise.resolve() + }, + } - const result = await backfillEmailConfirmedAt({ db }) + const result = await backfillEmailConfirmedAt({ + accountManager, + accounts: UNCONFIRMED, + }) - expect(result).toEqual({ candidates: 3, updated: 2, dryRun: false }) + expect(result).toMatchObject({ candidates: 2, updated: 1, failed: 1 }) + expect(result.failures).toEqual([ + { did: 'did:plc:aaa', error: 'account deactivated' }, + ]) }) }) @@ -278,10 +242,11 @@ describe('parseDryRun', () => { describe('formatBackfillReport', () => { const LOCATION = '/data/account.sqlite' + const base = { failed: 0, failures: [], dryRun: false } it('reports the candidate count and names the db on a dry run', () => { const line = formatBackfillReport( - { candidates: 3, updated: 0, dryRun: true }, + { ...base, candidates: 3, updated: 0, dryRun: true }, LOCATION, ) @@ -292,7 +257,7 @@ describe('formatBackfillReport', () => { it('reports what was actually written on a real run', () => { const line = formatBackfillReport( - { candidates: 3, updated: 3, dryRun: false }, + { ...base, candidates: 3, updated: 3 }, LOCATION, ) @@ -301,10 +266,24 @@ describe('formatBackfillReport', () => { it('makes a no-op run unambiguous rather than silent', () => { expect( - formatBackfillReport( - { candidates: 0, updated: 0, dryRun: false }, - LOCATION, - ), + formatBackfillReport({ ...base, candidates: 0, updated: 0 }, LOCATION), ).toContain('0 account(s)') }) + + it('names the accounts that failed so the operator can chase them', () => { + const line = formatBackfillReport( + { + candidates: 2, + updated: 1, + failed: 1, + failures: [{ did: 'did:plc:aaa', error: 'account deactivated' }], + dryRun: false, + }, + LOCATION, + ) + + expect(line).toContain('Marked 1 account(s)') + expect(line).toContain('1 account(s) could not be confirmed') + expect(line).toContain('did:plc:aaa: account deactivated') + }) }) diff --git a/packages/pds-core/src/backfill-email-confirmed.ts b/packages/pds-core/src/backfill-email-confirmed.ts index 16df22b9..7b1eb532 100644 --- a/packages/pds-core/src/backfill-email-confirmed.ts +++ b/packages/pds-core/src/backfill-email-confirmed.ts @@ -1,6 +1,6 @@ /** - * One-off operator script: stamp `emailConfirmedAt` on ePDS accounts - * created before that field was recorded at sign-up. + * One-off operator script: record email confirmation for ePDS + * accounts created before that was done at sign-up. * * Run it against a deployment's account database, with the same * environment the server runs with: @@ -11,19 +11,25 @@ * Not wired into startup on purpose — see the rationale on * `backfillEmailConfirmedAt`. * - * Builds the PDS via the same `PDS.create()` the server uses (rather - * than opening account.sqlite directly) so the database location, - * migrations and connection settings come from the deployment's own - * config, and so we stay on `@atproto/pds`'s public entry point — - * its account-db helpers are not exported from the package root. - * `create()` wires up the context without binding a port; `start()` - * is never called, so nothing is served. + * Builds the PDS via the same `PDS.create()` the server uses so the + * database location, migrations and connection settings come from the + * deployment's own config. `create()` wires up the context without + * binding a port; `start()` is never called, so nothing is served. + * + * Confirmation itself goes through `accountManager.createEmailToken` / + * `confirmEmail`, per AGENTS.md's account-manager boundary. Listing + * the accounts to consider is the one read this script does directly: + * `AccountManager` exposes `getAccounts(dids)` but no "every account" + * query, and a backfill cannot know the DIDs in advance. The read is + * confined to this operator-invoked script — pds-core's request path + * never does it. */ import { PDS, envToCfg, envToSecrets, readEnv } from '@atproto/pds' import { backfillEmailConfirmedAt, formatBackfillReport, parseDryRun, + type BackfillCandidate, } from './lib/email-confirmed.js' async function main(): Promise { @@ -35,13 +41,22 @@ async function main(): Promise { const pds = await PDS.create(cfg, secrets) try { + const accounts: BackfillCandidate[] = await pds.ctx.accountManager.db.db + .selectFrom('account') + .select(['did', 'email', 'emailConfirmedAt']) + .execute() + const result = await backfillEmailConfirmedAt({ - db: pds.ctx.accountManager.db, + accountManager: pds.ctx.accountManager, + accounts, dryRun, }) process.stdout.write( formatBackfillReport(result, cfg.db.accountDbLoc) + '\n', ) + // Surface partial failure to the shell so a scripted run does not + // report success when some accounts could not be confirmed. + if (result.failed > 0) process.exitCode = 1 } finally { await pds.destroy() } diff --git a/packages/pds-core/src/index.ts b/packages/pds-core/src/index.ts index bbf4c8d6..0c98a25e 100644 --- a/packages/pds-core/src/index.ts +++ b/packages/pds-core/src/index.ts @@ -72,7 +72,10 @@ import { createUpstreamFaviconMiddleware } from './upstream-favicon.js' import { createAuthUiGuard, parsePromptTokens } from './auth-ui-guard.js' import { loadDeviceAccountEmails } from './lib/device-accounts.js' import { handleCallbackError } from './lib/epds-callback-error.js' -import { markEmailConfirmed } from './lib/email-confirmed.js' +import { + markEmailConfirmed, + needsEmailConfirmation, +} from './lib/email-confirmed.js' import { installTestHooks } from './lib/test-hooks.js' import { buildPostCallbackAuthorizeUrl } from './lib/epds-callback-authorize.js' @@ -489,15 +492,19 @@ async function main() { // Step 4b: Record that the email is confirmed. Reaching this // point means auth-service verified a one-time code sent to - // `email`, but upstream only populates `emailConfirmedAt` from + // `email`, but upstream only records confirmation via // confirmEmail()'s token flow, so without this the address stays - // marked unverified forever. Only new accounts need it — - // existing ones were stamped when they were created (or by the - // one-off backfill script for accounts predating this). + // marked unverified forever. + // + // Keyed on whether the account is *confirmed*, not on whether it + // is new: a returning user whose earlier confirmation failed + // (or who predates this code) is repaired on their next sign-in + // rather than waiting for the backfill script. Already-confirmed + // accounts skip it, so the common case costs no writes. // Best-effort: never fails the sign-in. - if (!existingAccount) { + if (!existingAccount || needsEmailConfirmation(existingAccount)) { await markEmailConfirmed({ - db: pds.ctx.accountManager.db, + accountManager: pds.ctx.accountManager, did: account.sub, logger, }) diff --git a/packages/pds-core/src/lib/email-confirmed.ts b/packages/pds-core/src/lib/email-confirmed.ts index f7466c79..0b0b056b 100644 --- a/packages/pds-core/src/lib/email-confirmed.ts +++ b/packages/pds-core/src/lib/email-confirmed.ts @@ -6,8 +6,8 @@ * address. The email is therefore *already* verified by the time the * account row exists — but the PDS `account` table's * `emailConfirmedAt` column stays null, because upstream only ever - * populates it from `confirmEmail()`, which demands a - * `confirm_email` token the OTP flow never issues. + * populates it from `confirmEmail()`, which consumes a + * `confirm_email` token that the OTP flow never mints. * * Leaving it null has two observable consequences: * 1. `email_verified` is false in every OIDC/token claim, because @@ -17,94 +17,117 @@ * token when `emailConfirmedAt` is set, so a null value skips * the verification gate on email change entirely. * - * Lives in its own module so the write can be unit-tested against a - * fake db without booting a real PDS, matching the extraction - * pattern used by the other lib/ modules. + * ## Why mint-then-redeem rather than writing the column * - * ## Why write via Kysely rather than upstream's helper + * AGENTS.md: "Do not directly read or modify `@atproto/pds` database + * tables — use `pds.ctx.accountManager.*` methods." `createEmailToken` + * and `confirmEmail` are both public `AccountManager` methods, and + * together they are exactly the supported route to a confirmed email: + * `confirmEmail` validates the token, deletes it, and sets + * `emailConfirmedAt` in a single transaction, so no token row is left + * behind. * - * `@atproto/pds` does not re-export `setEmailConfirmedAt` from its - * package root, and its package.json declares no `exports` map — so - * the only way to call it directly is a deep import into `dist/`, - * which nothing guarantees across upgrades. `AccountManager.db` is - * public and typed (`readonly db: AccountDb`), so we issue the same - * statement upstream's helper issues, through a supported surface. - * Kept deliberately identical to upstream's implementation - * (`account-manager/helpers/account.ts`) so behaviour matches. + * `createEmailToken` only inserts the row and returns the token — + * upstream's XRPC handlers do the mailing separately, so nothing is + * sent to the user here. That matters: the user already proved + * ownership of this address via the OTP, and a second unexpected mail + * would be worse than the bug being fixed. + * + * Lives in its own module so the flow can be unit-tested against a + * fake account manager without booting a real PDS, matching the + * extraction pattern used by the other lib/ modules. */ import type { Logger } from 'pino' /** - * The slice of `AccountDb` we depend on: `executeWithRetry` (SQLite - * busy-retry wrapper) plus the Kysely instance. Structural typing - * against the real `AccountDb` keeps the fake in tests honest - * without importing PDS internals. + * The slice of `AccountManager` this module needs. Structurally + * compatible with the real class, so the call sites pass + * `pds.ctx.accountManager` directly and the fakes in tests stay + * honest without importing PDS internals. */ -export interface EmailConfirmedDb { - /** SQLite busy-retry wrapper; takes any executable Kysely statement. */ - executeWithRetry: (query: { execute: () => Promise }) => Promise - db: { - updateTable: (table: 'account') => { - set: (values: { emailConfirmedAt: string }) => { - where: ( - column: 'did', - op: '=', - value: string, - ) => { execute: () => Promise } - } - } - } +export interface EmailConfirmingAccountManager { + createEmailToken: (did: string, purpose: 'confirm_email') => Promise + confirmEmail: (opts: { did: string; token: string }) => Promise } /** - * Set `emailConfirmedAt` on a single account. + * Confirm a single account's email address. * - * Mirrors upstream `setEmailConfirmedAt(db, did, emailConfirmedAt)`. - * `emailConfirmedAt` defaults to now, which is the honest value for - * the OTP flow: the code was verified moments ago. + * Mints a `confirm_email` token and immediately redeems it. Both + * halves are public `AccountManager` operations; the token never + * leaves this function and is deleted by `confirmEmail` as part of + * the same transaction that records the confirmation. */ -export async function setEmailConfirmedAt( - db: EmailConfirmedDb, +export async function confirmAccountEmail( + accountManager: EmailConfirmingAccountManager, did: string, - emailConfirmedAt: string = new Date().toISOString(), ): Promise { - await db.executeWithRetry( - db.db - .updateTable('account') - .set({ emailConfirmedAt }) - .where('did', '=', did), - ) + const token = await accountManager.createEmailToken(did, 'confirm_email') + await accountManager.confirmEmail({ did, token }) } /** - * The db slice the backfill needs: a Kysely instance able to run the - * bulk UPDATE and count the rows it would touch. + * Best-effort variant used on the sign-in hot path. + * + * The email genuinely *is* verified at this point, so recording that + * fact is correct — but it is bookkeeping, not part of the sign-in + * contract. If it fails (SQLite busy, disk error) the user has still + * proven ownership of the address, so failing their sign-in over it + * would be a strictly worse outcome than a stale + * `email_verified: false` claim. The error is logged at `warn` so it + * stays visible without tripping error-level alerting, and the next + * sign-in retries — callers skip already-confirmed accounts, not + * already-*seen* ones, so a failure here is self-healing. */ -export interface BackfillDb { - db: { - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- structural slice of Kysely's fluent builder; the real types come from AccountDb at the call site - selectFrom: (table: 'account') => any - // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ditto - updateTable: (table: 'account') => any +export async function markEmailConfirmed(opts: { + accountManager: EmailConfirmingAccountManager + did: string + logger: Pick +}): Promise { + try { + await confirmAccountEmail(opts.accountManager, opts.did) + } catch (err) { + opts.logger.warn( + { err, did: opts.did }, + 'Failed to record email confirmation after OTP-verified sign-in', + ) } } +/** An account as far as the backfill is concerned. */ +export interface BackfillCandidate { + did: string + email?: string | null + emailConfirmedAt?: string | null +} + +/** True when this account has a real address that is not yet confirmed. */ +export function needsEmailConfirmation( + account: Pick | null, +): boolean { + if (!account) return false + // An account with no address has nothing to confirm — upstream + // reports `email_verified` as undefined rather than false for those. + if (!account.email) return false + return !account.emailConfirmedAt +} + export interface BackfillResult { - /** Rows matching the backfill criteria before the update ran. */ + /** Accounts found needing confirmation. */ candidates: number - /** - * Rows the UPDATE actually touched, as reported by the database; - * 0 when `dryRun` is set. May be lower than `candidates` if - * something stamped a row between the two statements. - */ + /** Accounts confirmed successfully. */ updated: number + /** Accounts that failed to confirm; see `failures`. */ + failed: number + /** DIDs that failed, with the reason, for the operator to chase. */ + failures: { did: string; error: string }[] /** True when no write was attempted. */ dryRun: boolean } /** * One-off backfill for accounts created before this fix landed, whose - * `emailConfirmedAt` is still null. + * email was verified by OTP but never recorded as confirmed. * * Deliberately NOT run automatically at startup. Whether a null * `emailConfirmedAt` means "verified via OTP but never recorded" or @@ -115,55 +138,55 @@ export interface BackfillResult { * silently promoted to verified. Only the operator knows which case * applies, so this is exposed as a script they choose to run. * - * Restricted to rows with a real email address: an account with no - * address has nothing to confirm, and upstream reports - * `email_verified` as undefined rather than false for those. The - * column is `NOT NULL` in the PDS schema, so "no address" shows up - * as the empty string; the null check is kept alongside it as - * cheap insurance against that constraint being relaxed upstream. + * Confirms one account at a time via the public API rather than a + * single set-based UPDATE. That is more round-trips, but it keeps to + * the account-manager boundary and lets one bad row be reported + * without abandoning the rest of the run. * - * Idempotent — rows already stamped are excluded, so re-running is a - * no-op. Use `dryRun` to report the candidate count without writing. + * Idempotent — already-confirmed accounts are skipped, so re-running + * is a no-op. Use `dryRun` to report the candidate count without + * writing. */ export async function backfillEmailConfirmedAt(opts: { - db: BackfillDb - emailConfirmedAt?: string + accountManager: EmailConfirmingAccountManager + /** Every account to consider, typically the full account list. */ + accounts: readonly BackfillCandidate[] dryRun?: boolean }): Promise { const dryRun = opts.dryRun ?? false - const emailConfirmedAt = opts.emailConfirmedAt ?? new Date().toISOString() + const candidates = opts.accounts.filter((a) => needsEmailConfirmation(a)) - const rows = await opts.db.db - .selectFrom('account') - .select('did') - .where('emailConfirmedAt', 'is', null) - .where('email', 'is not', null) - .where('email', '!=', '') - .execute() - const candidates = rows.length - - if (dryRun || candidates === 0) { - return { candidates, updated: 0, dryRun } + if (dryRun || candidates.length === 0) { + return { + candidates: candidates.length, + updated: 0, + failed: 0, + failures: [], + dryRun, + } } - // Report what the UPDATE actually touched rather than reusing the - // SELECT's count. The two are separate statements, so a sign-in - // that stamps one of these rows in between would make `candidates` - // an overstatement — and this number is the operator's only - // evidence of what the script did. - const result = await opts.db.db - .updateTable('account') - .set({ emailConfirmedAt }) - .where('emailConfirmedAt', 'is', null) - .where('email', 'is not', null) - .where('email', '!=', '') - .executeTakeFirst() + let updated = 0 + const failures: { did: string; error: string }[] = [] + for (const account of candidates) { + try { + await confirmAccountEmail(opts.accountManager, account.did) + updated++ + } catch (err) { + // One unconfirmable account must not strand the rest of the + // run; collect and report instead. + failures.push({ + did: account.did, + error: err instanceof Error ? err.message : String(err), + }) + } + } return { - candidates, - // numUpdatedRows is a bigint; row counts here are far below - // Number.MAX_SAFE_INTEGER, so the narrowing is safe. - updated: Number(result?.numUpdatedRows ?? 0), + candidates: candidates.length, + updated, + failed: failures.length, + failures, dryRun, } } @@ -182,34 +205,11 @@ export function formatBackfillReport( result: BackfillResult, location: string, ): string { - return result.dryRun - ? `[dry run] ${result.candidates} account(s) in ${location} would be marked email-confirmed.` - : `Marked ${result.updated} account(s) in ${location} as email-confirmed.` -} - -/** - * Best-effort variant used on the sign-in hot path. - * - * The email genuinely *is* verified at this point, so recording that - * fact is correct — but it is bookkeeping, not part of the sign-in - * contract. If the write fails (SQLite busy, disk error) the user - * has still proven ownership of the address, so failing their - * sign-in over it would be a strictly worse outcome than a stale - * `email_verified: false` claim that the next sign-in re-attempts. - * The error is logged at `warn` so it stays visible without tripping - * error-level alerting. - */ -export async function markEmailConfirmed(opts: { - db: EmailConfirmedDb - did: string - logger: Pick -}): Promise { - try { - await setEmailConfirmedAt(opts.db, opts.did) - } catch (err) { - opts.logger.warn( - { err, did: opts.did }, - 'Failed to set emailConfirmedAt after OTP-verified account creation', - ) + if (result.dryRun) { + return `[dry run] ${result.candidates} account(s) in ${location} would be marked email-confirmed.` } + const base = `Marked ${result.updated} account(s) in ${location} as email-confirmed.` + if (result.failed === 0) return base + const detail = result.failures.map((f) => ` ${f.did}: ${f.error}`).join('\n') + return `${base}\n${result.failed} account(s) could not be confirmed:\n${detail}` } From e22559f85c430e04f1139edb646e991e70a1c5a3 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 4 Aug 2026 14:30:33 +0100 Subject: [PATCH 67/84] feat: carry proof-of-email-control across the auth handover pds-core inferred that a valid signed callback meant the user had proved control of the email it carried. That held only because an emailed one-time code was the sole route to such a callback, and the assumption lived in a comment rather than in the payload. A passkey or similar flow breaks it: that flow would legitimately sign a callback carrying `email` merely to locate the account, having proved nothing about the address. pds-core would then mark it confirmed -- asserting email_verified: true to relying parties on no evidence, and arming upstream's email-change verification gate on an address nobody proved. The failure would be silent and in the dangerous direction. Carry the fact instead of inferring it. auth-service reads better-auth's emailVerified where it already reads the session and signs it into the callback as `email_verified`; pds-core records confirmation only on an explicit '1'. The field is required rather than sentinel-defaulted like handle and client_id. The payload is positional, so a producer that omits it signs a different payload and is rejected at the trust boundary. A future flow whose author forgets it fails loudly instead of quietly claiming verification. It is inside the HMAC, so it cannot be flipped from '0' to '1' by anyone holding the URL. The recovery path sets it false: it rebinds `email` from the verified backup address to the account's primary, so the session's flag no longer describes the address being signed. Also repairs two expiry tests that hand-build the payload. They were passing on a stale signature rather than on the timestamp check, so they would have kept passing with expiry validation removed entirely. Operators must deploy both services together; a mixed pair rejects sign-in with "Invalid callback signature" until the rollout completes. Raised by Adam in review of PR #234. --- .changeset/record-email-as-confirmed.md | 4 +- .../__tests__/build-epds-callback-url.test.ts | 48 +++++++++++++++++ .../auth-service/src/routes/choose-handle.ts | 17 +++++- packages/auth-service/src/routes/complete.ts | 40 +++++++++++--- packages/pds-core/src/index.ts | 31 ++++++++--- packages/shared/src/__tests__/crypto.test.ts | 54 +++++++++++++++++++ packages/shared/src/crypto.ts | 29 +++++++++- 7 files changed, 206 insertions(+), 17 deletions(-) diff --git a/.changeset/record-email-as-confirmed.md b/.changeset/record-email-as-confirmed.md index c28d12af..9f65982d 100644 --- a/.changeset/record-email-as-confirmed.md +++ b/.changeset/record-email-as-confirmed.md @@ -8,7 +8,9 @@ Apps you sign in to are now told that your email address is confirmed. **Client app developers:** the `email_verified` claim is now `true` for accounts that signed in through the emailed-code flow, instead of always `false`. If your app gated any behaviour on `email_verified` being true, that gate will now open for those accounts. -**Operators:** new accounts are recorded as confirmed at sign-up, and existing accounts are repaired the next time their owner signs in — so most accounts need no action. Use the backfill for accounts whose owners have not signed in since upgrading: +**Operators:** deploy the auth service and the PDS together. The signed handover between them carries a new required field, so a sign-in served by one old and one new instance is rejected with `Invalid callback signature` until both are updated. Sign-in recovers on its own once the rollout completes; no data is affected. + +New accounts are recorded as confirmed at sign-up, and existing accounts are repaired the next time their owner signs in — so most accounts need no action. Use the backfill for accounts whose owners have not signed in since upgrading: - `pnpm --filter @certified-app/pds-core backfill:email-confirmed --dry-run` reports how many accounts would change. - Dropping `--dry-run` performs the update. It is idempotent, so re-running is safe. diff --git a/packages/auth-service/src/__tests__/build-epds-callback-url.test.ts b/packages/auth-service/src/__tests__/build-epds-callback-url.test.ts index faf25ab1..b998b245 100644 --- a/packages/auth-service/src/__tests__/build-epds-callback-url.test.ts +++ b/packages/auth-service/src/__tests__/build-epds-callback-url.test.ts @@ -58,6 +58,7 @@ describe('buildEpdsCallbackUrl', () => { flowClientId: CLIENT_ID, email: EMAIL, isNewAccount: false, + emailVerified: true, pdsPublicUrl: PDS_PUBLIC_URL, epdsCallbackSecret: SECRET, }) @@ -72,6 +73,7 @@ describe('buildEpdsCallbackUrl', () => { flowClientId: CLIENT_ID, email: EMAIL, isNewAccount: false, + emailVerified: true, pdsPublicUrl: PDS_PUBLIC_URL, epdsCallbackSecret: SECRET, }) @@ -89,6 +91,7 @@ describe('buildEpdsCallbackUrl', () => { email: EMAIL, approved: '1', new_account: '0', + email_verified: '1', client_id: CLIENT_ID, } expect(verifyCallback(params, ts, sig, SECRET)).toBe(true) @@ -100,6 +103,7 @@ describe('buildEpdsCallbackUrl', () => { flowClientId: CLIENT_ID, email: EMAIL, isNewAccount: true, + emailVerified: true, pdsPublicUrl: PDS_PUBLIC_URL, epdsCallbackSecret: SECRET, }) @@ -110,6 +114,7 @@ describe('buildEpdsCallbackUrl', () => { email: EMAIL, approved: '1', new_account: '1', + email_verified: '1', client_id: CLIENT_ID, } expect( @@ -128,6 +133,7 @@ describe('buildEpdsCallbackUrl', () => { flowClientId: null, email: EMAIL, isNewAccount: false, + emailVerified: true, pdsPublicUrl: PDS_PUBLIC_URL, epdsCallbackSecret: SECRET, }) @@ -140,6 +146,7 @@ describe('buildEpdsCallbackUrl', () => { email: EMAIL, approved: '1', new_account: '0', + email_verified: '1', } expect( verifyCallback( @@ -160,6 +167,7 @@ describe('buildEpdsCallbackUrl', () => { flowClientId: CLIENT_ID, email: EMAIL, isNewAccount: true, + emailVerified: true, pdsPublicUrl: PDS_PUBLIC_URL, epdsCallbackSecret: SECRET, }) @@ -172,6 +180,7 @@ describe('buildEpdsCallbackUrl', () => { email: EMAIL, approved: '1', new_account: '1', + email_verified: '1', client_id: CLIENT_ID, } expect( @@ -190,6 +199,7 @@ describe('buildEpdsCallbackUrl', () => { flowClientId: CLIENT_ID, email: EMAIL, isNewAccount: false, + emailVerified: true, pdsPublicUrl: PDS_PUBLIC_URL, epdsCallbackSecret: SECRET, }) @@ -199,6 +209,7 @@ describe('buildEpdsCallbackUrl', () => { email: EMAIL, approved: '1', new_account: '0', + email_verified: '1', client_id: 'https://attacker.example/client-metadata.json', } expect( @@ -210,4 +221,41 @@ describe('buildEpdsCallbackUrl', () => { ), ).toBe(false) }) + it('forwards emailVerified into the signed callback, both ways', () => { + // The whole point of the field: pds-core records email + // confirmation from this and nothing else, so a flow that did not + // prove control of the address must say so. + for (const [emailVerified, expected] of [ + [true, '1'], + [false, '0'], + ] as const) { + const url = buildEpdsCallbackUrl({ + flowRequestUri: REQUEST_URI, + flowClientId: null, + email: EMAIL, + isNewAccount: true, + emailVerified, + pdsPublicUrl: PDS_PUBLIC_URL, + epdsCallbackSecret: SECRET, + }) + const q = paramsFromUrl(url) + expect(requiredParam(q, 'email_verified')).toBe(expected) + // And it must verify as signed — i.e. the value on the wire is + // the value inside the HMAC, not a decoration alongside it. + expect( + verifyCallback( + { + request_uri: REQUEST_URI, + email: EMAIL, + approved: '1', + new_account: '1', + email_verified: expected, + }, + requiredParam(q, 'ts'), + requiredParam(q, 'sig'), + SECRET, + ), + ).toBe(true) + } + }) }) diff --git a/packages/auth-service/src/routes/choose-handle.ts b/packages/auth-service/src/routes/choose-handle.ts index a986ca87..a98a8d59 100644 --- a/packages/auth-service/src/routes/choose-handle.ts +++ b/packages/auth-service/src/routes/choose-handle.ts @@ -67,6 +67,9 @@ export function createChooseHandleRouter( clientId: string | null } email: string + /** Whether this sign-in proved control of `email`; see + * CallbackParams.email_verified. */ + emailVerified: boolean } | null> { // Guard 1: auth_flow cookie const flowId = req.cookies[AUTH_FLOW_COOKIE] as string | undefined @@ -144,7 +147,16 @@ export function createChooseHandleRouter( return null } - return { flowId, flow, email: session.user.email.toLowerCase() } + // Carried alongside the email so the signed callback can state + // whether this sign-in actually proved control of that address. + // better-auth sets emailVerified when the emailed one-time code is + // verified. See CallbackParams.email_verified. + return { + flowId, + flow, + email: session.user.email.toLowerCase(), + emailVerified: session.user.emailVerified === true, + } } // --------------------------------------------------------------------------- @@ -245,7 +257,7 @@ export function createChooseHandleRouter( const result = await getFlowAndSession(req, res) if (!result) return - const { flowId, flow, email } = result + const { flowId, flow, email, emailVerified } = result // Guard: reject flows with handleMode='random' — they should skip the picker entirely if (flow.handleMode === 'random') { @@ -405,6 +417,7 @@ export function createChooseHandleRouter( new_account: '1', handle: normalizedLocal, epds_handle_mode: flow.handleMode ?? '', + email_verified: emailVerified ? '1' : '0', } if (flow.clientId) callbackParams.client_id = flow.clientId const { sig, ts } = signCallback( diff --git a/packages/auth-service/src/routes/complete.ts b/packages/auth-service/src/routes/complete.ts index da5c1c44..5309c00a 100644 --- a/packages/auth-service/src/routes/complete.ts +++ b/packages/auth-service/src/routes/complete.ts @@ -70,6 +70,15 @@ export function buildEpdsCallbackUrl(args: { email: string isNewAccount: boolean flowHandleMode?: string | null + /** + * Whether this sign-in actually proved control of `email` — read + * from the better-auth session, which sets it when the emailed + * one-time code is verified. pds-core records email confirmation + * from this and nothing else, so it must reflect what the + * authenticating flow really established, not what the current + * flow happens to be. See CallbackParams.email_verified. + */ + emailVerified: boolean pdsPublicUrl: string epdsCallbackSecret: string }): string { @@ -78,6 +87,7 @@ export function buildEpdsCallbackUrl(args: { email: args.email, approved: '1', new_account: args.isNewAccount ? '1' : '0', + email_verified: args.emailVerified ? '1' : '0', } if (args.flowClientId) callbackParams.client_id = args.flowClientId if (args.flowHandleMode) callbackParams.epds_handle_mode = args.flowHandleMode @@ -92,9 +102,9 @@ async function resolveCompleteIdentity( ctx: AuthServiceContext, pdsUrl: string, internalSecret: string, -): Promise<{ email: string; did: string | null }> { +): Promise<{ email: string; did: string | null; viaRecovery: boolean }> { const did = await getDidByEmail(email, pdsUrl, internalSecret) - if (did) return { email, did } + if (did) return { email, did, viaRecovery: false } // Recovery path: session email is a backup email, not a primary. Resolve // the backup-email -> DID mapping (auth-service-owned) and then DID -> @@ -106,13 +116,16 @@ async function resolveCompleteIdentity( pdsUrl, internalSecret, ) - if (!recovered) return { email, did: null } + if (!recovered) return { email, did: null, viaRecovery: false } logger.info( { flowId, did: recovered.did }, 'Recovery: translated backup email to primary email via DID', ) - return { email: recovered.email, did: recovered.did } + // Reported so the caller can withhold the email-verified claim: the + // returned address is the account's primary, which this sign-in proved + // nothing about. + return { email: recovered.email, did: recovered.did, viaRecovery: true } } export function createCompleteRouter( @@ -169,6 +182,7 @@ export function createCompleteRouter( }, email: string, flowId: string, + emailVerified: boolean, ): Promise { const ping = await pingParRequest(flow.requestUri, pdsUrl, internalSecret) if (!ping.ok) { @@ -183,6 +197,7 @@ export function createCompleteRouter( email, isNewAccount: true, flowHandleMode: flow.handleMode, + emailVerified, pdsPublicUrl: ctx.config.pdsPublicUrl, epdsCallbackSecret: ctx.config.epdsCallbackSecret, }) @@ -246,8 +261,14 @@ export function createCompleteRouter( const sessionEmail = session.user.email.toLowerCase() + // Whether this sign-in proved control of the address the user + // actually authenticated with. better-auth sets emailVerified when + // the emailed one-time code is verified, so it is the authoritative + // answer for `sessionEmail`. + const sessionEmailVerified: boolean = session.user.emailVerified === true + // Step 4: Check whether this is a new user (no PDS account for email). - const { email, did } = await resolveCompleteIdentity( + const { email, did, viaRecovery } = await resolveCompleteIdentity( sessionEmail, flowId, ctx, @@ -255,13 +276,19 @@ export function createCompleteRouter( internalSecret, ) + // Recovery rebinds `email` from the proved backup address to the + // account's primary. The user proved control of the backup, not the + // primary, so recovering access is not evidence about the address + // being signed here — withhold the claim. + const emailVerified = sessionEmailVerified && !viaRecovery + const isNewAccount = !did if (isNewAccount && flow.handleMode === 'random') { // Step 5a: skip the handle picker, let pds-core call // generateRandomHandle() (signalled by the absent `handle` // field in the signed callback). - await redirectNewUserRandomMode(res, flow, email, flowId) + await redirectNewUserRandomMode(res, flow, email, flowId, emailVerified) return } @@ -290,6 +317,7 @@ export function createCompleteRouter( email, isNewAccount: false, flowHandleMode: flow.handleMode, + emailVerified, pdsPublicUrl: ctx.config.pdsPublicUrl, epdsCallbackSecret: ctx.config.epdsCallbackSecret, }) diff --git a/packages/pds-core/src/index.ts b/packages/pds-core/src/index.ts index 0c98a25e..0da2ce6e 100644 --- a/packages/pds-core/src/index.ts +++ b/packages/pds-core/src/index.ts @@ -222,6 +222,10 @@ async function main() { const approvedStr = req.query.approved as string const newAccountStr = req.query.new_account as string + // Whether auth-service is claiming this sign-in proved control of + // `email`. Part of the HMAC payload, so it cannot be forged or + // stripped: a callback without it fails verifyCallback outright. + const emailVerifiedStr = req.query.email_verified as string const handleParam = req.query.handle as string | undefined const clientIdParam = req.query.client_id as string | undefined const handleModeParam = req.query.epds_handle_mode as string | undefined @@ -234,6 +238,7 @@ async function main() { handle: handleParam, client_id: clientIdParam, epds_handle_mode: handleModeParam, + email_verified: emailVerifiedStr, }, ts, sig, @@ -490,11 +495,21 @@ async function main() { } await provider.accountManager.upsertDeviceAccount(deviceId, account.did) - // Step 4b: Record that the email is confirmed. Reaching this - // point means auth-service verified a one-time code sent to - // `email`, but upstream only records confirmation via - // confirmEmail()'s token flow, so without this the address stays - // marked unverified forever. + // Step 4b: Record that the email is confirmed, but only when + // auth-service explicitly claims this sign-in proved control of + // `email`. Upstream only records confirmation via confirmEmail()'s + // token flow, so without this the address stays marked unverified + // forever. + // + // The claim is read from the signed callback rather than inferred + // from "a valid callback arrived". Only the authenticating service + // knows *how* the user authenticated: today that is always an + // emailed one-time code, but a passkey or similar flow would + // legitimately send a signed callback carrying `email` merely to + // locate the account, having proved nothing about that address. + // Inferring verification here would assert `email_verified: true` + // to relying parties on no evidence, and would engage the + // email-change verification gate on an unproven address. // // Keyed on whether the account is *confirmed*, not on whether it // is new: a returning user whose earlier confirmation failed @@ -502,7 +517,11 @@ async function main() { // rather than waiting for the backfill script. Already-confirmed // accounts skip it, so the common case costs no writes. // Best-effort: never fails the sign-in. - if (!existingAccount || needsEmailConfirmation(existingAccount)) { + const emailProven = emailVerifiedStr === '1' + if ( + emailProven && + (!existingAccount || needsEmailConfirmation(existingAccount)) + ) { await markEmailConfirmed({ accountManager: pds.ctx.accountManager, did: account.sub, diff --git a/packages/shared/src/__tests__/crypto.test.ts b/packages/shared/src/__tests__/crypto.test.ts index cd2a57c1..493ba883 100644 --- a/packages/shared/src/__tests__/crypto.test.ts +++ b/packages/shared/src/__tests__/crypto.test.ts @@ -168,6 +168,7 @@ describe('signCallback / verifyCallback', () => { email: 'user@example.com', approved: '1', new_account: '0', + email_verified: '1', } it('produces a hex signature and numeric timestamp string', () => { @@ -213,6 +214,7 @@ describe('signCallback / verifyCallback', () => { '', // handle sentinel (absent) '', // client_id sentinel (absent) '', // epds_handle_mode sentinel (absent) + params.email_verified, staleTs, ].join('\n') const { createHmac } = await import('node:crypto') @@ -248,6 +250,7 @@ describe('signCallback / verifyCallback', () => { '', // handle sentinel (absent) '', // client_id sentinel (absent) '', // epds_handle_mode sentinel (absent) + params.email_verified, futureTs, ].join('\n') const { createHmac } = await import('node:crypto') @@ -274,6 +277,7 @@ describe('signCallback / verifyCallback with handle', () => { email: 'alice@example.com', approved: '1', new_account: '1', + email_verified: '1', handle: 'alice.pds.example.com', } const { sig, ts } = signCallback(params, secret) @@ -287,6 +291,7 @@ describe('signCallback / verifyCallback with handle', () => { email: 'alice@example.com', approved: '1', new_account: '1', + email_verified: '1', } const { sig, ts } = signCallback(params, secret) expect(verifyCallback(params, ts, sig, secret)).toBe(true) @@ -299,6 +304,7 @@ describe('signCallback / verifyCallback with handle', () => { email: 'alice@example.com', approved: '1', new_account: '1', + email_verified: '1', } // Sign without handle, then verify that adding a handle breaks the signature. // This proves handle is included in the HMAC payload without relying on two @@ -321,6 +327,7 @@ describe('signCallback / verifyCallback with handle', () => { email: 'alice@example.com', approved: '1', new_account: '1', + email_verified: '1', } const withUndefined: CallbackParams = { ...baseParams, handle: undefined } const { sig, ts } = signCallback(baseParams, secret) @@ -339,6 +346,7 @@ describe('signCallback / verifyCallback with handle', () => { email: 'alice@example.com', approved: '1', new_account: '1', + email_verified: '1', handle: 'alice.pds.example.com', } const { sig, ts } = signCallback(params, secret) @@ -360,6 +368,7 @@ describe('signCallback / verifyCallback with client_id', () => { email: 'alice@example.com', approved: '1', new_account: '0', + email_verified: '1', client_id: 'https://demo.example.com/client-metadata.json', } const { sig, ts } = signCallback(params, secret) @@ -372,6 +381,7 @@ describe('signCallback / verifyCallback with client_id', () => { email: 'alice@example.com', approved: '1', new_account: '0', + email_verified: '1', client_id: 'https://demo.example.com/client-metadata.json', } const { sig, ts } = signCallback(params, secret) @@ -392,6 +402,7 @@ describe('signCallback / verifyCallback with client_id', () => { email: 'alice@example.com', approved: '1', new_account: '0', + email_verified: '1', } const withUndefined: CallbackParams = { ...baseParams, @@ -403,12 +414,54 @@ describe('signCallback / verifyCallback with client_id', () => { expect(verifyCallback(baseParams, ts2, sig2, secret)).toBe(true) }) + const emailVerifiedParams: CallbackParams = { + request_uri: 'urn:ietf:params:oauth:request_uri:test', + email: 'alice@example.com', + approved: '1', + new_account: '0', + email_verified: '1', + } + + it('a caller that omits email_verified is rejected rather than read as verified', () => { + // The field is required, not sentinel-defaulted like handle / + // client_id. A future sign-in flow whose author forgets to set it + // signs a different payload and fails at the trust boundary — the + // one failure mode we can afford, versus silently asserting + // verification that never happened. + const omitted = { + request_uri: emailVerifiedParams.request_uri, + email: emailVerifiedParams.email, + approved: emailVerifiedParams.approved, + new_account: emailVerifiedParams.new_account, + } as unknown as CallbackParams + const { sig, ts } = signCallback(omitted, secret) + + expect(verifyCallback(emailVerifiedParams, ts, sig, secret)).toBe(false) + }) + + it('email_verified is covered by the signature, so it cannot be flipped in transit', () => { + // pds-core records email confirmation from this field alone. If it + // were outside the HMAC, anyone holding a callback URL could + // upgrade '0' to '1' and have an unproven address marked verified. + const unverified: CallbackParams = { + ...emailVerifiedParams, + email_verified: '0', + } + const { sig, ts } = signCallback(unverified, secret) + + expect(verifyCallback(unverified, ts, sig, secret)).toBe(true) + expect( + verifyCallback({ ...unverified, email_verified: '1' }, ts, sig, secret), + ).toBe(false) + }) + it('a sig produced WITH a client_id does not verify WITHOUT one', () => { const withClient: CallbackParams = { request_uri: 'urn:ietf:params:oauth:request_uri:test', email: 'alice@example.com', approved: '1', new_account: '0', + email_verified: '1', client_id: 'https://demo.example.com/client-metadata.json', } const { sig, ts } = signCallback(withClient, secret) @@ -417,6 +470,7 @@ describe('signCallback / verifyCallback with client_id', () => { email: withClient.email, approved: withClient.approved, new_account: withClient.new_account, + email_verified: withClient.email_verified, } expect(verifyCallback(withoutClient, ts, sig, secret)).toBe(false) }) diff --git a/packages/shared/src/crypto.ts b/packages/shared/src/crypto.ts index 722eab9a..c215a70c 100644 --- a/packages/shared/src/crypto.ts +++ b/packages/shared/src/crypto.ts @@ -75,6 +75,28 @@ export interface CallbackParams { handle?: string // only set for new account creation with chosen handle client_id?: string // OAuth client this flow belongs to; carried only so a clean-exit redirect from the catch block on /oauth/epds-callback can recover the client's redirect_uri when the upstream PAR row is gone. Signed so an attacker cannot redirect a victim's flow at a different OAuth client. epds_handle_mode?: string + /** + * '1' when the sign-in that produced this callback actually proved + * control of `email`; '0' otherwise. + * + * pds-core records email confirmation on the PDS account from this + * flag alone. It exists because only the authenticating service + * knows *how* the user authenticated: today every callback follows + * an emailed one-time code, but a future passkey or similar flow + * would legitimately send a signed callback carrying `email` merely + * to locate the account, with nobody having proved control of that + * address. Without an explicit claim, pds-core would have to infer + * verification from the mere existence of a valid signature and + * would mark such an address confirmed — asserting + * `email_verified: true` to relying parties on no evidence. + * + * Deliberately REQUIRED rather than optional: the signed payload is + * positional, so a caller that omits it produces a different + * payload and its callback is rejected as an invalid signature. A + * future flow that forgets to set it therefore fails loudly at the + * trust boundary instead of silently claiming verification. + */ + email_verified: string } /** @@ -82,10 +104,11 @@ export interface CallbackParams { * Returns the hex signature and the Unix timestamp (seconds) used. * * Payload: request_uri, email, approved, new_account, handle (empty when absent), - * client_id (empty when absent), epds_handle_mode (empty when absent), and ts, - * joined by newlines. + * client_id (empty when absent), epds_handle_mode (empty when absent), + * email_verified, and ts, joined by newlines. * A timestamp is included so signatures expire (see verifyCallback). * handle, client_id and epds_handle_mode use empty string as sentinel when absent so existing flows still produce valid signatures and so the payload shape stays stable across releases. + * email_verified has no sentinel — it is required, so a caller that omits it signs a different payload and is rejected rather than being read as verified. */ export function signCallback( params: CallbackParams, @@ -100,6 +123,7 @@ export function signCallback( params.handle ?? '', // empty string when absent params.client_id ?? '', // empty string when absent params.epds_handle_mode ?? '', // empty string when absent + params.email_verified, // required — no sentinel, see the note above ts, ].join('\n') const sig = crypto.createHmac('sha256', secret).update(payload).digest('hex') @@ -134,6 +158,7 @@ export function verifyCallback( params.handle ?? '', // empty string when absent — matches signCallback sentinel params.client_id ?? '', // empty string when absent — matches signCallback sentinel params.epds_handle_mode ?? '', // empty string when absent — matches signCallback sentinel + params.email_verified, // required — matches signCallback, no sentinel ts, ].join('\n') const expected = crypto From bd97808d41ec26a16572340b4d81a5ea2c3b120e Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 4 Aug 2026 14:55:17 +0100 Subject: [PATCH 68/84] feat(pds-core): filter the backfill by email substring; log failures as errors Three review points from PR #234. Add an optional trailing argument to the backfill that scopes the run to addresses containing it, case-insensitively -- "@gmail.com" for a domain, a full address for one account. Operators can then work through a deployment in batches or repair a single user instead of being forced to process the whole table in one go. No argument still means every account: "no filter" must never be read as "match nothing", or a mistyped invocation would silently do nothing and look like a clean run. The report names the filter, because otherwise "0 account(s)" cannot be distinguished between nothing-left-to-do and your-filter-matched-nothing. Raise the failed-confirmation log from warn to error. Swallowing the exception keeps the user signed in, but the account is left claiming an unverified address to every relying party until some later sign-in happens to succeed, and the operator has no other signal it occurred. Self-healing is not the same as harmless, so it belongs at error-level alerting. Add End users to the changeset's audience list. The claim is user-visible: once #233 lands, the PDS /account route stops telling people to verify an address they have already confirmed. Verified against a real account.sqlite built by upstream's migrator: the domain filter matched mixed-case addresses, left non-matching accounts untouched, a later unfiltered run picked up the remainder, and no email_token rows were left behind. --- .changeset/record-email-as-confirmed.md | 5 +- .../src/__tests__/email-confirmed.test.ts | 95 +++++++++++++++++-- .../pds-core/src/backfill-email-confirmed.ts | 12 ++- packages/pds-core/src/lib/email-confirmed.ts | 67 +++++++++++-- 4 files changed, 162 insertions(+), 17 deletions(-) diff --git a/.changeset/record-email-as-confirmed.md b/.changeset/record-email-as-confirmed.md index 9f65982d..4d5db424 100644 --- a/.changeset/record-email-as-confirmed.md +++ b/.changeset/record-email-as-confirmed.md @@ -4,7 +4,9 @@ Apps you sign in to are now told that your email address is confirmed. -**Affects:** Client app developers, Operators +**Affects:** End users, Client app developers, Operators + +**End users:** apps you sign in to no longer ask you to verify an address you have already confirmed with an emailed code. **Client app developers:** the `email_verified` claim is now `true` for accounts that signed in through the emailed-code flow, instead of always `false`. If your app gated any behaviour on `email_verified` being true, that gate will now open for those accounts. @@ -14,6 +16,7 @@ New accounts are recorded as confirmed at sign-up, and existing accounts are rep - `pnpm --filter @certified-app/pds-core backfill:email-confirmed --dry-run` reports how many accounts would change. - Dropping `--dry-run` performs the update. It is idempotent, so re-running is safe. +- A trailing argument scopes the run to addresses containing it, case-insensitively — `... backfill:email-confirmed @gmail.com` or `... backfill:email-confirmed my.account@yahoo.com` — so you can work through a deployment in batches or fix a single account. With no argument every account is considered. - Run it with the same environment as the server, so it targets that deployment's account database. - Accounts that cannot be confirmed are listed by DID at the end and the command exits non-zero, so a scripted run does not report success after partial failure. - It is deliberately not automatic: it marks every account that has an email address but no confirmation timestamp, so if you provisioned any accounts outside the normal sign-in flow, their addresses would be marked confirmed without having been verified. Check the dry-run count before committing. diff --git a/packages/pds-core/src/__tests__/email-confirmed.test.ts b/packages/pds-core/src/__tests__/email-confirmed.test.ts index dd598ee8..4d1e3899 100644 --- a/packages/pds-core/src/__tests__/email-confirmed.test.ts +++ b/packages/pds-core/src/__tests__/email-confirmed.test.ts @@ -5,8 +5,10 @@ import { confirmAccountEmail, formatBackfillReport, markEmailConfirmed, + matchesEmailFilter, needsEmailConfirmation, parseDryRun, + parseEmailFilter, type EmailConfirmingAccountManager, } from '../lib/email-confirmed.js' @@ -83,17 +85,17 @@ describe('confirmAccountEmail', () => { describe('markEmailConfirmed', () => { it('confirms the account and stays silent on success', async () => { const { accountManager, calls } = makeAccountManager() - const logger = { warn: vi.fn() } + const logger = { error: vi.fn() } await markEmailConfirmed({ accountManager, did: DID, logger }) expect(calls.redeemed).toEqual([{ did: DID, token: 'TOKEN-1' }]) - expect(logger.warn).not.toHaveBeenCalled() + expect(logger.error).not.toHaveBeenCalled() }) - it('swallows failures so sign-in is never blocked, logging at warn', async () => { + it('swallows failures so sign-in is never blocked, logging at error', async () => { const { accountManager } = makeAccountManager({ failOn: 'confirmEmail' }) - const logger = { warn: vi.fn() } + const logger = { error: vi.fn() } // Must resolve, not reject — the user has already proven ownership // of the address, so bookkeeping failure must not fail their sign-in. @@ -101,8 +103,8 @@ describe('markEmailConfirmed', () => { markEmailConfirmed({ accountManager, did: DID, logger }), ).resolves.toBeUndefined() - expect(logger.warn).toHaveBeenCalledTimes(1) - const [context] = logger.warn.mock.calls[0] + expect(logger.error).toHaveBeenCalledTimes(1) + const [context] = logger.error.mock.calls[0] expect(context).toMatchObject({ did: DID }) expect((context as { err: Error }).err.message).toBe('token rejected') }) @@ -168,6 +170,22 @@ describe('backfillEmailConfirmedAt', () => { ]) }) + it('confirms only accounts matching the email filter', async () => { + const { accountManager, calls } = makeAccountManager() + + const result = await backfillEmailConfirmedAt({ + accountManager, + accounts: [ + { did: 'did:plc:aaa', email: 'a@gmail.com', emailConfirmedAt: null }, + { did: 'did:plc:bbb', email: 'b@yahoo.com', emailConfirmedAt: null }, + ], + emailFilter: '@gmail.com', + }) + + expect(result).toMatchObject({ candidates: 1, updated: 1, failed: 0 }) + expect(calls.redeemed.map((r) => r.did)).toEqual(['did:plc:aaa']) + }) + it('reports candidates without writing when dryRun is set', async () => { const { accountManager, calls } = makeAccountManager() @@ -225,6 +243,56 @@ describe('backfillEmailConfirmedAt', () => { }) }) +describe('matchesEmailFilter', () => { + it('matches everything when no filter is given', () => { + // "no filter" must mean all accounts, never none — otherwise a + // mistyped invocation would do nothing and look like a clean run. + expect(matchesEmailFilter('a@x.test', undefined)).toBe(true) + expect(matchesEmailFilter('a@x.test', '')).toBe(true) + }) + + it('matches on a domain substring, case-insensitively', () => { + expect(matchesEmailFilter('someone@gmail.com', '@gmail.com')).toBe(true) + expect(matchesEmailFilter('Someone@GMAIL.com', '@gmail.com')).toBe(true) + expect(matchesEmailFilter('someone@yahoo.com', '@gmail.com')).toBe(false) + }) + + it('matches a single full address', () => { + expect( + matchesEmailFilter('my.account@yahoo.com', 'my.account@yahoo.com'), + ).toBe(true) + expect( + matchesEmailFilter('other.account@yahoo.com', 'my.account@yahoo.com'), + ).toBe(false) + }) + + it('never matches an account with no address once a filter is set', () => { + expect(matchesEmailFilter('', '@gmail.com')).toBe(false) + expect(matchesEmailFilter(null, '@gmail.com')).toBe(false) + }) +}) + +describe('parseEmailFilter', () => { + it('is undefined when only flags are passed', () => { + expect(parseEmailFilter(['node', 'backfill.ts'])).toBeUndefined() + expect( + parseEmailFilter(['node', 'backfill.ts', '--dry-run']), + ).toBeUndefined() + }) + + it('takes the first non-flag argument, in any position', () => { + expect(parseEmailFilter(['node', 'backfill.ts', '@gmail.com'])).toBe( + '@gmail.com', + ) + expect( + parseEmailFilter(['node', 'backfill.ts', '--dry-run', '@gmail.com']), + ).toBe('@gmail.com') + expect( + parseEmailFilter(['node', 'backfill.ts', '@gmail.com', '--dry-run']), + ).toBe('@gmail.com') + }) +}) + describe('parseDryRun', () => { it('is off unless --dry-run is passed', () => { expect(parseDryRun(['node', 'backfill.ts'])).toBe(false) @@ -270,6 +338,21 @@ describe('formatBackfillReport', () => { ).toContain('0 account(s)') }) + it('names the filter so a scoped run is distinguishable from a full one', () => { + // "0 account(s)" is ambiguous otherwise: nothing left to do, or a + // filter that matched nothing? + expect( + formatBackfillReport( + { ...base, candidates: 0, updated: 0, dryRun: true }, + LOCATION, + '@gmail.com', + ), + ).toContain('matching "@gmail.com"') + expect( + formatBackfillReport({ ...base, candidates: 2, updated: 2 }, LOCATION), + ).not.toContain('matching') + }) + it('names the accounts that failed so the operator can chase them', () => { const line = formatBackfillReport( { diff --git a/packages/pds-core/src/backfill-email-confirmed.ts b/packages/pds-core/src/backfill-email-confirmed.ts index 7b1eb532..207599a9 100644 --- a/packages/pds-core/src/backfill-email-confirmed.ts +++ b/packages/pds-core/src/backfill-email-confirmed.ts @@ -8,6 +8,13 @@ * pnpm --filter @certified-app/pds-core backfill:email-confirmed --dry-run * pnpm --filter @certified-app/pds-core backfill:email-confirmed * + * A trailing argument scopes the run to addresses containing it + * (case-insensitive), so an operator can work through a deployment in + * batches or fix up a single account: + * + * pnpm --filter @certified-app/pds-core backfill:email-confirmed --dry-run @gmail.com + * pnpm --filter @certified-app/pds-core backfill:email-confirmed my.account@yahoo.com + * * Not wired into startup on purpose — see the rationale on * `backfillEmailConfirmedAt`. * @@ -29,11 +36,13 @@ import { backfillEmailConfirmedAt, formatBackfillReport, parseDryRun, + parseEmailFilter, type BackfillCandidate, } from './lib/email-confirmed.js' async function main(): Promise { const dryRun = parseDryRun(process.argv) + const emailFilter = parseEmailFilter(process.argv) const env = readEnv() const cfg = envToCfg(env) @@ -49,10 +58,11 @@ async function main(): Promise { const result = await backfillEmailConfirmedAt({ accountManager: pds.ctx.accountManager, accounts, + emailFilter, dryRun, }) process.stdout.write( - formatBackfillReport(result, cfg.db.accountDbLoc) + '\n', + formatBackfillReport(result, cfg.db.accountDbLoc, emailFilter) + '\n', ) // Surface partial failure to the shell so a scripted run does not // report success when some accounts could not be confirmed. diff --git a/packages/pds-core/src/lib/email-confirmed.ts b/packages/pds-core/src/lib/email-confirmed.ts index 0b0b056b..74fe7124 100644 --- a/packages/pds-core/src/lib/email-confirmed.ts +++ b/packages/pds-core/src/lib/email-confirmed.ts @@ -74,20 +74,26 @@ export async function confirmAccountEmail( * contract. If it fails (SQLite busy, disk error) the user has still * proven ownership of the address, so failing their sign-in over it * would be a strictly worse outcome than a stale - * `email_verified: false` claim. The error is logged at `warn` so it - * stays visible without tripping error-level alerting, and the next - * sign-in retries — callers skip already-confirmed accounts, not - * already-*seen* ones, so a failure here is self-healing. + * `email_verified: false` claim. The next sign-in retries — callers + * skip already-confirmed accounts, not already-*seen* ones, so a + * failure here is self-healing. + * + * Logged at `error`, not `warn`: swallowing the exception keeps the + * user signed in, but the account is left claiming an unverified + * address to every relying party until some later sign-in happens to + * succeed — and the operator has no other signal that it happened. + * Self-healing is not the same as harmless, so this should reach + * error-level alerting rather than sit quietly in the logs. */ export async function markEmailConfirmed(opts: { accountManager: EmailConfirmingAccountManager did: string - logger: Pick + logger: Pick }): Promise { try { await confirmAccountEmail(opts.accountManager, opts.did) } catch (err) { - opts.logger.warn( + opts.logger.error( { err, did: opts.did }, 'Failed to record email confirmation after OTP-verified sign-in', ) @@ -112,6 +118,34 @@ export function needsEmailConfirmation( return !account.emailConfirmedAt } +/** + * Case-insensitive substring match on the address, so an operator can + * scope a backfill run to a domain (`@gmail.com`) or a single account + * (`my.account@yahoo.com`) instead of the whole table. + * + * An empty or absent filter matches everything — "no filter given" + * must mean "all accounts", never "none", or a mistyped invocation + * would silently do nothing and look like a clean run. + */ +export function matchesEmailFilter( + email: string | null | undefined, + filter?: string, +): boolean { + if (!filter) return true + if (!email) return false + return email.toLowerCase().includes(filter.toLowerCase()) +} + +/** + * The first non-flag argument, treated as the address filter. + * Returns undefined when the operator passed only flags. + */ +export function parseEmailFilter(argv: readonly string[]): string | undefined { + // argv[0] is the node binary and argv[1] the script path; anything + // further that is not a flag is the filter. + return argv.slice(2).find((a) => !a.startsWith('-')) +} + export interface BackfillResult { /** Accounts found needing confirmation. */ candidates: number @@ -151,10 +185,19 @@ export async function backfillEmailConfirmedAt(opts: { accountManager: EmailConfirmingAccountManager /** Every account to consider, typically the full account list. */ accounts: readonly BackfillCandidate[] + /** + * Optional case-insensitive substring match on the address, so a run + * can be scoped to a domain or a single account. Absent means all. + */ + emailFilter?: string dryRun?: boolean }): Promise { const dryRun = opts.dryRun ?? false - const candidates = opts.accounts.filter((a) => needsEmailConfirmation(a)) + const candidates = opts.accounts.filter( + (a) => + needsEmailConfirmation(a) && + matchesEmailFilter(a.email, opts.emailFilter), + ) if (dryRun || candidates.length === 0) { return { @@ -204,11 +247,17 @@ export function parseDryRun(argv: readonly string[]): boolean { export function formatBackfillReport( result: BackfillResult, location: string, + emailFilter?: string, ): string { + // Name the filter in the output: a scoped run and an + // everything-matched run otherwise look identical, and an operator + // reading "0 account(s)" needs to know whether that means "none left + // to do" or "your filter matched nothing". + const scope = emailFilter ? `${location} matching "${emailFilter}"` : location if (result.dryRun) { - return `[dry run] ${result.candidates} account(s) in ${location} would be marked email-confirmed.` + return `[dry run] ${result.candidates} account(s) in ${scope} would be marked email-confirmed.` } - const base = `Marked ${result.updated} account(s) in ${location} as email-confirmed.` + const base = `Marked ${result.updated} account(s) in ${scope} as email-confirmed.` if (result.failed === 0) return base const detail = result.failures.map((f) => ` ${f.did}: ${f.error}`).join('\n') return `${base}\n${result.failed} account(s) could not be confirmed:\n${detail}` From ddea22e3ce2111f53b83b152fca376088a37fe1f Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 4 Aug 2026 14:58:16 +0100 Subject: [PATCH 69/84] fix(pds-core): run backfill from dist; diagnose missing email_verified Two suppressed Copilot findings, both real. The backfill script ran via `tsx src/backfill-email-confirmed.ts`, but the runtime image copies only `dist/` and tsx is a devDependency (Dockerfile.pds:36). The documented command would therefore fail in exactly the deployed environment an operator needs it in. Point the script at the compiled entrypoint, which tsconfig already emits since it includes all of src/, and keep the tsx form as `backfill:email-confirmed:dev` for local use. Verified `node dist/backfill-email-confirmed.js` loads and reaches config validation. A callback missing `email_verified` failed as "Invalid callback signature", since the field is inside the HMAC. Accurate but misleading: during a mixed-version rollout the cause is an old auth-service, not a secret mismatch, and that error sends an operator hunting in the wrong place. Reject a missing or non-'0'/'1' value up front with an explicit 400 naming the parameter, mirroring the existing expired-vs-invalid split that exists for the same diagnosability reason. --- packages/pds-core/package.json | 3 ++- packages/pds-core/src/index.ts | 20 ++++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/packages/pds-core/package.json b/packages/pds-core/package.json index 958c5e59..afe6e420 100644 --- a/packages/pds-core/package.json +++ b/packages/pds-core/package.json @@ -11,7 +11,8 @@ "build": "tsc --build", "dev": "tsx watch src/index.ts", "start": "node dist/index.js", - "backfill:email-confirmed": "tsx src/backfill-email-confirmed.ts" + "backfill:email-confirmed": "node dist/backfill-email-confirmed.js", + "backfill:email-confirmed:dev": "tsx src/backfill-email-confirmed.ts" }, "dependencies": { "@atproto/crypto": "^0.4.5", diff --git a/packages/pds-core/src/index.ts b/packages/pds-core/src/index.ts index 0da2ce6e..6bb30e21 100644 --- a/packages/pds-core/src/index.ts +++ b/packages/pds-core/src/index.ts @@ -229,6 +229,26 @@ async function main() { const handleParam = req.query.handle as string | undefined const clientIdParam = req.query.client_id as string | undefined const handleModeParam = req.query.epds_handle_mode as string | undefined + + // Reject a missing or malformed email_verified before signature + // verification. The signature check would catch it anyway — the + // field is inside the HMAC — but it would surface as "Invalid + // callback signature", which sends an operator hunting for a + // secret mismatch during what is actually a mixed-version rollout + // (an auth-service too old to send the field). Say what is + // actually wrong instead. + if (emailVerifiedStr !== '0' && emailVerifiedStr !== '1') { + logger.warn( + { emailVerified: emailVerifiedStr }, + 'epds-callback missing or invalid email_verified — auth-service too old, or a hand-built callback', + ) + res.status(400).json({ + error: + 'Missing or invalid email_verified parameter (expected "0" or "1")', + }) + return + } + const signatureValid = verifyCallback( { request_uri: requestUri, From b97d2ff32896ab91ceca30a9f532ef00a496818c Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 4 Aug 2026 15:02:17 +0100 Subject: [PATCH 70/84] docs: move backfill procedure out of the changeset The changeset had grown to a full operator runbook: six bullets of command syntax, flag semantics, exit codes and a caveat about provisioning accounts outside the sign-in flow. None of that is release-note material, and the writing-changesets skill says so plainly -- "keep it as short as the change deserves", two to four sentences per audience, and cut anything the reader does not act on. Move the procedure to a "Backfilling Email Confirmation" section in docs/deployment.md, alongside the other operator runbooks, where it can be found by someone doing the task rather than only by someone reading the changelog for the release that introduced it. The changeset keeps one sentence per audience and points operators at the doc: 23 lines down to 13. Raised by Adam in review on PR #234. --- .changeset/record-email-as-confirmed.md | 15 ++------ docs/deployment.md | 46 +++++++++++++++++++++++++ 2 files changed, 49 insertions(+), 12 deletions(-) diff --git a/.changeset/record-email-as-confirmed.md b/.changeset/record-email-as-confirmed.md index 4d5db424..98ca1ffc 100644 --- a/.changeset/record-email-as-confirmed.md +++ b/.changeset/record-email-as-confirmed.md @@ -6,17 +6,8 @@ Apps you sign in to are now told that your email address is confirmed. **Affects:** End users, Client app developers, Operators -**End users:** apps you sign in to no longer ask you to verify an address you have already confirmed with an emailed code. +**End users:** apps no longer ask you to verify an address you have already confirmed with an emailed code. -**Client app developers:** the `email_verified` claim is now `true` for accounts that signed in through the emailed-code flow, instead of always `false`. If your app gated any behaviour on `email_verified` being true, that gate will now open for those accounts. +**Client app developers:** the `email_verified` claim is now `true` for accounts that signed in through the emailed-code flow, instead of always `false`. -**Operators:** deploy the auth service and the PDS together. The signed handover between them carries a new required field, so a sign-in served by one old and one new instance is rejected with `Invalid callback signature` until both are updated. Sign-in recovers on its own once the rollout completes; no data is affected. - -New accounts are recorded as confirmed at sign-up, and existing accounts are repaired the next time their owner signs in — so most accounts need no action. Use the backfill for accounts whose owners have not signed in since upgrading: - -- `pnpm --filter @certified-app/pds-core backfill:email-confirmed --dry-run` reports how many accounts would change. -- Dropping `--dry-run` performs the update. It is idempotent, so re-running is safe. -- A trailing argument scopes the run to addresses containing it, case-insensitively — `... backfill:email-confirmed @gmail.com` or `... backfill:email-confirmed my.account@yahoo.com` — so you can work through a deployment in batches or fix a single account. With no argument every account is considered. -- Run it with the same environment as the server, so it targets that deployment's account database. -- Accounts that cannot be confirmed are listed by DID at the end and the command exits non-zero, so a scripted run does not report success after partial failure. -- It is deliberately not automatic: it marks every account that has an email address but no confirmation timestamp, so if you provisioned any accounts outside the normal sign-in flow, their addresses would be marked confirmed without having been verified. Check the dry-run count before committing. +**Operators:** deploy the auth service and the PDS together — the signed handover between them carries a new required field, and a mixed pair rejects sign-in until both are updated. Accounts predating this release are repaired on their owner's next sign-in; to fix the rest, see "Backfilling Email Confirmation" in `docs/deployment.md`. diff --git a/docs/deployment.md b/docs/deployment.md index 42939a72..fc282b31 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -246,3 +246,49 @@ For test or development environments, you can disable the invite code requirement entirely by setting `PDS_INVITE_REQUIRED=false` on the pds-core service. This allows anyone who can reach the PDS to create accounts, so it is **not recommended for production**. + +## Backfilling Email Confirmation + +Accounts created before email confirmation was recorded at sign-up report +`email_verified: false` to relying parties. New accounts are recorded as +confirmed automatically, and existing accounts are repaired the next time +their owner signs in, so this is only needed for accounts whose owners have +not signed in since the upgrade. + +Preview first: + +```bash +pnpm --filter @certified-app/pds-core backfill:email-confirmed --dry-run +``` + +Then run it for real: + +```bash +pnpm --filter @certified-app/pds-core backfill:email-confirmed +``` + +Run it with the same environment as the PDS service, so it targets that +deployment's account database. It is idempotent — already-confirmed accounts +are skipped, so re-running is safe. + +A trailing argument scopes the run to addresses containing it +(case-insensitive), which lets you work through a large deployment in batches +or repair a single account: + +```bash +pnpm --filter @certified-app/pds-core backfill:email-confirmed @gmail.com +pnpm --filter @certified-app/pds-core backfill:email-confirmed my.account@yahoo.com +``` + +With no argument, every account is considered. + +Accounts that cannot be confirmed are listed by DID at the end and the command +exits non-zero, so a scripted run does not report success after a partial +failure. + +**This is deliberately not automatic.** It marks every account that has an +email address but no confirmation timestamp. ePDS does not block upstream's +`com.atproto.server.createAccount` XRPC route, so if you provisioned any +accounts outside the normal sign-in flow, their addresses would be marked +confirmed without having been verified. Check the dry-run count before +committing. From 0a97345e96727d8738b88fdb7ae995fe44c6582c Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 4 Aug 2026 15:09:38 +0100 Subject: [PATCH 71/84] docs(changeset): capitalise the sentence after each audience label Applies the three review suggestions verbatim. Note this now differs from the other pending changesets, which start lowercase after the bold label. Worth settling one way in the skill so the generated changelog reads consistently. --- .changeset/record-email-as-confirmed.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/record-email-as-confirmed.md b/.changeset/record-email-as-confirmed.md index 98ca1ffc..ddb9150b 100644 --- a/.changeset/record-email-as-confirmed.md +++ b/.changeset/record-email-as-confirmed.md @@ -6,8 +6,8 @@ Apps you sign in to are now told that your email address is confirmed. **Affects:** End users, Client app developers, Operators -**End users:** apps no longer ask you to verify an address you have already confirmed with an emailed code. +**End users:** Apps no longer ask you to verify an address you have already confirmed with an emailed code. -**Client app developers:** the `email_verified` claim is now `true` for accounts that signed in through the emailed-code flow, instead of always `false`. +**Client app developers:** The `email_verified` claim is now `true` for accounts that signed in through the emailed-code flow, instead of always `false`. -**Operators:** deploy the auth service and the PDS together — the signed handover between them carries a new required field, and a mixed pair rejects sign-in until both are updated. Accounts predating this release are repaired on their owner's next sign-in; to fix the rest, see "Backfilling Email Confirmation" in `docs/deployment.md`. +**Operators:** Deploy the auth service and the PDS together — the signed handover between them carries a new required field, and a mixed pair rejects sign-in until both are updated. Accounts predating this release are repaired on their owner's next sign-in; to fix the rest, see "Backfilling Email Confirmation" in `docs/deployment.md`. From 86068b59457ed362856d82463d0a734df870ac62 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 12 Aug 2026 00:34:19 +0100 Subject: [PATCH 72/84] fix(pds-core): port email confirmation to the upgraded upstream API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The atproto upgrade in #233 changed both halves of the confirmation call. `confirmEmail` takes positional arguments and now also takes the address — `confirmEmail(did, email, token)` — and `Account.sub` became `Account.did`. `createEmailToken` takes a `DidString`. Upstream now compares the supplied address against the account's current one and throws `InvalidEmail` on a mismatch. That closes the TOCTOU gap CodeRabbit raised on this PR: pass the address whose control was actually proved and the check becomes atomic and upstream-enforced, rather than something this code has to re-derive. So thread the proved address through instead of only the DID: - the callback passes the HMAC-signed `email`, not the account's stored address, so a change between signing and confirmation is rejected; - the backfill passes each candidate's scanned address, so a row whose email moved between scan and write is reported as a failure rather than confirmed on stale evidence. The test fake records the address it receives, so a future signature change breaks these tests rather than passing silently. Co-Authored-By: Claude Opus 5 (1M context) --- .../__tests__/callback-handle-mode.test.ts | 3 ++ .../src/__tests__/email-confirmed.test.ts | 36 +++++++++------- packages/pds-core/src/index.ts | 7 +++- packages/pds-core/src/lib/email-confirmed.ts | 41 +++++++++++++++---- packages/shared/src/__tests__/crypto.test.ts | 2 + 5 files changed, 67 insertions(+), 22 deletions(-) diff --git a/packages/auth-service/src/__tests__/callback-handle-mode.test.ts b/packages/auth-service/src/__tests__/callback-handle-mode.test.ts index 6d63e114..41391b27 100644 --- a/packages/auth-service/src/__tests__/callback-handle-mode.test.ts +++ b/packages/auth-service/src/__tests__/callback-handle-mode.test.ts @@ -117,6 +117,9 @@ function verifySignedCallbackUrl(url: URL): boolean { email: url.searchParams.get('email') ?? '', approved: url.searchParams.get('approved') ?? '', new_account: url.searchParams.get('new_account') ?? '', + // Required, not spread conditionally: a callback that omitted it + // must fail verification rather than be read as unverified. + email_verified: url.searchParams.get('email_verified') ?? '', ...(url.searchParams.has('handle') ? { handle: url.searchParams.get('handle') ?? '' } : {}), diff --git a/packages/pds-core/src/__tests__/email-confirmed.test.ts b/packages/pds-core/src/__tests__/email-confirmed.test.ts index 4d1e3899..af0f3e26 100644 --- a/packages/pds-core/src/__tests__/email-confirmed.test.ts +++ b/packages/pds-core/src/__tests__/email-confirmed.test.ts @@ -13,6 +13,7 @@ import { } from '../lib/email-confirmed.js' const DID = 'did:plc:7iza6de2dwap2sbkpav7c6c6' +const EMAIL = 'alice@example.test' /** * Fake AccountManager recording the mint/redeem pair. Typed against @@ -25,7 +26,7 @@ function makeAccountManager( ) { const calls: { minted: { did: string; purpose: string }[] - redeemed: { did: string; token: string }[] + redeemed: { did: string; email: string; token: string }[] } = { minted: [], redeemed: [] } let counter = 0 @@ -38,11 +39,14 @@ function makeAccountManager( calls.minted.push({ did, purpose }) return Promise.resolve(`TOKEN-${++counter}`) }, - confirmEmail: ({ did, token }) => { + confirmEmail: (did, email, token) => { if (opts.failOn === 'confirmEmail') { return Promise.reject(new Error('token rejected')) } - calls.redeemed.push({ did, token }) + // Upstream rejects a mismatch between this address and the + // account's current one; recorded so tests can assert the + // proved address is what reaches it. + calls.redeemed.push({ did, email, token }) return Promise.resolve() }, } @@ -54,12 +58,14 @@ describe('confirmAccountEmail', () => { it('redeems the token it just minted, for the same DID', async () => { const { accountManager, calls } = makeAccountManager() - await confirmAccountEmail(accountManager, DID) + await confirmAccountEmail(accountManager, DID, EMAIL) expect(calls.minted).toEqual([{ did: DID, purpose: 'confirm_email' }]) // The token must be the minted one — redeeming anything else // would leave the freshly created token row behind. - expect(calls.redeemed).toEqual([{ did: DID, token: 'TOKEN-1' }]) + expect(calls.redeemed).toEqual([ + { did: DID, email: EMAIL, token: 'TOKEN-1' }, + ]) }) it('propagates a minting failure without attempting to redeem', async () => { @@ -67,18 +73,18 @@ describe('confirmAccountEmail', () => { failOn: 'createEmailToken', }) - await expect(confirmAccountEmail(accountManager, DID)).rejects.toThrow( - 'db down', - ) + await expect( + confirmAccountEmail(accountManager, DID, EMAIL), + ).rejects.toThrow('db down') expect(calls.redeemed).toEqual([]) }) it('propagates a redemption failure', async () => { const { accountManager } = makeAccountManager({ failOn: 'confirmEmail' }) - await expect(confirmAccountEmail(accountManager, DID)).rejects.toThrow( - 'token rejected', - ) + await expect( + confirmAccountEmail(accountManager, DID, EMAIL), + ).rejects.toThrow('token rejected') }) }) @@ -87,9 +93,11 @@ describe('markEmailConfirmed', () => { const { accountManager, calls } = makeAccountManager() const logger = { error: vi.fn() } - await markEmailConfirmed({ accountManager, did: DID, logger }) + await markEmailConfirmed({ accountManager, did: DID, email: EMAIL, logger }) - expect(calls.redeemed).toEqual([{ did: DID, token: 'TOKEN-1' }]) + expect(calls.redeemed).toEqual([ + { did: DID, email: EMAIL, token: 'TOKEN-1' }, + ]) expect(logger.error).not.toHaveBeenCalled() }) @@ -100,7 +108,7 @@ describe('markEmailConfirmed', () => { // Must resolve, not reject — the user has already proven ownership // of the address, so bookkeeping failure must not fail their sign-in. await expect( - markEmailConfirmed({ accountManager, did: DID, logger }), + markEmailConfirmed({ accountManager, did: DID, email: EMAIL, logger }), ).resolves.toBeUndefined() expect(logger.error).toHaveBeenCalledTimes(1) diff --git a/packages/pds-core/src/index.ts b/packages/pds-core/src/index.ts index 6bb30e21..a101b8d2 100644 --- a/packages/pds-core/src/index.ts +++ b/packages/pds-core/src/index.ts @@ -544,7 +544,12 @@ async function main() { ) { await markEmailConfirmed({ accountManager: pds.ctx.accountManager, - did: account.sub, + did: account.did, + // The HMAC-signed address whose control was proved — not the + // account's stored address. Upstream compares the two and + // rejects a mismatch, so an address changed since the flow + // began is not recorded as confirmed on this evidence. + email, logger, }) } diff --git a/packages/pds-core/src/lib/email-confirmed.ts b/packages/pds-core/src/lib/email-confirmed.ts index 74fe7124..89612875 100644 --- a/packages/pds-core/src/lib/email-confirmed.ts +++ b/packages/pds-core/src/lib/email-confirmed.ts @@ -38,6 +38,7 @@ * extraction pattern used by the other lib/ modules. */ import type { Logger } from 'pino' +import type { DidString } from '@atproto/syntax' /** * The slice of `AccountManager` this module needs. Structurally @@ -46,8 +47,15 @@ import type { Logger } from 'pino' * honest without importing PDS internals. */ export interface EmailConfirmingAccountManager { - createEmailToken: (did: string, purpose: 'confirm_email') => Promise - confirmEmail: (opts: { did: string; token: string }) => Promise + createEmailToken: ( + did: DidString, + purpose: 'confirm_email', + ) => Promise + confirmEmail: ( + did: DidString, + email: string, + token: string, + ) => Promise } /** @@ -57,13 +65,20 @@ export interface EmailConfirmingAccountManager { * halves are public `AccountManager` operations; the token never * leaves this function and is deleted by `confirmEmail` as part of * the same transaction that records the confirmation. + * + * `email` is the address whose control was actually proved. Upstream + * compares it against the account's current address and throws + * `InvalidEmail` if they differ, so an address that changed between + * our check and this call is rejected rather than silently recorded + * as confirmed. */ export async function confirmAccountEmail( accountManager: EmailConfirmingAccountManager, - did: string, + did: DidString, + email: string, ): Promise { const token = await accountManager.createEmailToken(did, 'confirm_email') - await accountManager.confirmEmail({ did, token }) + await accountManager.confirmEmail(did, email, token) } /** @@ -87,11 +102,13 @@ export async function confirmAccountEmail( */ export async function markEmailConfirmed(opts: { accountManager: EmailConfirmingAccountManager - did: string + did: DidString + /** The address whose control this sign-in proved. */ + email: string logger: Pick }): Promise { try { - await confirmAccountEmail(opts.accountManager, opts.did) + await confirmAccountEmail(opts.accountManager, opts.did, opts.email) } catch (err) { opts.logger.error( { err, did: opts.did }, @@ -213,7 +230,17 @@ export async function backfillEmailConfirmedAt(opts: { const failures: { did: string; error: string }[] = [] for (const account of candidates) { try { - await confirmAccountEmail(opts.accountManager, account.did) + // needsEmailConfirmation() already excluded blank addresses, so + // this only narrows the type. Upstream compares the address we + // pass against the account's current one and rejects a mismatch, + // so a row whose email changed between the scan above and this + // call is reported as a failure rather than confirmed. + if (!account.email) continue + await confirmAccountEmail( + opts.accountManager, + account.did as DidString, + account.email, + ) updated++ } catch (err) { // One unconfirmable account must not strand the rest of the diff --git a/packages/shared/src/__tests__/crypto.test.ts b/packages/shared/src/__tests__/crypto.test.ts index 493ba883..a2e288e8 100644 --- a/packages/shared/src/__tests__/crypto.test.ts +++ b/packages/shared/src/__tests__/crypto.test.ts @@ -137,6 +137,8 @@ function makeCallbackParams(overrides: Partial = {}) { email: 'alice@example.com', approved: '1', new_account: '1', + // Required, so it has no sentinel — see CallbackParams.email_verified. + email_verified: '1', ...overrides, } satisfies CallbackParams } From 9d0d0fb893f6c9c32603608490b2e8b7fe879c0a Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 12 Aug 2026 09:51:41 +0100 Subject: [PATCH 73/84] fix(pds-core): log backfill failures structurally; document the DB exception MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `String(err)` collapsed the error to its message, discarding the type and stack — exactly the context an operator needs when a one-off backfill dies. Use the repo's `createLogger` so pino serialises `err` in full. `process.exit(1)` is unchanged, so scripted runs still see the failure. The account enumeration this script does was defended in a code comment, but AGENTS.md states the account-manager boundary as an unqualified rule. Record the exception where the rule lives instead: `AccountManager` offers only `getAccount`, `getAccounts(dids)` and `getAccountByEmail`, all requiring the identifier up front, so there is no supported way to list every account. Noted as something to re-check on each `@atproto/pds` upgrade. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 12 ++++++++++++ packages/pds-core/src/backfill-email-confirmed.ts | 15 ++++++++------- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index c7d73c39..995e4118 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -383,6 +383,18 @@ import { AuthServiceContext } from './context.js' schema and crashes. Leave unused tables/columns in place; they're harmless. - Do **not** directly read or modify `@atproto/pds` database tables — use `pds.ctx.accountManager.*` methods. + - **One documented exception:** enumerating every account. + `AccountManager` exposes `getAccount`, `getAccounts(dids)` and + `getAccountByEmail`, all of which require knowing the identifier up + front, so there is no supported "list all accounts" call. A backfill + cannot know the DIDs in advance, so + `packages/pds-core/src/backfill-email-confirmed.ts` reads + `accountManager.db.db.selectFrom('account')` directly. This is confined + to that operator-invoked script — pds-core's request path must never do + it, and the writes it performs still go through + `createEmailToken` / `confirmEmail`. Re-check this on each + `@atproto/pds` upgrade: if upstream adds an enumeration API, switch to + it and delete this exception. ## Security diff --git a/packages/pds-core/src/backfill-email-confirmed.ts b/packages/pds-core/src/backfill-email-confirmed.ts index 207599a9..3897a1fc 100644 --- a/packages/pds-core/src/backfill-email-confirmed.ts +++ b/packages/pds-core/src/backfill-email-confirmed.ts @@ -24,14 +24,13 @@ * binding a port; `start()` is never called, so nothing is served. * * Confirmation itself goes through `accountManager.createEmailToken` / - * `confirmEmail`, per AGENTS.md's account-manager boundary. Listing - * the accounts to consider is the one read this script does directly: - * `AccountManager` exposes `getAccounts(dids)` but no "every account" - * query, and a backfill cannot know the DIDs in advance. The read is - * confined to this operator-invoked script — pds-core's request path - * never does it. + * `confirmEmail`, per AGENTS.md's account-manager boundary. Enumerating + * the accounts to consider is the one read this script does directly, + * under the documented exception to that rule — see "Database" in + * AGENTS.md. */ import { PDS, envToCfg, envToSecrets, readEnv } from '@atproto/pds' +import { createLogger } from '@certified-app/shared' import { backfillEmailConfirmedAt, formatBackfillReport, @@ -40,6 +39,8 @@ import { type BackfillCandidate, } from './lib/email-confirmed.js' +const logger = createLogger('pds-core:backfill-email-confirmed') + async function main(): Promise { const dryRun = parseDryRun(process.argv) const emailFilter = parseEmailFilter(process.argv) @@ -73,6 +74,6 @@ async function main(): Promise { } main().catch((err: unknown) => { - process.stderr.write(`Backfill failed: ${String(err)}\n`) + logger.error({ err }, 'Email-confirmation backfill failed') process.exit(1) }) From 31777adecd8c9417015b1311c9195341b9649131 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 12 Aug 2026 12:44:27 +0100 Subject: [PATCH 74/84] docs: correct the rollout and self-healing claims around email_verified MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three statements aimed at operators were inaccurate, all in ways that would mislead during the coordinated deploy this change requires. The signCallback docstring claimed the sentinel scheme keeps the payload "stable across releases". Adding required email_verified deliberately breaks that: the payload is positional, so signer and verifier must ship together. Say so, rather than implying releases are interchangeable. The changeset described a mixed pair as simply rejecting sign-in. pds-core now returns an explicit 400 naming the missing parameter, which is the difference between an operator recognising a mixed-version rollout and hunting a phantom secret mismatch. The changeset, docs/deployment.md and markEmailConfirmed's docstring all said existing accounts are repaired on their owner's next sign-in. Repair requires email_verified='1', so a sign-in by any other means leaves the account unconfirmed — which is exactly when the backfill is still needed. Also refreshed the PR-facing test count and the account-enumeration note now that the exception is recorded in AGENTS.md. Co-Authored-By: Claude Opus 5 (1M context) --- .changeset/record-email-as-confirmed.md | 2 +- docs/deployment.md | 7 +++++-- packages/pds-core/src/lib/email-confirmed.ts | 9 ++++++--- packages/shared/src/crypto.ts | 6 +++++- 4 files changed, 17 insertions(+), 7 deletions(-) diff --git a/.changeset/record-email-as-confirmed.md b/.changeset/record-email-as-confirmed.md index ddb9150b..efc2852d 100644 --- a/.changeset/record-email-as-confirmed.md +++ b/.changeset/record-email-as-confirmed.md @@ -10,4 +10,4 @@ Apps you sign in to are now told that your email address is confirmed. **Client app developers:** The `email_verified` claim is now `true` for accounts that signed in through the emailed-code flow, instead of always `false`. -**Operators:** Deploy the auth service and the PDS together — the signed handover between them carries a new required field, and a mixed pair rejects sign-in until both are updated. Accounts predating this release are repaired on their owner's next sign-in; to fix the rest, see "Backfilling Email Confirmation" in `docs/deployment.md`. +**Operators:** Deploy the auth service and the PDS together — the signed handover between them carries a new required field, and a mixed pair rejects sign-in until both are updated. An older auth service is rejected with an explicit `Missing or invalid email_verified parameter` rather than a generic signature error, so a mixed-version rollout is recognisable in the logs. Accounts predating this release are repaired the next time their owner signs in **through the emailed-code flow**; a sign-in that does not prove control of the address leaves them unconfirmed. To fix the rest, see "Backfilling Email Confirmation" in `docs/deployment.md`. diff --git a/docs/deployment.md b/docs/deployment.md index fc282b31..d71afc85 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -252,8 +252,11 @@ is **not recommended for production**. Accounts created before email confirmation was recorded at sign-up report `email_verified: false` to relying parties. New accounts are recorded as confirmed automatically, and existing accounts are repaired the next time -their owner signs in, so this is only needed for accounts whose owners have -not signed in since the upgrade. +their owner signs in **through the emailed-code flow** — repair requires a +sign-in that proves control of the address, so an authentication method that +does not (a passkey, say) leaves the account unconfirmed. This backfill is +therefore needed for accounts whose owners have not signed in since the +upgrade, and for any that sign in only by other means. Preview first: diff --git a/packages/pds-core/src/lib/email-confirmed.ts b/packages/pds-core/src/lib/email-confirmed.ts index 89612875..986a96a9 100644 --- a/packages/pds-core/src/lib/email-confirmed.ts +++ b/packages/pds-core/src/lib/email-confirmed.ts @@ -89,9 +89,12 @@ export async function confirmAccountEmail( * contract. If it fails (SQLite busy, disk error) the user has still * proven ownership of the address, so failing their sign-in over it * would be a strictly worse outcome than a stale - * `email_verified: false` claim. The next sign-in retries — callers - * skip already-confirmed accounts, not already-*seen* ones, so a - * failure here is self-healing. + * `email_verified: false` claim. The next sign-in that proves control + * of the address retries — callers skip already-confirmed accounts, + * not already-*seen* ones. Self-healing therefore depends on the owner + * signing in through the emailed-code flow again; an account whose + * owner only ever uses another method stays unconfirmed until the + * operator backfill runs. * * Logged at `error`, not `warn`: swallowing the exception keeps the * user signed in, but the account is left claiming an unverified diff --git a/packages/shared/src/crypto.ts b/packages/shared/src/crypto.ts index c215a70c..d9b466ea 100644 --- a/packages/shared/src/crypto.ts +++ b/packages/shared/src/crypto.ts @@ -107,8 +107,12 @@ export interface CallbackParams { * client_id (empty when absent), epds_handle_mode (empty when absent), * email_verified, and ts, joined by newlines. * A timestamp is included so signatures expire (see verifyCallback). - * handle, client_id and epds_handle_mode use empty string as sentinel when absent so existing flows still produce valid signatures and so the payload shape stays stable across releases. + * handle, client_id and epds_handle_mode use empty string as sentinel when absent, so a flow that does not set them still produces a valid signature. * email_verified has no sentinel — it is required, so a caller that omits it signs a different payload and is rejected rather than being read as verified. + * Adding a field to this payload is a breaking change for the handover: both + * signer and verifier must be deployed together, since either side alone + * computes a different HMAC. See the rollout note in the changeset that + * introduced email_verified. */ export function signCallback( params: CallbackParams, From f140e9ebee21869898c3ac332f3751c3a0aa8a9c Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 12 Aug 2026 16:09:33 +0100 Subject: [PATCH 75/84] docs: catalogue the backfill's direct account-table read as white-boxing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The exception was recorded only in AGENTS.md, but docs/design/pds-white-boxing.md is the catalogue for exactly this — reliance on internal, non-exported or undocumented upstream behaviour — and it already carries the instruction to check the list on every upstream bump. Recording a separate "re-check on each @atproto/pds upgrade" note in AGENTS.md duplicated a review trigger that already existed, in a place nobody consults during an upgrade. Add it as item 19 under High Risk, next to item 6 (the accountManager methods it bypasses), with the reason no supported alternative exists, the scope of the exposure, and what an upstream rename would break. AGENTS.md now states the rule and points at the catalogue rather than restating the rationale. Also correct item 2 while here: it claimed createAccount returns an Account with a `.sub` property. That broke in the 0.5.23 upgrade — the property is now `.did`, and every call site was updated. A catalogue of breakage risks is worth more if it records the breakages that already happened. Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 20 ++++----- docs/design/pds-white-boxing.md | 44 ++++++++++++++++++- .../pds-core/src/backfill-email-confirmed.ts | 3 +- 3 files changed, 53 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 995e4118..9e7b83c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -383,18 +383,14 @@ import { AuthServiceContext } from './context.js' schema and crashes. Leave unused tables/columns in place; they're harmless. - Do **not** directly read or modify `@atproto/pds` database tables — use `pds.ctx.accountManager.*` methods. - - **One documented exception:** enumerating every account. - `AccountManager` exposes `getAccount`, `getAccounts(dids)` and - `getAccountByEmail`, all of which require knowing the identifier up - front, so there is no supported "list all accounts" call. A backfill - cannot know the DIDs in advance, so - `packages/pds-core/src/backfill-email-confirmed.ts` reads - `accountManager.db.db.selectFrom('account')` directly. This is confined - to that operator-invoked script — pds-core's request path must never do - it, and the writes it performs still go through - `createEmailToken` / `confirmEmail`. Re-check this on each - `@atproto/pds` upgrade: if upstream adds an enumeration API, switch to - it and delete this exception. + - **One documented exception:** enumerating every account in + `packages/pds-core/src/backfill-email-confirmed.ts`, because + `AccountManager` has no "list all accounts" call. Confined to that + operator-invoked script; the request path must never do it. See item 19 + of [`docs/design/pds-white-boxing.md`](docs/design/pds-white-boxing.md) + for the rationale and the breakage scenario — that document is the + catalogue of upstream-internal dependencies, and is the list to check + on every `@atproto/pds` bump. ## Security diff --git a/docs/design/pds-white-boxing.md b/docs/design/pds-white-boxing.md index 9fb8dcaa..71e8f4f2 100644 --- a/docs/design/pds-white-boxing.md +++ b/docs/design/pds-white-boxing.md @@ -57,7 +57,10 @@ The call assumes: - A real password string is **required** — passing `undefined` skips `registerAccount()` internally, leaving the `account` table empty and breaking `upsertDeviceAccount()` FK constraints -- Returns an `Account` object with a `.sub` property (the DID) +- Returns an `Account` object carrying the DID. **This has already broken + once:** the property was `.sub` up to `@atproto/pds` 0.4.x and is `.did` + as of 0.5.23. Call sites were updated in the upgrade; a future rename + would break them again. The `Account` type is not exported, so the return value is typed `any`. @@ -137,6 +140,45 @@ Methods accessed on the PDS-level account manager: `provider.accountManager` (OAuth-provider-level, manages OAuth sessions). The code assumes these are kept in sync by the upstream PDS. +### 19. Direct `account` table read in the email-confirmation backfill + +**File:** `packages/pds-core/src/backfill-email-confirmed.ts` + +```ts +const accounts: BackfillCandidate[] = await pds.ctx.accountManager.db.db + .selectFrom('account') + .select(['did', 'email', 'emailConfirmedAt']) + .execute() +``` + +This reaches past the `accountManager` methods catalogued in item 6 and +queries upstream's SQLite `account` table directly, via the nested +`.db.db` Kysely instance. It is the only place in the codebase that does +so, and it is the documented exception to AGENTS.md's "do not directly +read or modify `@atproto/pds` database tables" rule. + +**Why there is no supported alternative:** as of `@atproto/pds` 0.5.23, +`AccountManager` exposes `getAccount(handleOrDid)`, +`getAccounts(dids)` and `getAccountByEmail(email)` — every one of which +requires knowing the identifier up front. There is no "list all accounts" +query, and a backfill cannot know the DIDs in advance. Adding a wrapper +helper would relocate the same raw query rather than remove it. + +**Scope of the exposure:** confined to an operator-invoked one-off script. +pds-core's request path never does this, and the _writes_ still go through +the public `createEmailToken` / `confirmEmail` pair (see item 6). + +**Breakage scenario:** upstream renames the `account` table or the `did` / +`email` / `emailConfirmedAt` columns, or restructures `AccountDb` so +`accountManager.db.db` is no longer a Kysely instance. The backfill script +fails — loudly, at run time, in an operator's terminal rather than in a +user-facing path. Lowest-consequence failure of anything in this document, +but it fails with no type-checking safety net, since `.db.db` is untyped +at this depth. + +**If upstream ever adds an enumeration API**, switch to it and delete both +this entry and the exception in AGENTS.md's "Database" section. + ### 7. `provider.deviceManager.load()` **File:** `packages/pds-core/src/index.ts` diff --git a/packages/pds-core/src/backfill-email-confirmed.ts b/packages/pds-core/src/backfill-email-confirmed.ts index 3897a1fc..1defcee2 100644 --- a/packages/pds-core/src/backfill-email-confirmed.ts +++ b/packages/pds-core/src/backfill-email-confirmed.ts @@ -27,7 +27,8 @@ * `confirmEmail`, per AGENTS.md's account-manager boundary. Enumerating * the accounts to consider is the one read this script does directly, * under the documented exception to that rule — see "Database" in - * AGENTS.md. + * AGENTS.md, and item 19 of docs/design/pds-white-boxing.md for what + * breaks it on an upstream bump. */ import { PDS, envToCfg, envToSecrets, readEnv } from '@atproto/pds' import { createLogger } from '@certified-app/shared' From fa83e668e3123475f187cd38ec4877895a7004ce Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:51:11 +0000 Subject: [PATCH 76/84] chore: release --- .changeset/announce-auth-errors.md | 9 - .changeset/atproto-upstream-upgrade.md | 15 -- .changeset/charset-filter-on-otp-forms.md | 9 - .changeset/clear-otp-boxes-on-resend.md | 9 - .changeset/email-autocomplete-hints.md | 9 - ...allback-app-name-on-malformed-client-id.md | 9 - .changeset/handle-mode-through-approval.md | 9 - .changeset/handle-not-available-message.md | 9 - .changeset/human-sign-in-error-copy.md | 9 - .../keyboard-focus-visible-on-buttons.md | 9 - .changeset/muted-text-contrast-on-sign-in.md | 11 -- .changeset/record-email-as-confirmed.md | 13 -- .changeset/require-secret-env-vars.md | 11 -- .changeset/resend-log-message-context.md | 9 - .changeset/send-a-new-code-label.md | 9 - .changeset/sign-in-button-readiness.md | 9 - .../sign-in-errors-at-point-of-failure.md | 11 -- .changeset/suggest-mistyped-email-domains.md | 9 - .changeset/unify-link-affordances.md | 11 -- .changeset/use-different-email-clears-form.md | 9 - CHANGELOG.md | 174 ++++++++++++++++++ package.json | 2 +- 22 files changed, 175 insertions(+), 199 deletions(-) delete mode 100644 .changeset/announce-auth-errors.md delete mode 100644 .changeset/atproto-upstream-upgrade.md delete mode 100644 .changeset/charset-filter-on-otp-forms.md delete mode 100644 .changeset/clear-otp-boxes-on-resend.md delete mode 100644 .changeset/email-autocomplete-hints.md delete mode 100644 .changeset/fallback-app-name-on-malformed-client-id.md delete mode 100644 .changeset/handle-mode-through-approval.md delete mode 100644 .changeset/handle-not-available-message.md delete mode 100644 .changeset/human-sign-in-error-copy.md delete mode 100644 .changeset/keyboard-focus-visible-on-buttons.md delete mode 100644 .changeset/muted-text-contrast-on-sign-in.md delete mode 100644 .changeset/record-email-as-confirmed.md delete mode 100644 .changeset/require-secret-env-vars.md delete mode 100644 .changeset/resend-log-message-context.md delete mode 100644 .changeset/send-a-new-code-label.md delete mode 100644 .changeset/sign-in-button-readiness.md delete mode 100644 .changeset/sign-in-errors-at-point-of-failure.md delete mode 100644 .changeset/suggest-mistyped-email-domains.md delete mode 100644 .changeset/unify-link-affordances.md delete mode 100644 .changeset/use-different-email-clears-form.md diff --git a/.changeset/announce-auth-errors.md b/.changeset/announce-auth-errors.md deleted file mode 100644 index b499d6c2..00000000 --- a/.changeset/announce-auth-errors.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'ePDS': patch ---- - -Sign-in, recovery and handle-choice feedback is now announced automatically by screen readers. - -**Affects:** End users - -**End users:** authentication errors now use standard alert and live-region semantics across sign-in, recovery, handle selection, and the demo client, so screen-reader users hear failures without having to search the page for changed text. Repeated failures — such as entering a second incorrect one-time code — announce each time rather than falling silent, and handle availability ("Available!" / "Already taken.") is now announced as you type. diff --git a/.changeset/atproto-upstream-upgrade.md b/.changeset/atproto-upstream-upgrade.md deleted file mode 100644 index ea5a5ea9..00000000 --- a/.changeset/atproto-upstream-upgrade.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'ePDS': minor ---- - -Signing in and managing your account now run on the latest AT Protocol server, which requires operators to upgrade to Node.js 22.19 or newer. - -**Affects:** End users, Operators - -**End users:** the account and sign-in screens now come from a newer version of the underlying AT Protocol software, which adds changing your handle and deactivating or deleting your account through the account-management interface. The passwordless email-code sign-in and email verification you already use through ePDS are unchanged. - -**Operators:** - -- ePDS now requires **Node.js 22.19.0 or newer** (previously 20). Deployments pinned to Node 20 must upgrade; the Docker images already use `node:22-alpine`. `engines.node` is now `>=22.19.0` (the floor is set by a transitive `undici@8` runtime dependency, which fails on Node 22.0–22.18). -- The bundled AT Protocol packages moved to their latest releases: `@atproto/pds` 0.5.23, `@atproto/oauth-provider` 0.21.1, `@atproto/oauth-provider-ui` 0.8.9. -- No configuration changes are required, but the native `better-sqlite3` module must be built for the Node 22 ABI — a clean `pnpm install` on Node 22 handles this; an in-place Node version switch needs `pnpm rebuild better-sqlite3`. diff --git a/.changeset/charset-filter-on-otp-forms.md b/.changeset/charset-filter-on-otp-forms.md deleted file mode 100644 index ca87a8dd..00000000 --- a/.changeset/charset-filter-on-otp-forms.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'ePDS': patch ---- - -Sign-in code boxes now ignore stray characters pasted or typed alongside your code. - -**Affects:** End users - -**End users:** every screen that asks for a sign-in code — the main sign-in screen, Account Settings sign-in, and account recovery — now drops characters your code can't contain, so a code pasted with surrounding quotes, brackets, or a trailing full stop is accepted instead of rejected. The main sign-in screen previously ignored only spaces and line breaks. Where the operator has turned on codes that mix letters and numbers, typing them in lowercase now works too. diff --git a/.changeset/clear-otp-boxes-on-resend.md b/.changeset/clear-otp-boxes-on-resend.md deleted file mode 100644 index d0ee7e58..00000000 --- a/.changeset/clear-otp-boxes-on-resend.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'ePDS': patch ---- - -Asking for a new sign-in code now clears the boxes and tells you the old code has stopped working. - -**Affects:** End users - -**End users:** the boxes reset on **Send a new code**, so a half-typed old code no longer has to be deleted by hand before you can type the new one. The confirmation also warns that only the newest code will be accepted, and points at the spam folder — the two things most likely to be going wrong for anyone who got that far. diff --git a/.changeset/email-autocomplete-hints.md b/.changeset/email-autocomplete-hints.md deleted file mode 100644 index ed312c3b..00000000 --- a/.changeset/email-autocomplete-hints.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'ePDS': patch ---- - -Sign-in and recovery email fields now work more reliably with browser autofill and password managers. - -**Affects:** End users - -**End users:** email fields now identify themselves to browsers as email addresses, while the same sign-in field identifies itself as a username after switching to handle entry. This helps browsers and password managers offer the right saved value in each mode. diff --git a/.changeset/fallback-app-name-on-malformed-client-id.md b/.changeset/fallback-app-name-on-malformed-client-id.md deleted file mode 100644 index 376284f8..00000000 --- a/.changeset/fallback-app-name-on-malformed-client-id.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'ePDS': patch ---- - -Sign-in pages no longer leak a malformed client_id as the displayed app name. - -**Affects:** End users - -**End users:** if you arrived at a sign-in page with a broken `client_id` parameter on the URL (e.g. from a misconfigured app or a tampered link), the page would display that raw broken value as the app's name, e.g. "Sign in to not-a-url" or "Sign in to my-local-app". The page now falls back to "an application" in that case, so a malformed value in the URL doesn't leak into the visible page text or browser tab title. diff --git a/.changeset/handle-mode-through-approval.md b/.changeset/handle-mode-through-approval.md deleted file mode 100644 index 2936e07c..00000000 --- a/.changeset/handle-mode-through-approval.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'ePDS': patch ---- - -The approval and account-chooser screens now honour the handle mode your app asked for, instead of falling back to the server default. - -**Affects:** Client app developers - -**Client app developers:** no action needed — `epds_handle_mode` is resolved exactly as before (the `/oauth/authorize` query parameter, then your OAuth client metadata, then the server default). Previously the resolved mode was lost on the way to the approval step, so a flow that asked for a chosen handle could still show a generated one there. The mode is now carried through `/oauth/epds-callback` and applied on the approval and chooser screens. Values that are not one of the recognised modes are ignored rather than forwarded. diff --git a/.changeset/handle-not-available-message.md b/.changeset/handle-not-available-message.md deleted file mode 100644 index 6eb0bf43..00000000 --- a/.changeset/handle-not-available-message.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'ePDS': patch ---- - -Handle-picker error wording is now accurate when the handle is reserved. - -**Affects:** End users - -**End users:** if you tried to claim a handle that was on the reserved list (admin, www, …) the picker said "That handle was just taken — please choose another." That's misleading — it wasn't just taken, it's permanently unavailable. The wording is now "That handle is not available — please choose another." which doesn't imply you can wait it out. diff --git a/.changeset/human-sign-in-error-copy.md b/.changeset/human-sign-in-error-copy.md deleted file mode 100644 index 7d27cc54..00000000 --- a/.changeset/human-sign-in-error-copy.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'ePDS': patch ---- - -Sign-in errors are written in plain English instead of showing the raw failure code. - -**Affects:** End users - -**End users:** a rejected code used to report "Invalid OTP", an unexplained acronym that ran straight into the recovery link beside it — "Invalid OTP Send a new code". It now reads "That code didn't work." followed by the link, and the two other common failures are similarly rewritten: an aged-out code says "That code has expired.", and one rejected after repeated wrong attempts says "Too many tries — that code is no longer usable.". Any failure outside those three is still shown as-is rather than hidden behind a generic apology, so an unexpected problem can still be reported accurately. diff --git a/.changeset/keyboard-focus-visible-on-buttons.md b/.changeset/keyboard-focus-visible-on-buttons.md deleted file mode 100644 index bf99df16..00000000 --- a/.changeset/keyboard-focus-visible-on-buttons.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'ePDS': patch ---- - -Sign-in buttons now show a visible keyboard focus ring. - -**Affects:** End users - -**End users:** keyboard users tabbing through the sign-in pages couldn't tell where focus was — the buttons styled out the browser default focus ring without replacing it. Verify, Resend, Use different email, Recover, Send recovery code, Continue with email, Update handle, etc. all now show a clear outline when focused via the keyboard, while still hiding the ring on a mouse click. diff --git a/.changeset/muted-text-contrast-on-sign-in.md b/.changeset/muted-text-contrast-on-sign-in.md deleted file mode 100644 index 153de33b..00000000 --- a/.changeset/muted-text-contrast-on-sign-in.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'ePDS': patch ---- - -Small grey text on the sign-in page is darker and easier to read. - -**Affects:** End users, Client app developers - -**End users:** the 13px grey text — the terms line, the "Recover with backup email" link, the "or continue with" / "or use email" separators, and the "powered by Certified" footer — was too light to meet the WCAG AA contrast minimum against either the card or the page background behind it. It is now dark enough to pass on both. - -**Client app developers:** the `--muted-foreground` custom property on the sign-in page changes from `#999` to `#666`. If your `branding.css` sets it, check your value clears 4.5:1 against both `#F8F8F8` (the card) and `#E8E8E8` (the page) — the old default reached only 2.68:1 and 2.33:1 respectively, so a value chosen to sit alongside it may be equally low. The separator text between the social and email sign-in options now follows `--muted-foreground` too, where it previously ignored your override and stayed grey. diff --git a/.changeset/record-email-as-confirmed.md b/.changeset/record-email-as-confirmed.md deleted file mode 100644 index efc2852d..00000000 --- a/.changeset/record-email-as-confirmed.md +++ /dev/null @@ -1,13 +0,0 @@ ---- -'ePDS': patch ---- - -Apps you sign in to are now told that your email address is confirmed. - -**Affects:** End users, Client app developers, Operators - -**End users:** Apps no longer ask you to verify an address you have already confirmed with an emailed code. - -**Client app developers:** The `email_verified` claim is now `true` for accounts that signed in through the emailed-code flow, instead of always `false`. - -**Operators:** Deploy the auth service and the PDS together — the signed handover between them carries a new required field, and a mixed pair rejects sign-in until both are updated. An older auth service is rejected with an explicit `Missing or invalid email_verified parameter` rather than a generic signature error, so a mixed-version rollout is recognisable in the logs. Accounts predating this release are repaired the next time their owner signs in **through the emailed-code flow**; a sign-in that does not prove control of the address leaves them unconfirmed. To fix the rest, see "Backfilling Email Confirmation" in `docs/deployment.md`. diff --git a/.changeset/require-secret-env-vars.md b/.changeset/require-secret-env-vars.md deleted file mode 100644 index c437bde3..00000000 --- a/.changeset/require-secret-env-vars.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'ePDS': patch ---- - -Both services now refuse to start unless their secret environment variables are set, instead of silently falling back to well-known development defaults. - -**Affects:** Operators - -**Operators:** `AUTH_SESSION_SECRET` and `EPDS_CALLBACK_SECRET` are now mandatory. Previously, a deployment that omitted them would boot successfully using hardcoded placeholders such as `dev-session-secret-change-me`, leaving session cookies and the signed `/oauth/epds-callback` redirect verifiable by anyone who has read the source. The auth service and PDS Core now throw at startup with a message naming the missing variable and the `openssl rand -hex 32` command to generate one. Check that both variables are set in your deployment before upgrading, and note that `EPDS_CALLBACK_SECRET` must be identical across the two services. - -`AUTH_CSRF_SECRET` has been removed entirely. CSRF protection derives its tokens from `crypto.randomBytes` and never consumed the value, so the variable was inert; it is no longer read, no longer generated by `scripts/setup.sh`, and can be deleted from your environment. diff --git a/.changeset/resend-log-message-context.md b/.changeset/resend-log-message-context.md deleted file mode 100644 index 118462a3..00000000 --- a/.changeset/resend-log-message-context.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'ePDS': patch ---- - -Resend webhook logs now identify the email event type and provider in their message text. - -**Affects:** Operators - -**Operators:** Log messages use text such as `Received email event 'delivered' from Resend`, making events legible in log viewers that de-emphasize structured fields. diff --git a/.changeset/send-a-new-code-label.md b/.changeset/send-a-new-code-label.md deleted file mode 100644 index 739071d0..00000000 --- a/.changeset/send-a-new-code-label.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'ePDS': patch ---- - -The sign-in button that emails you another code now says "Send a new code" instead of "Resend code". - -**Affects:** End users - -**End users:** each request emails a genuinely new code and stops the previous one working, so "Resend" was misleading — it suggested the code already in your inbox was still the one to type. The confirmation after clicking now starts "Sent!" rather than "Resent!", and still tells you that earlier codes no longer work. The same wording appears on the recovery and account sign-in pages, and on the link offered beside a rejected or expired code. diff --git a/.changeset/sign-in-button-readiness.md b/.changeset/sign-in-button-readiness.md deleted file mode 100644 index d3a7cc2d..00000000 --- a/.changeset/sign-in-button-readiness.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'ePDS': patch ---- - -The email sign-in button now waits until the page is ready before accepting clicks. - -**Affects:** End users - -**End users:** tapping Continue before the sign-in page finished loading could start a broken sign-in attempt that silently did nothing. The button is now disabled until the page is ready. Browsers with JavaScript disabled see a message explaining that JavaScript is required to sign in with email, instead of a button that never responds. diff --git a/.changeset/sign-in-errors-at-point-of-failure.md b/.changeset/sign-in-errors-at-point-of-failure.md deleted file mode 100644 index ab0c5174..00000000 --- a/.changeset/sign-in-errors-at-point-of-failure.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'ePDS': patch ---- - -Sign-in error messages now appear next to the field that caused them, instead of at the top of the page. - -**Affects:** End users - -**End users:** a rejected sign-in code used to report the problem above the page heading, several elements away from the boxes you had just typed into — easy to miss, and it left the **Verify** button looking like the thing to press again. The message now sits directly between the code boxes and **Verify**, and a failed email submission likewise reads under the email field rather than above it. - -A rejected code now also carries a **Send a new code** link beside the message. The standalone button below the form was easy to overlook, and "that code didn't work" often means there is no usable code left at all rather than that you mistyped it — after too many wrong attempts, after signing in from another tab, or once an old code has aged out. Retyping cannot recover any of those. The boxes still clear and refocus, so retyping remains one keystroke away when that is what you need. diff --git a/.changeset/suggest-mistyped-email-domains.md b/.changeset/suggest-mistyped-email-domains.md deleted file mode 100644 index cc872278..00000000 --- a/.changeset/suggest-mistyped-email-domains.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'ePDS': minor ---- - -Catch likely email-domain typos before sending a sign-in code. - -**Affects:** End users - -**End users:** addresses one edit away from Gmail, Hotmail, Outlook, Yahoo, or iCloud now show a “Did you mean…?” prompt as soon as they are typed. Accepting replaces the address in the email field; dismissing keeps the original. The prompt never changes or blocks an address silently. diff --git a/.changeset/unify-link-affordances.md b/.changeset/unify-link-affordances.md deleted file mode 100644 index b9966d3a..00000000 --- a/.changeset/unify-link-affordances.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -'ePDS': patch ---- - -Clickable text on the sign-in screens now looks the same wherever it appears. - -**Affects:** End users, Client app developers - -**End users:** the same action no longer renders differently depending on where it sits. "Resend code", "Use different email" and "Recover with backup email" are now all plain text that darkens when you point at it, while actions embedded in a sentence — "Send a new code" in an error message, and the Terms of Use and Privacy Policy links — stay underlined so they remain visible against the text around them. Pointing at an underlined action no longer makes its underline vanish, and every one of these actions now shows a focus outline when reached with the keyboard. - -**Client app developers:** `--muted-foreground` now also sets the colour of the `.btn-secondary` actions on the sign-in page, which previously hardcoded `#6b6b6b`. If you override that custom property in your `branding.css`, it will now recolour "Resend code" and "Use different email" alongside "Recover with backup email" and the other muted text. Choose a value that clears 4.5:1 contrast against your card background. `--recovery-link-display` is unchanged. diff --git a/.changeset/use-different-email-clears-form.md b/.changeset/use-different-email-clears-form.md deleted file mode 100644 index 3fa6e176..00000000 --- a/.changeset/use-different-email-clears-form.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'ePDS': patch ---- - -"Use different email" on the sign-in code page now clears the email field. - -**Affects:** End users - -**End users:** clicking **Use different email** on the code-entry page used to take you back to the email form with your previous address still filled in — exactly the opposite of what the button suggests, and forcing you to clear the field manually before you could type a new address. The form now starts empty, with the cursor in the field, so you can just type the new address and continue. diff --git a/CHANGELOG.md b/CHANGELOG.md index 34fe1351..dbc7c04f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,179 @@ # ePDS +## 0.8.0 + +### Who should read this release + +- **End users:** + - [Signing in and managing your account now run on the latest AT Protocol server, which requires operators to upgrade to Node.js 22.19 or newer.](#v0.8.0-signing-in-and-managing-your-account-now-run-on-the-latest) + - [Catch likely email-domain typos before sending a sign-in code.](#v0.8.0-catch-likely-email-domain-typos-before-sending-a-sign-in) + - [Sign-in, recovery and handle-choice feedback is now announced automatically by screen readers.](#v0.8.0-sign-in-recovery-and-handle-choice-feedback-is-now) + - [Sign-in code boxes now ignore stray characters pasted or typed alongside your code.](#v0.8.0-sign-in-code-boxes-now-ignore-stray-characters-pasted-or) + - [Asking for a new sign-in code now clears the boxes and tells you the old code has stopped working.](#v0.8.0-asking-for-a-new-sign-in-code-now-clears-the-boxes-and) + - [Sign-in and recovery email fields now work more reliably with browser autofill and password managers.](#v0.8.0-sign-in-and-recovery-email-fields-now-work-more-reliably) + - [Sign-in pages no longer leak a malformed client_id as the displayed app name.](#v0.8.0-sign-in-pages-no-longer-leak-a-malformed-client-id-as-the) + - [Handle-picker error wording is now accurate when the handle is reserved.](#v0.8.0-handle-picker-error-wording-is-now-accurate-when-the-handle) + - [Sign-in errors are written in plain English instead of showing the raw failure code.](#v0.8.0-sign-in-errors-are-written-in-plain-english-instead-of) + - [Sign-in buttons now show a visible keyboard focus ring.](#v0.8.0-sign-in-buttons-now-show-a-visible-keyboard-focus-ring) + - [Small grey text on the sign-in page is darker and easier to read.](#v0.8.0-small-grey-text-on-the-sign-in-page-is-darker-and-easier-to) + - [Apps you sign in to are now told that your email address is confirmed.](#v0.8.0-apps-you-sign-in-to-are-now-told-that-your-email-address-is) + - [The sign-in button that emails you another code now says "Send a new code" instead of "Resend code".](#v0.8.0-the-sign-in-button-that-emails-you-another-code-now-says) + - [The email sign-in button now waits until the page is ready before accepting clicks.](#v0.8.0-the-email-sign-in-button-now-waits-until-the-page-is-ready) + - [Sign-in error messages now appear next to the field that caused them, instead of at the top of the page.](#v0.8.0-sign-in-error-messages-now-appear-next-to-the-field-that) + - [Clickable text on the sign-in screens now looks the same wherever it appears.](#v0.8.0-clickable-text-on-the-sign-in-screens-now-looks-the-same) + - ["Use different email" on the sign-in code page now clears the email field.](#v0.8.0-use-different-email-on-the-sign-in-code-page-now-clears-the) +- **Client app developers:** + - [The approval and account-chooser screens now honour the handle mode your app asked for, instead of falling back to the server default.](#v0.8.0-the-approval-and-account-chooser-screens-now-honour-the) + - [Small grey text on the sign-in page is darker and easier to read.](#v0.8.0-small-grey-text-on-the-sign-in-page-is-darker-and-easier-to) + - [Apps you sign in to are now told that your email address is confirmed.](#v0.8.0-apps-you-sign-in-to-are-now-told-that-your-email-address-is) + - [Clickable text on the sign-in screens now looks the same wherever it appears.](#v0.8.0-clickable-text-on-the-sign-in-screens-now-looks-the-same) +- **Operators:** + - [Signing in and managing your account now run on the latest AT Protocol server, which requires operators to upgrade to Node.js 22.19 or newer.](#v0.8.0-signing-in-and-managing-your-account-now-run-on-the-latest) + - [Apps you sign in to are now told that your email address is confirmed.](#v0.8.0-apps-you-sign-in-to-are-now-told-that-your-email-address-is) + - [Both services now refuse to start unless their secret environment variables are set, instead of silently falling back to well-known development defaults.](#v0.8.0-both-services-now-refuse-to-start-unless-their-secret) + - [Resend webhook logs now identify the email event type and provider in their message text.](#v0.8.0-resend-webhook-logs-now-identify-the-email-event-type-and) + +### Minor Changes + +- [#233](https://github.com/hypercerts-org/ePDS/pull/233) [`ebd214e`](https://github.com/hypercerts-org/ePDS/commit/ebd214effb0072b5280f2902083f8d0ab1366e4d) Thanks [@aspiers](https://github.com/aspiers)! - Signing in and managing your account now run on the latest AT Protocol server, which requires operators to upgrade to Node.js 22.19 or newer. + + **Affects:** End users, Operators + + **End users:** the account and sign-in screens now come from a newer version of the underlying AT Protocol software, which adds changing your handle and deactivating or deleting your account through the account-management interface. The passwordless email-code sign-in and email verification you already use through ePDS are unchanged. + + **Operators:** + - ePDS now requires **Node.js 22.19.0 or newer** (previously 20). Deployments pinned to Node 20 must upgrade; the Docker images already use `node:22-alpine`. `engines.node` is now `>=22.19.0` (the floor is set by a transitive `undici@8` runtime dependency, which fails on Node 22.0–22.18). + - The bundled AT Protocol packages moved to their latest releases: `@atproto/pds` 0.5.23, `@atproto/oauth-provider` 0.21.1, `@atproto/oauth-provider-ui` 0.8.9. + - No configuration changes are required, but the native `better-sqlite3` module must be built for the Node 22 ABI — a clean `pnpm install` on Node 22 handles this; an in-place Node version switch needs `pnpm rebuild better-sqlite3`. + +- [#231](https://github.com/hypercerts-org/ePDS/pull/231) [`fe3b15c`](https://github.com/hypercerts-org/ePDS/commit/fe3b15c17b85319e7d7c73b036bafe90ade8c06b) Thanks [@aspiers](https://github.com/aspiers)! - Catch likely email-domain typos before sending a sign-in code. + + **Affects:** End users + + **End users:** addresses one edit away from Gmail, Hotmail, Outlook, Yahoo, or iCloud now show a “Did you mean…?” prompt as soon as they are typed. Accepting replaces the address in the email field; dismissing keeps the original. The prompt never changes or blocks an address silently. + +### Patch Changes + +- [#224](https://github.com/hypercerts-org/ePDS/pull/224) [`610b5e6`](https://github.com/hypercerts-org/ePDS/commit/610b5e6318e363f9987adaf7846393e66cd988a0) Thanks [@aspiers](https://github.com/aspiers)! - Sign-in, recovery and handle-choice feedback is now announced automatically by screen readers. + + **Affects:** End users + + **End users:** authentication errors now use standard alert and live-region semantics across sign-in, recovery, handle selection, and the demo client, so screen-reader users hear failures without having to search the page for changed text. Repeated failures — such as entering a second incorrect one-time code — announce each time rather than falling silent, and handle availability ("Available!" / "Already taken.") is now announced as you type. + +- [#223](https://github.com/hypercerts-org/ePDS/pull/223) [`0637fa7`](https://github.com/hypercerts-org/ePDS/commit/0637fa77e71fc425647a67ced188372311500ef3) Thanks [@aspiers](https://github.com/aspiers)! - Sign-in code boxes now ignore stray characters pasted or typed alongside your code. + + **Affects:** End users + + **End users:** every screen that asks for a sign-in code — the main sign-in screen, Account Settings sign-in, and account recovery — now drops characters your code can't contain, so a code pasted with surrounding quotes, brackets, or a trailing full stop is accepted instead of rejected. The main sign-in screen previously ignored only spaces and line breaks. Where the operator has turned on codes that mix letters and numbers, typing them in lowercase now works too. + +- [#220](https://github.com/hypercerts-org/ePDS/pull/220) [`68b2104`](https://github.com/hypercerts-org/ePDS/commit/68b21040c6e823b038c2e30410288a225c680f6c) Thanks [@aspiers](https://github.com/aspiers)! - Asking for a new sign-in code now clears the boxes and tells you the old code has stopped working. + + **Affects:** End users + + **End users:** the boxes reset on **Send a new code**, so a half-typed old code no longer has to be deleted by hand before you can type the new one. The confirmation also warns that only the newest code will be accepted, and points at the spam folder — the two things most likely to be going wrong for anyone who got that far. + +- [#222](https://github.com/hypercerts-org/ePDS/pull/222) [`9e12e30`](https://github.com/hypercerts-org/ePDS/commit/9e12e30edf8a9259fec8d261f8587d90d24a2e37) Thanks [@aspiers](https://github.com/aspiers)! - Sign-in and recovery email fields now work more reliably with browser autofill and password managers. + + **Affects:** End users + + **End users:** email fields now identify themselves to browsers as email addresses, while the same sign-in field identifies itself as a username after switching to handle entry. This helps browsers and password managers offer the right saved value in each mode. + +- [#229](https://github.com/hypercerts-org/ePDS/pull/229) [`6f4fa2d`](https://github.com/hypercerts-org/ePDS/commit/6f4fa2dd43b32720154d0b8cc80e272f32568b7b) Thanks [@aspiers](https://github.com/aspiers)! - Sign-in pages no longer leak a malformed client_id as the displayed app name. + + **Affects:** End users + + **End users:** if you arrived at a sign-in page with a broken `client_id` parameter on the URL (e.g. from a misconfigured app or a tampered link), the page would display that raw broken value as the app's name, e.g. "Sign in to not-a-url" or "Sign in to my-local-app". The page now falls back to "an application" in that case, so a malformed value in the URL doesn't leak into the visible page text or browser tab title. + +- [#243](https://github.com/hypercerts-org/ePDS/pull/243) [`534647d`](https://github.com/hypercerts-org/ePDS/commit/534647da664abe07cf543759f94b8286dc7b81a6) Thanks [@aspiers](https://github.com/aspiers)! - The approval and account-chooser screens now honour the handle mode your app asked for, instead of falling back to the server default. + + **Affects:** Client app developers + + **Client app developers:** no action needed — `epds_handle_mode` is resolved exactly as before (the `/oauth/authorize` query parameter, then your OAuth client metadata, then the server default). Previously the resolved mode was lost on the way to the approval step, so a flow that asked for a chosen handle could still show a generated one there. The mode is now carried through `/oauth/epds-callback` and applied on the approval and chooser screens. Values that are not one of the recognised modes are ignored rather than forwarded. + +- [#227](https://github.com/hypercerts-org/ePDS/pull/227) [`68b0760`](https://github.com/hypercerts-org/ePDS/commit/68b0760afa923ca2a3f3b4997137eca1a5ca0b77) Thanks [@aspiers](https://github.com/aspiers)! - Handle-picker error wording is now accurate when the handle is reserved. + + **Affects:** End users + + **End users:** if you tried to claim a handle that was on the reserved list (admin, www, …) the picker said "That handle was just taken — please choose another." That's misleading — it wasn't just taken, it's permanently unavailable. The wording is now "That handle is not available — please choose another." which doesn't imply you can wait it out. + +- [#237](https://github.com/hypercerts-org/ePDS/pull/237) [`ddde1b2`](https://github.com/hypercerts-org/ePDS/commit/ddde1b22f89b45dfd90a38a6f34915dce9b013d3) Thanks [@aspiers](https://github.com/aspiers)! - Sign-in errors are written in plain English instead of showing the raw failure code. + + **Affects:** End users + + **End users:** a rejected code used to report "Invalid OTP", an unexplained acronym that ran straight into the recovery link beside it — "Invalid OTP Send a new code". It now reads "That code didn't work." followed by the link, and the two other common failures are similarly rewritten: an aged-out code says "That code has expired.", and one rejected after repeated wrong attempts says "Too many tries — that code is no longer usable.". Any failure outside those three is still shown as-is rather than hidden behind a generic apology, so an unexpected problem can still be reported accurately. + +- [#225](https://github.com/hypercerts-org/ePDS/pull/225) [`23dcd0f`](https://github.com/hypercerts-org/ePDS/commit/23dcd0f0b88ca5e87b4b465523be19d14864ea04) Thanks [@aspiers](https://github.com/aspiers)! - Sign-in buttons now show a visible keyboard focus ring. + + **Affects:** End users + + **End users:** keyboard users tabbing through the sign-in pages couldn't tell where focus was — the buttons styled out the browser default focus ring without replacing it. Verify, Resend, Use different email, Recover, Send recovery code, Continue with email, Update handle, etc. all now show a clear outline when focused via the keyboard, while still hiding the ring on a mouse click. + +- [#232](https://github.com/hypercerts-org/ePDS/pull/232) [`87ef832`](https://github.com/hypercerts-org/ePDS/commit/87ef8320a169e970474747ca0e9d65b26a3984da) Thanks [@aspiers](https://github.com/aspiers)! - Small grey text on the sign-in page is darker and easier to read. + + **Affects:** End users, Client app developers + + **End users:** the 13px grey text — the terms line, the "Recover with backup email" link, the "or continue with" / "or use email" separators, and the "powered by Certified" footer — was too light to meet the WCAG AA contrast minimum against either the card or the page background behind it. It is now dark enough to pass on both. + + **Client app developers:** the `--muted-foreground` custom property on the sign-in page changes from `[#999](https://github.com/hypercerts-org/ePDS/issues/999)` to `[#666](https://github.com/hypercerts-org/ePDS/issues/666)`. If your `branding.css` sets it, check your value clears 4.5:1 against both `#F8F8F8` (the card) and `#E8E8E8` (the page) — the old default reached only 2.68:1 and 2.33:1 respectively, so a value chosen to sit alongside it may be equally low. The separator text between the social and email sign-in options now follows `--muted-foreground` too, where it previously ignored your override and stayed grey. + +- [#234](https://github.com/hypercerts-org/ePDS/pull/234) [`3924855`](https://github.com/hypercerts-org/ePDS/commit/39248551ccaa1097cdfd569bbe4d6617ad0fed7f) Thanks [@aspiers](https://github.com/aspiers)! - Apps you sign in to are now told that your email address is confirmed. + + **Affects:** End users, Client app developers, Operators + + **End users:** Apps no longer ask you to verify an address you have already confirmed with an emailed code. + + **Client app developers:** The `email_verified` claim is now `true` for accounts that signed in through the emailed-code flow, instead of always `false`. + + **Operators:** Deploy the auth service and the PDS together — the signed handover between them carries a new required field, and a mixed pair rejects sign-in until both are updated. An older auth service is rejected with an explicit `Missing or invalid email_verified parameter` rather than a generic signature error, so a mixed-version rollout is recognisable in the logs. Accounts predating this release are repaired the next time their owner signs in **through the emailed-code flow**; a sign-in that does not prove control of the address leaves them unconfirmed. To fix the rest, see "Backfilling Email Confirmation" in `docs/deployment.md`. + +- [#41](https://github.com/hypercerts-org/ePDS/pull/41) [`d4256ea`](https://github.com/hypercerts-org/ePDS/commit/d4256ea03a0982386662c3a9adb1ff7d9f347a38) Thanks [@aspiers](https://github.com/aspiers)! - Both services now refuse to start unless their secret environment variables are set, instead of silently falling back to well-known development defaults. + + **Affects:** Operators + + **Operators:** `AUTH_SESSION_SECRET` and `EPDS_CALLBACK_SECRET` are now mandatory. Previously, a deployment that omitted them would boot successfully using hardcoded placeholders such as `dev-session-secret-change-me`, leaving session cookies and the signed `/oauth/epds-callback` redirect verifiable by anyone who has read the source. The auth service and PDS Core now throw at startup with a message naming the missing variable and the `openssl rand -hex 32` command to generate one. Check that both variables are set in your deployment before upgrading, and note that `EPDS_CALLBACK_SECRET` must be identical across the two services. + + `AUTH_CSRF_SECRET` has been removed entirely. CSRF protection derives its tokens from `crypto.randomBytes` and never consumed the value, so the variable was inert; it is no longer read, no longer generated by `scripts/setup.sh`, and can be deleted from your environment. + +- [#209](https://github.com/hypercerts-org/ePDS/pull/209) [`0af6d7f`](https://github.com/hypercerts-org/ePDS/commit/0af6d7f690f848517269e181d18a9a6229828e79) Thanks [@aspiers](https://github.com/aspiers)! - Resend webhook logs now identify the email event type and provider in their message text. + + **Affects:** Operators + + **Operators:** Log messages use text such as `Received email event 'delivered' from Resend`, making events legible in log viewers that de-emphasize structured fields. + +- [#236](https://github.com/hypercerts-org/ePDS/pull/236) [`e238c38`](https://github.com/hypercerts-org/ePDS/commit/e238c3830566b5e2960d4bd5bf40e474ac6db0e4) Thanks [@aspiers](https://github.com/aspiers)! - The sign-in button that emails you another code now says "Send a new code" instead of "Resend code". + + **Affects:** End users + + **End users:** each request emails a genuinely new code and stops the previous one working, so "Resend" was misleading — it suggested the code already in your inbox was still the one to type. The confirmation after clicking now starts "Sent!" rather than "Resent!", and still tells you that earlier codes no longer work. The same wording appears on the recovery and account sign-in pages, and on the link offered beside a rejected or expired code. + +- [#242](https://github.com/hypercerts-org/ePDS/pull/242) [`a0a819a`](https://github.com/hypercerts-org/ePDS/commit/a0a819af2d39288351ea17f4f4f9de62af026be8) Thanks [@aspiers](https://github.com/aspiers)! - The email sign-in button now waits until the page is ready before accepting clicks. + + **Affects:** End users + + **End users:** tapping Continue before the sign-in page finished loading could start a broken sign-in attempt that silently did nothing. The button is now disabled until the page is ready. Browsers with JavaScript disabled see a message explaining that JavaScript is required to sign in with email, instead of a button that never responds. + +- [#220](https://github.com/hypercerts-org/ePDS/pull/220) [`50c8620`](https://github.com/hypercerts-org/ePDS/commit/50c8620896d8c797ab629467e09701d983426468) Thanks [@aspiers](https://github.com/aspiers)! - Sign-in error messages now appear next to the field that caused them, instead of at the top of the page. + + **Affects:** End users + + **End users:** a rejected sign-in code used to report the problem above the page heading, several elements away from the boxes you had just typed into — easy to miss, and it left the **Verify** button looking like the thing to press again. The message now sits directly between the code boxes and **Verify**, and a failed email submission likewise reads under the email field rather than above it. + + A rejected code now also carries a **Send a new code** link beside the message. The standalone button below the form was easy to overlook, and "that code didn't work" often means there is no usable code left at all rather than that you mistyped it — after too many wrong attempts, after signing in from another tab, or once an old code has aged out. Retyping cannot recover any of those. The boxes still clear and refocus, so retyping remains one keystroke away when that is what you need. + +- [#237](https://github.com/hypercerts-org/ePDS/pull/237) [`43baeb5`](https://github.com/hypercerts-org/ePDS/commit/43baeb54c60402ab97050b5e4af5b143b8901640) Thanks [@aspiers](https://github.com/aspiers)! - Clickable text on the sign-in screens now looks the same wherever it appears. + + **Affects:** End users, Client app developers + + **End users:** the same action no longer renders differently depending on where it sits. "Resend code", "Use different email" and "Recover with backup email" are now all plain text that darkens when you point at it, while actions embedded in a sentence — "Send a new code" in an error message, and the Terms of Use and Privacy Policy links — stay underlined so they remain visible against the text around them. Pointing at an underlined action no longer makes its underline vanish, and every one of these actions now shows a focus outline when reached with the keyboard. + + **Client app developers:** `--muted-foreground` now also sets the colour of the `.btn-secondary` actions on the sign-in page, which previously hardcoded `#6b6b6b`. If you override that custom property in your `branding.css`, it will now recolour "Resend code" and "Use different email" alongside "Recover with backup email" and the other muted text. Choose a value that clears 4.5:1 contrast against your card background. `--recovery-link-display` is unchanged. + +- [#215](https://github.com/hypercerts-org/ePDS/pull/215) [`1a90062`](https://github.com/hypercerts-org/ePDS/commit/1a900621936941066372228b423d26a6808cb21b) Thanks [@aspiers](https://github.com/aspiers)! - "Use different email" on the sign-in code page now clears the email field. + + **Affects:** End users + + **End users:** clicking **Use different email** on the code-entry page used to take you back to the email form with your previous address still filled in — exactly the opposite of what the button suggests, and forcing you to clear the field manually before you could type a new address. The form now starts empty, with the cursor in the field, so you can just type the new address and continue. + ## 0.7.0 ### Who should read this release diff --git a/package.json b/package.json index 6a1a3fec..0e5e638f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ePDS", - "version": "0.7.0", + "version": "0.8.0", "private": true, "description": "ePDS — extended Personal Data Server for AT Protocol with passwordless OTP authentication", "license": "MIT", From b5a3f34fc3e4e992507abec4ac6c13c0cc13fd20 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Thu, 3 Sep 2026 15:07:21 +0600 Subject: [PATCH 77/84] docs: simplify ePDS OAuth login skill --- .agents/skills/epds-login/SKILL.md | 412 ++++++++++++------ .../epds-login/references/client-metadata.md | 35 +- .../skills/epds-login/references/dpop-pkce.md | 189 +------- .agents/skills/epds-login/references/flows.md | 359 +-------------- 4 files changed, 323 insertions(+), 672 deletions(-) diff --git a/.agents/skills/epds-login/SKILL.md b/.agents/skills/epds-login/SKILL.md index 1d5fadc3..547b7c34 100644 --- a/.agents/skills/epds-login/SKILL.md +++ b/.agents/skills/epds-login/SKILL.md @@ -1,61 +1,59 @@ --- name: epds-login -description: Implement AT Protocol OAuth login against an ePDS instance. Covers two flows — Flow 1 (email-first, hand-rolled PAR/DPoP) and Flow 2 (via @atproto/oauth-client-node, accepting no hint / handle / DID). Use when building passwordless OTP login, configuring client metadata (confidential vs public), or integrating NodeOAuthClient. +description: Implement AT Protocol OAuth login against an ePDS instance with @atproto/oauth-client-node. Covers email-first OTP, hosted email entry, handle/DID login, client metadata, application sessions, callbacks, and debugging. Use when building passwordless or social login against ePDS, configuring confidential/public OAuth clients, or integrating NodeOAuthClient. --- # Implementing ePDS Login -ePDS lets your users sign in to [AT Protocol](https://atproto.com/) apps — like -[Bluesky](https://bsky.app/) — using familiar login methods: **email OTP**, **Google**, -**GitHub**, or any other provider [Better Auth](https://www.better-auth.com/) supports. -Under the hood it is a standard AT Protocol PDS wrapped with a pluggable authentication -layer. Users just sign in with their email or social account and get a presence in the -AT Protocol universe (a DID, a handle, a data repository) automatically provisioned. +ePDS lets users sign in to AT Protocol apps using email OTP, Google, GitHub, +or another provider supported by Better Auth. New users receive a DID, handle, +and data repository automatically. -From your app's perspective, ePDS uses standard AT Protocol OAuth (PAR + PKCE + DPoP). -The reference implementation is `packages/demo` in the [ePDS repository](https://github.com/hypercerts-org/ePDS). +From a client's perspective, ePDS uses standard AT Protocol OAuth with PAR, +PKCE, and DPoP. Use `@atproto/oauth-client-node` for every login variant. Do +not implement those protocol mechanisms yourself. -For protocol-level detail beyond ePDS specifics — DPoP proof mechanics, granular -scope design (`repo:`/`rpc:`/`blob:`/`account:`), identity verification after token -exchange, and refresh-token race handling — see the `atproto-oauth` skill. This skill -covers only what is ePDS-specific. +The ePDS repository demonstrates server behavior and UI. Its `packages/demo` +OAuth client still contains a legacy hand-rolled flow; do not copy that client +implementation. -## Two Flows +For protocol-level guidance beyond ePDS specifics—granular scope design, +identity verification after token exchange, session storage, and refresh-token +race handling—read the `atproto-oauth` skill. -| Flow | App provides | How user starts | Implementation | -| ---- | ----------------------- | ---------------------------- | -------------------- | -| 1 | Email address | OTP screen immediately | Hand-rolled PAR/DPoP | -| 2 | Nothing, handle, or DID | Depends on input (see below) | `NodeOAuthClient` | +## Choose the Login Input -**Why the split?** `@atproto/oauth-client-node`'s `authorize()` method accepts -a handle or DID as input but explicitly omits `login_hint` from its options — -the library resolves the identity itself and overrides the hint. Flow 1 needs -to pass a raw email as `login_hint` on the auth redirect URL (not in the PAR -body), which the library cannot do. Flow 1 must therefore use hand-rolled -PAR + DPoP requests. +All variants use one `NodeOAuthClient` and converge on the same callback and +`session.did`. -Flow 2 covers three input variants — all use the same `NodeOAuthClient` code: +| App provides | Call | User experience | +| ------------ | -------------------------------------------------- | --------------------------------------- | +| Email | `authorize(epdsUrl)`, then append URL `login_hint` | OTP step immediately | +| Nothing | `authorize(epdsUrl)` | Hosted ePDS login form | +| Handle | `authorize(handle)` | SDK resolves account; ePDS starts login | +| DID | `authorize(did)` | SDK resolves account; ePDS starts login | -- **No identifier** — pass the PDS URL; auth server shows its own email form -- **Handle** — pass an AT Protocol handle (e.g. `alice.pds.example.com`); auth server resolves it and sends OTP directly -- **DID** — pass a DID (e.g. `did:plc:abc123...`); auth server resolves it and sends OTP directly +`NodeOAuthClient.authorize()` deliberately does not accept `login_hint` in its +options. For email-first login, call `authorize(epdsUrl)` first, then append the +email to the returned authorization URL. This preserves SDK ownership of PAR, +PKCE, DPoP, state, nonce retry, token exchange, and OAuth session storage. -> **Important:** `login_hint` must **never** go in the PAR body when the value -> is an email address. The PDS core validates `login_hint` as an ATProto -> identity (handle or DID) and rejects emails with `Invalid login_hint`. Put -> email `login_hint` only on the **auth redirect URL** — that request goes to -> the ePDS auth service (Better Auth layer), which accepts emails. +> **ePDS-specific behavior:** Put an email `login_hint` only on the returned +> authorization URL. Never put it in the PAR body. PDS core validates a PAR +> `login_hint` as an AT Protocol handle or DID and rejects an email with +> `Invalid login_hint`; ePDS auth-service accepts email hints from the browser +> authorization request. +> +> Email in a URL may appear in browser history, infrastructure logs, and error +> reports. Do not log authorization URLs. Prefer hosted email entry when that +> exposure is unacceptable, and configure query-string redaction where possible. -## Quick Start — Flow 2 (recommended) +## Quick Start -Use `@atproto/oauth-client-node` for any flow that does not require passing a -raw email as `login_hint`. +### 1. Client Metadata -### 1. Client Metadata (confidential client) - -Host at your `client_id` URL (must be HTTPS in production). Provide the -public key via `jwks_uri` (remote endpoint) or inline `jwks` — the two -are mutually exclusive: +Host metadata at the HTTPS URL used as `client_id`. A confidential client is +recommended for server-rendered web applications: ```json { @@ -72,23 +70,22 @@ are mutually exclusive: } ``` -> **On the `scope` value:** `atproto` is mandatory and must be listed first; the -> remaining entries request only the permissions your app needs. The examples here -> reference two hypercerts specific permission sets via the `include:` prefix — -> `include:org.hypercerts.authWrite` and `include:app.certified.authWrite`; -> substitute the permission sets your own app defines. A -> permission set that bundles `rpc:` service calls also needs an -> `?aud=` parameter (with `#` percent-encoded as `%23`); these are -> write-only sets, so no `aud` is required. Avoid the legacy `transition:generic` -> catch-all. See the `atproto-oauth` skill's "Scopes and Permission Sets" for the -> grammar. +`atproto` is mandatory and must come first. Replace example Hypercerts +permission sets with only those your app needs. A permission set containing +`rpc:` service calls also needs `?aud=`; percent-encode `#` as +`%23`. Avoid legacy `transition:generic`. + +You may replace `jwks_uri` with inline `jwks`; never publish the private `d` +parameter. Read [client-metadata.md](references/client-metadata.md) for public +clients, JWKS generation and rotation, consent behavior, branding, and email +templates. -Alternatively, replace `jwks_uri` with an inline `jwks` object containing -the public key directly — see -[client-metadata.md](references/client-metadata.md) for both forms, the -force-consent gotcha with public clients, and key generation instructions. +### 2. Create One OAuth Client -### 2. Create the OAuth client +Create a singleton. Its state and session stores must persist across requests; +in-memory stores are unsafe for serverless or multi-instance deployments. +Multi-instance deployments also need a distributed `requestLock` so concurrent +token refreshes for one DID cannot revoke each other. ```typescript import { NodeOAuthClient } from '@atproto/oauth-client-node' @@ -97,6 +94,8 @@ import { JoseKey } from '@atproto/jwk-jose' const privateJwk = JSON.parse(process.env.OAUTH_PRIVATE_KEY!) const client = new NodeOAuthClient({ + // Required across multiple processes. Use a distributed lock implementation. + requestLock, clientMetadata: { client_id: 'https://yourapp.example.com/client-metadata.json', client_name: 'Your App', @@ -111,10 +110,9 @@ const client = new NodeOAuthClient({ dpop_bound_access_tokens: true, }, keyset: [await JoseKey.fromImportable(privateJwk, privateJwk.kid)], - stateStore: { async set(key, value) { - /* store in DB/Redis */ + /* persist in DB/Redis */ }, async get(key) { /* retrieve */ @@ -125,7 +123,7 @@ const client = new NodeOAuthClient({ }, sessionStore: { async set(key, value) { - /* store in DB/Redis */ + /* persist in DB/Redis */ }, async get(key) { /* retrieve */ @@ -137,133 +135,271 @@ const client = new NodeOAuthClient({ }) ``` -### 3. Login handler +A single-process deployment may omit `requestLock`; the SDK then uses only a +process-local fallback and warns that credentials might be revoked. A public +client omits `OAUTH_PRIVATE_KEY`, `keyset`, and confidential-client metadata +fields; read [client-metadata.md](references/client-metadata.md). -```typescript -// No identifier — auth server shows email form -const authUrl = await client.authorize('https://pds.example.com') +For local HTTP-only ePDS development, pass `allowHttp: true`. Never enable it in +production. -// With a handle — auth server resolves and sends OTP -const authUrl = await client.authorize('alice.pds.example.com') +### 3. Login Handler -// With a DID — same behaviour as handle -const authUrl = await client.authorize('did:plc:abc123...') +```typescript +const EPDS_URL = process.env.EPDS_URL! + +export async function beginLogin(input?: { + email?: string + identifier?: string + forceLogin?: boolean +}): Promise { + // identifier may be a handle or DID. Without one, use the configured ePDS. + // Keep prompt in PAR for pds-core, then duplicate it on the returned URL for + // auth-service's browser-session decision. + const authorizationUrl = await client.authorize( + input?.identifier ?? EPDS_URL, + input?.forceLogin ? { prompt: 'login' } : undefined, + ) + + // Email hints belong on the browser authorization URL, not in PAR. + if (input?.email) { + authorizationUrl.searchParams.set('login_hint', input.email) + } + + // ePDS reads this URL parameter when deciding whether to reuse a browser + // session. A prompt stored only in PAR does not engage that behavior. + if (input?.forceLogin) { + authorizationUrl.searchParams.set('prompt', 'login') + } + + return authorizationUrl +} ``` -Redirect the user's browser to `authUrl`. +Validate and normalize user input before calling this helper. Redirect the +browser to the returned URL. + +### 4. Use One Shared Callback -### 4. Callback handler +Email, hosted-form, handle, and DID login should normally use one callback: + +```text +/api/oauth/callback +``` + +`client.callback()` matches `state` to context created by `authorize()`, then +restores issuer, redirect URI, PKCE verifier, and DPoP key before exchanging the +code. Login method does not require a separate callback. ```typescript const { session, state } = await client.callback( new URLSearchParams(callbackQueryString), ) -// session.did — the user's DID (e.g. "did:plc:abc123...") -// session.fetchHandler() — authenticated fetch for AT Protocol API calls + +const userDid = session.did ``` -### 5. Restore a session +Use a separate callback only when application behavior genuinely differs. In +that case, register both URIs in client metadata and pass the selected URI to +`authorize()` explicitly: ```typescript -const session = await client.restore(userDid) -// Use session.fetchHandler() for API calls +const epdsCallbackUri = 'https://yourapp.example.com/api/oauth/epds/callback' + +await client.authorize(EPDS_URL, { + redirect_uri: epdsCallbackUri, +}) + +// Pass the same non-default URI during code exchange. +await client.callback(callbackParams, { + redirect_uri: epdsCallbackUri, +}) ``` -### 6. Serve library endpoints +### 5. Create an Application Session -Your `client_id` URL must be publicly reachable. If you use `jwks_uri` -(rather than inline `jwks`), that endpoint must also be reachable. You -can serve both from the `NodeOAuthClient` instance: +OAuth session storage and browser application sessions have different jobs: + +- `NodeOAuthClient.sessionStore` stores OAuth tokens and DPoP material by DID. +- Application session store maps a random ID to the signed-in DID. +- Browser cookie contains only that random ID, never tokens, email, or DID. ```typescript -app.get('/client-metadata.json', (req, res) => { - res.json(client.clientMetadata) -}) +interface AppSession { + did: string +} + +const APP_SESSION_TTL_SECONDS = 60 * 60 * 24 * 30 +const { session: oauthSession } = await client.callback(callbackParams) +const appSessionId = crypto.randomUUID() -// Only needed when using jwks_uri (not inline jwks) -app.get('/jwks.json', (req, res) => { - res.json(client.jwks) +await appSessionStore.set( + appSessionId, + { did: oauthSession.did }, + { ttlSeconds: APP_SESSION_TTL_SECONDS }, +) + +setCookie('app_session', appSessionId, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'lax', + path: '/', + maxAge: APP_SESSION_TTL_SECONDS, }) ``` -## Quick Start — Flow 1 (hand-rolled) +The cookie is opaque: its random value reveals no identity or credentials. +`httpOnly` prevents browser JavaScript from reading it. On later requests, load +the DID server-side and restore the OAuth session: + +```typescript +const appSession = await appSessionStore.get(appSessionId) +if (!appSession) throw new Error('Application session expired; sign in again') + +const oauthSession = await client.restore(appSession.did) +``` + +Generate session IDs with a cryptographically secure random source. Rotate the +ID after authentication, delete it during logout, and protect state-changing +routes against CSRF. Do not treat `httpOnly` alone as complete session security. -Flow 1 requires hand-rolled PAR and token exchange because the library -cannot pass a raw email as `login_hint`. See -[references/flows.md](references/flows.md) for the full walkthrough and -[references/dpop-pkce.md](references/dpop-pkce.md) for the helper -functions. +`NodeOAuthClient` stores OAuth credentials by DID. Multiple opaque application +sessions for one DID therefore share one underlying OAuth session; they are not +independent device credentials. A later login may replace that stored OAuth +session. Deleting one application session should normally delete only its random +ID. Calling OAuth `signOut()` revokes shared credentials and may affect other +application sessions for that DID. Choose and document intended multi-device +logout behavior. -The abbreviated version: +### 6. Serve Metadata and JWKS -1. Generate DPoP key pair and PKCE verifier -2. POST to `/oauth/par` (with DPoP nonce retry) -3. Redirect browser to `/oauth/authorize?...&login_hint=` -4. Handle callback: verify state, exchange code for tokens (with DPoP nonce retry) +```typescript +app.get('/client-metadata.json', (_request, response) => { + response.json(publishedClientMetadata) +}) + +// Only needed when metadata uses jwks_uri instead of inline jwks. +app.get('/jwks.json', (_request, response) => { + response.json(client.jwks) +}) +``` + +Serve the original `publishedClientMetadata` object when using ePDS extension +fields such as `branding` or `epds_handle_mode`. SDK validation may omit unknown +extension fields from `client.clientMetadata`. + +The ePDS must be able to reach discoverable client metadata and remote JWKS. A +remote ePDS cannot fetch an endpoint bound only to your local machine; use an +HTTPS tunnel or deployed development URL. Loopback clients follow separate AT +Protocol metadata rules and are not confidential clients. -## Forcing a Fresh Sign-In (`prompt=login`) +## Do Not Hand-Roll OAuth Primitives -When a previous sign-in's cookies are present in the browser, ePDS skips -the email code form and lands the user on the account chooser to confirm -which identity to reuse. To force the email code form instead, use the -standard OIDC `prompt=login` parameter. +Do not manually implement: -**Important — where to put it:** ePDS's auth service decides whether to -engage session reuse by inspecting the **query string** of the -`/oauth/authorize` redirect. PAR-body `prompt=login` is ignored. +- PKCE verifier or challenge generation +- DPoP key generation, proof JWTs, or nonce retry +- PAR requests +- authorization-code token exchange +- `private_key_jwt` client assertions +- authorization-server discovery +- OAuth state/session object construction -If your OAuth library (e.g. `NodeOAuthClient`) only supports passing -`prompt` via the PAR body, you must also append `&prompt=login` to the -authorization URL the library returns before redirecting the user. +`NodeOAuthClient` already coordinates these operations and their persisted +state. Reimplementing only part of that lifecycle risks mismatched keys, +incorrect issuer or audience values, replay vulnerabilities, and broken refresh. -**Hand-rolled (Flow 1):** +## Forcing Fresh Sign-In + +Existing ePDS cookies may lead to account reuse or an account chooser. Put +`prompt=login` in both PAR and the returned authorization URL: ```typescript -const authUrl = - `${authEndpoint}?client_id=${encodeURIComponent(clientId)}` + - `&request_uri=${encodeURIComponent(parData.request_uri)}` + - (forceLogin ? '&prompt=login' : '') +const url = await client.authorize(EPDS_URL, { prompt: 'login' }) +url.searchParams.set('prompt', 'login') ``` -**With `NodeOAuthClient` (Flow 2):** +For email-first login, append the email too: ```typescript -const url = await client.authorize(input, { prompt: 'login' }) -// Library puts prompt in PAR; also append it to the URL query string -// so ePDS's session-reuse short-circuit fires. +const url = await client.authorize(EPDS_URL, { prompt: 'login' }) +url.searchParams.set('login_hint', email) url.searchParams.set('prompt', 'login') ``` -## Common Pitfalls +The PAR value informs pds-core's authentication guard. ePDS auth-service also +inspects the browser authorization URL when deciding whether to reuse a browser +session, so the URL value is required too. -| Pitfall | Fix | -| ------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | -| Consent screen on every login | Switch to `private_key_jwt` — public clients force consent unless in the PDS trusted list | -| Flash of email form (Flow 1) | Include `login_hint` on the **auth redirect URL only** (never in the PAR body) | -| `Invalid login_hint` from PAR | Remove `login_hint` from the PAR body — PDS core only accepts handles/DIDs, not emails | -| `auth_failed` immediately | Check Caddy logs — likely a DNS/upstream name mismatch | -| DPoP rejected (hand-rolled only) | Always implement the nonce retry loop (ePDS always demands a nonce) | -| Token exchange fails (hand-rolled) | Restore the DPoP key pair from the session cookie, don't generate a new one | -| `Cannot find package` in tests | Run `pnpm build` before `pnpm test` — vitest needs `dist/` | -| `NodeOAuthClient` callback 401 | Ensure `stateStore` and `sessionStore` persist across requests (not in-memory for serverless) | -| `prompt=login` ignored, chooser still shown | Append `&prompt=login` to the **authorize URL** query string — PAR body alone doesn't engage ePDS's short-circuit | +## Verification -## Handles +Check authorization-server discovery: + +```bash +curl -fsS "$EPDS_URL/.well-known/oauth-authorization-server" \ + | python3 -m json.tool +``` + +Confirm metadata contains exact callback URI and expected scope: -New users choose their own handle during signup (e.g. `alice.pds.example.com`). -The local part must be 5–20 characters, alphanumeric with hyphens. Handles are -not derived from the user's email address, for privacy. +```bash +curl -fsS "https://yourapp.example.com/client-metadata.json" \ + | python3 -m json.tool +``` -## ePDS Endpoints (defaults) +When metadata uses `jwks_uri`, confirm that endpoint exposes no private `d` +value: +```bash +curl -fsS "https://yourapp.example.com/jwks.json" \ + | python3 -m json.tool ``` -PAR: https:///oauth/par -Auth: https://auth./oauth/authorize -Token: https:///oauth/token + +Skip this endpoint check when metadata contains inline `jwks`; inspect those +inline keys instead. + +Test login endpoint without following redirects. Expect a `3xx` response whose +`Location` points to ePDS authorization endpoint and contains `request_uri`: + +```bash +curl -sS -o /dev/null -D - \ + "https://yourapp.example.com/api/oauth/login?email=user%40example.com" ``` +For email-first login, confirm `Location` also contains encoded `login_hint`. +Never print OAuth tokens, private JWKs, cookies, or store contents while +debugging. + +## Common Failures + +| Failure | Likely cause | Fix | +| --------------------------------------- | --------------------------------------------------------- | ---------------------------------------------------------------------- | +| `Invalid login_hint` during PAR | Email included in PAR body | Append email only after `authorize()` returns | +| Fresh-login request still shows chooser | `prompt=login` exists only in PAR | Append it to returned authorization URL | +| `Invalid redirect_uri` | Callback missing from metadata, or wrong URI selected | Register exact URI; pass `redirect_uri` when not using first entry | +| `client_id not found` | ePDS cannot fetch metadata URL | Use reachable HTTPS metadata URL or supported loopback client metadata | +| Callback returns 401 or loses state | In-memory store lost or request reached another instance | Use shared persistent state and session stores | +| Consent appears every login | Public untrusted client | Use `private_key_jwt` or operator trust configuration | +| New account has no profile | Profile record has not been created | Use DID as display fallback; resolve handle separately when needed | +| OTP never arrives | Unknown email, delivery delay, or ePDS mail configuration | Verify address and inspect ePDS operator logs without exposing secrets | + +## Handles + +New users choose a handle during signup. Local part must be 5–20 characters, +alphanumeric with hyphens. Handles are not derived from email addresses. + +Do not expect `OAuthSession` to expose `session.handle`; its stable identity is +`session.did`. Resolve current handle through an AT Protocol identity resolver +when needed. + +## ePDS Endpoint Discovery + +Do not hard-code PAR, authorization, or token endpoints. `NodeOAuthClient` +discovers them through OAuth authorization-server metadata. ePDS commonly uses +separate PDS and auth-service hostnames; that is normal. + ## Reference Files -- [Client metadata fields](references/client-metadata.md) — confidential vs public, JWKS, all fields, email branding -- [Full flow walkthrough](references/flows.md) — sequence diagrams, Flow 1 hand-rolled code, Flow 2 library code -- [PKCE and DPoP helpers](references/dpop-pkce.md) — Flow 1 only; Flow 2 should use `NodeOAuthClient` instead +- [Client metadata](references/client-metadata.md): confidential/public clients, JWKS, branding, email templates +- [Legacy flow walkthrough](references/flows.md): historical hand-rolled flow; do not use for new integrations +- [Legacy PKCE/DPoP helpers](references/dpop-pkce.md): historical reference only; prefer `NodeOAuthClient` diff --git a/.agents/skills/epds-login/references/client-metadata.md b/.agents/skills/epds-login/references/client-metadata.md index 130aceee..afb94f39 100644 --- a/.agents/skills/epds-login/references/client-metadata.md +++ b/.agents/skills/epds-login/references/client-metadata.md @@ -113,10 +113,9 @@ key generation and serving details. | `dpop_bound_access_tokens` | Yes | Always `true` | | `client_uri` | No | Your app's homepage URL | | `logo_uri` | No | URL to your app logo (shown on login page) | -| `email_template_uri` | No | URL to a custom OTP email HTML template | -| `email_subject_template` | No | Custom email subject line with `{{code}}` placeholder | +| `email_template_uri` | No | URL to a custom OTP email HTML template. Trusted clients only. | +| `email_subject_template` | No | Custom email subject line with `{{code}}` placeholder. Trusted clients only. | | `brand_color` | No | Hex colour for buttons and input focus rings (default: `#1A130F`) | -| `background_color` | No | Hex colour for the login page background (default: `#F2EBE4`) | | `epds_handle_mode` | No | ePDS extension. Handle picker variant for new users: `"picker"`, `"random"`, or `"picker-with-random"` (default). See [tutorial](../../../../docs/tutorial.md#optional-control-the-handle-picker). | | `epds_skip_consent_on_signup` | No | ePDS extension. When `true`, skip the consent screen on initial sign-up. Only honoured when the PDS has `PDS_SIGNUP_ALLOW_CONSENT_SKIP=true` AND the client is in `PDS_OAUTH_TRUSTED_CLIENTS`. | | `epds_handle_login_url` | No | ePDS extension. Absolute http(s) URL on your client's origin. When set, the auth-service login page renders an "Or sign in with ATProto/Bluesky" button; submitting a handle redirects the browser to this URL with `?handle=` appended. See [tutorial](../../../../docs/tutorial.md#optional-offer-atprotobluesky-handle-sign-in). | @@ -134,18 +133,20 @@ for the route list and example URLs. ## Custom email templates -If you provide `email_template_uri`, the auth service fetches that URL and -uses it as the OTP email body instead of the default Certified template. +Custom email HTML and subject fields are honored only when the client is in +`PDS_OAUTH_TRUSTED_CLIENTS`. When trusted metadata provides +`email_template_uri`, auth-service fetches that HTML and uses it for the HTML +part of the OTP email. The server retains control of the plain-text part. Your template must be an HTML file. Supported placeholders: -| Placeholder | Description | -| ------------------------------------- | ----------------------------------------- | -| `{{code}}` | The 8-digit OTP code — **required** | -| `{{app_name}}` | Value of `client_name` from your metadata | -| `{{logo_uri}}` | Value of `logo_uri` from your metadata | -| `{{#is_new_user}}...{{/is_new_user}}` | Block shown only on first sign-up | -| `{{^is_new_user}}...{{/is_new_user}}` | Block shown only on subsequent sign-ins | +| Placeholder | Description | +| ------------------------------------- | ------------------------------------------------- | +| `{{code}}` | Configured 4–12-character OTP code — **required** | +| `{{app_name}}` | Value of `client_name` from your metadata | +| `{{logo_uri}}` | Value of `logo_uri` from your metadata | +| `{{#is_new_user}}...{{/is_new_user}}` | Block shown only on first sign-up | +| `{{^is_new_user}}...{{/is_new_user}}` | Block shown only on subsequent sign-ins | Minimal template example: @@ -281,6 +282,10 @@ allow-list. ## Local development -During local development you can use `http://localhost` client IDs. The -`client_id` must still be a reachable URL — ePDS fetches it at runtime. -Use a local server or `ngrok` to expose your metadata endpoint. +A remote ePDS must be able to fetch a discoverable client metadata URL and any +remote `jwks_uri`. It cannot reach a server bound only to your localhost. Use an +HTTPS tunnel or deployed development URL for a confidential client. + +AT Protocol loopback clients use a separate metadata encoding and public-client +rules. Do not assume an ordinary `http://localhost/.../client-metadata.json` URL +will be fetched successfully by a remote ePDS. diff --git a/.agents/skills/epds-login/references/dpop-pkce.md b/.agents/skills/epds-login/references/dpop-pkce.md index 58255eb3..3c27fe3c 100644 --- a/.agents/skills/epds-login/references/dpop-pkce.md +++ b/.agents/skills/epds-login/references/dpop-pkce.md @@ -1,185 +1,18 @@ -# PKCE and DPoP Helper Implementations +# Legacy PKCE and DPoP Helpers -> **Flow 2 does not need these helpers.** If your app uses -> `@atproto/oauth-client-node` (recommended for any flow that does not pass -> a raw email as `login_hint`), the library handles PKCE, DPoP, and nonce -> retry internally. These helpers are only needed for **Flow 1** (hand-rolled -> PAR/DPoP with email `login_hint`). +This reference previously contained hand-rolled PKCE and DPoP helpers. They were +removed because partial OAuth implementations risk mismatched keys, invalid +issuer or audience values, replay vulnerabilities, broken nonce handling, and +refresh races. -Copy these into your project. They have no dependencies beyond Node's built-in -`node:crypto` module. +Use `NodeOAuthClient`, including for email-first login: ```typescript -import * as crypto from 'node:crypto' - -// --------------------------------------------------------------------------- -// PKCE helpers -// --------------------------------------------------------------------------- - -/** Generate a random code verifier for PKCE. Store this in your session. */ -export function generateCodeVerifier(): string { - return crypto.randomBytes(32).toString('base64url') -} - -/** Derive the code challenge to send to the auth server. */ -export function generateCodeChallenge(verifier: string): string { - return crypto.createHash('sha256').update(verifier).digest('base64url') -} - -/** Generate a random state value. Store this in your session. */ -export function generateState(): string { - return crypto.randomBytes(16).toString('base64url') -} - -// --------------------------------------------------------------------------- -// DPoP helpers -// --------------------------------------------------------------------------- - -/** - * Generate a fresh DPoP key pair. - * - * Call this once per login attempt. Store `privateJwk` in your session cookie - * so the callback handler can restore the key pair for token exchange. - * Never reuse a key pair across different login flows. - */ -export function generateDpopKeyPair() { - const { publicKey, privateKey } = crypto.generateKeyPairSync('ec', { - namedCurve: 'P-256', - }) - return { - privateKey, - publicJwk: publicKey.export({ format: 'jwk' }), - privateJwk: privateKey.export({ format: 'jwk' }), - } -} - -/** - * Restore a DPoP key pair from a serialized private JWK. - * - * Use this in the callback handler to recover the key pair from the session - * cookie that was set during the login handler. - */ -export function restoreDpopKeyPair(privateJwk: crypto.JsonWebKey) { - const privateKey = crypto.createPrivateKey({ key: privateJwk, format: 'jwk' }) - const publicKey = crypto.createPublicKey(privateKey) - return { - privateKey, - publicJwk: publicKey.export({ format: 'jwk' }), - } -} - -/** - * Create a DPoP proof JWT for a single HTTP request. - * - * Create a new proof for every request — they are single-use by design. - * - * @param opts.nonce - Include when the server returned a `dpop-nonce` header. - * @param opts.accessToken - Include when making API calls with an access token. - */ -export function createDpopProof(opts: { - privateKey: crypto.KeyObject - jwk: object - method: string - url: string - nonce?: string - accessToken?: string -}): string { - const header = { alg: 'ES256', typ: 'dpop+jwt', jwk: opts.jwk } - - const payload: Record = { - jti: crypto.randomUUID(), - htm: opts.method, - htu: opts.url, - iat: Math.floor(Date.now() / 1000), - } - if (opts.nonce) payload.nonce = opts.nonce - if (opts.accessToken) { - payload.ath = crypto - .createHash('sha256') - .update(opts.accessToken) - .digest('base64url') - } - - const headerB64 = Buffer.from(JSON.stringify(header)).toString('base64url') - const payloadB64 = Buffer.from(JSON.stringify(payload)).toString('base64url') - const signingInput = `${headerB64}.${payloadB64}` - const sig = crypto.sign('sha256', Buffer.from(signingInput), opts.privateKey) - return `${signingInput}.${derToRaw(sig).toString('base64url')}` -} - -// --------------------------------------------------------------------------- -// Internal helpers -// --------------------------------------------------------------------------- - -/** - * Convert a DER-encoded ECDSA signature to raw r||s format. - * Required because Node's crypto.sign() outputs DER, but JWTs expect raw. - */ -function derToRaw(der: Buffer): Buffer { - // DER: 0x30 [total-len] 0x02 [r-len] [r] 0x02 [s-len] [s] - let offset = 2 - if (der[1]! > 0x80) offset += der[1]! - 0x80 // long-form length - - offset++ // skip 0x02 tag - const rLen = der[offset++]! - let r = der.subarray(offset, offset + rLen) - offset += rLen - - offset++ // skip 0x02 tag - const sLen = der[offset++]! - let s = der.subarray(offset, offset + sLen) - - // DER may pad with a leading zero byte to indicate positive — strip it - if (r.length > 32) r = r.subarray(r.length - 32) - if (s.length > 32) s = s.subarray(s.length - 32) - - const raw = Buffer.alloc(64) - r.copy(raw, 32 - r.length) - s.copy(raw, 64 - s.length) - return raw -} +const url = await client.authorize(epdsUrl) +url.searchParams.set('login_hint', email) ``` -## Nonce retry pattern (Flow 1 only) - -ePDS always rejects the first DPoP proof with a `400` and a `dpop-nonce` -header. This is standard behaviour. For Flow 1 (hand-rolled), wrap every -PAR and token request in this retry loop. Flow 2 does not need this — -`NodeOAuthClient` handles nonce retry internally. +The SDK handles PKCE, DPoP key generation and proof signing, nonce retry, PAR, +token exchange, client assertions, and persisted OAuth state. -```typescript -async function fetchWithDpopRetry( - url: string, - body: URLSearchParams, - privateKey: crypto.KeyObject, - publicJwk: object, -): Promise { - const makeProof = (nonce?: string) => - createDpopProof({ privateKey, jwk: publicJwk, method: 'POST', url, nonce }) - - let res = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - DPoP: makeProof(), - }, - body: body.toString(), - }) - - if (!res.ok) { - const nonce = res.headers.get('dpop-nonce') - if (nonce && res.status === 400) { - res = await fetch(url, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - DPoP: makeProof(nonce), - }, - body: body.toString(), - }) - } - } - - return res -} -``` +See [`../SKILL.md`](../SKILL.md) for maintained integration guidance. diff --git a/.agents/skills/epds-login/references/flows.md b/.agents/skills/epds-login/references/flows.md index 4b6a9a15..fcdc55a4 100644 --- a/.agents/skills/epds-login/references/flows.md +++ b/.agents/skills/epds-login/references/flows.md @@ -1,352 +1,29 @@ -# Flow Walkthroughs +# Legacy Flow Walkthrough -## Which flow should I use? +This reference previously documented a hand-rolled email-first OAuth flow. +Do not use it for new integrations. -| Flow | App provides | User experience | Implementation | -| ---- | ----------------------- | ---------------------------- | -------------------- | -| 1 | Email address | OTP screen immediately | Hand-rolled PAR/DPoP | -| 2 | Nothing, handle, or DID | Depends on input (see below) | `NodeOAuthClient` | - -**Flow 1** is the only flow that requires hand-rolled PAR and DPoP code. -`@atproto/oauth-client-node`'s `authorize()` method explicitly omits -`login_hint` from its options — the library resolves handles and DIDs -itself and overrides the hint. Since Flow 1 needs to pass a raw email -as `login_hint` on the auth redirect URL (not in the PAR body), it -cannot use the library. - -**Flow 2** should use `NodeOAuthClient`, which handles PAR, PKCE, DPoP, -nonce retry, token exchange, and session management automatically. It -covers three input variants — all use the same code path: - -- **No identifier** — pass the PDS URL; auth server shows its own email form -- **Handle** — pass `alice.pds.example.com`; auth server resolves it, sends OTP directly -- **DID** — pass `did:plc:abc123...`; same as handle - -Both flows end the same way: the user enters an OTP, ePDS redirects -back to your app, and your callback receives an authorization code to -exchange for tokens. - ---- - -## Flow 2 — Using `NodeOAuthClient` - -### Setup - -See the [SKILL.md quick start](../SKILL.md) for `NodeOAuthClient` -construction (client metadata, keyset, stores). - -### Login handler - -```typescript -// No identifier — auth server shows email form -const authUrl = await client.authorize('https://pds.example.com') - -// With a handle — auth server resolves and sends OTP -const authUrl = await client.authorize('alice.pds.example.com') - -// With a DID — same behaviour as handle -const authUrl = await client.authorize('did:plc:abc123...') -``` - -The `authorize()` method: - -1. Resolves the input (handle → DID → PDS endpoint, or uses the PDS URL directly) -2. Sends a PAR request with PKCE and DPoP (including nonce retry) -3. Stores the OAuth state in your `stateStore` -4. Returns the authorization URL to redirect the user to - -Redirect the user's browser to the returned URL. - -### Callback handler - -```typescript -// GET /api/oauth/callback?code=...&state=...&iss=... -const { session, state } = await client.callback( - new URLSearchParams(callbackQueryString), -) - -const userDid = session.did // e.g. "did:plc:abc123..." -// session.fetchHandler() returns an authenticated fetch for AT Protocol API calls -``` - -The `callback()` method: - -1. Validates the state against your `stateStore` -2. Exchanges the authorization code for tokens (with DPoP) -3. Stores the session in your `sessionStore` -4. Returns the `OAuthSession` and original state - -### Restoring a session - -```typescript -const session = await client.restore(userDid) -// Use session.fetchHandler() for API calls -// session.signOut() to end the session -``` - -### Step-by-step (no identifier) - -1. User clicks "Sign in" in your app -2. Your login handler calls `client.authorize('https://pds.example.com')` -3. Library sends PAR request, gets `request_uri`, stores state -4. Your app redirects browser to the returned auth URL -5. Auth server shows email form -6. User enters email, receives OTP, enters it -7. **New users only**: ePDS shows a handle picker -8. Auth server redirects to your `redirect_uri` with `?code=&state=&iss=` -9. Your callback calls `client.callback(params)` — library handles token exchange -10. User is logged in - -When passing a handle or DID instead of the PDS URL, the flow is -identical except the user skips the email form (the auth server resolves -the handle/DID to an email and sends the OTP directly). - ---- - -## Flow 1 — Hand-rolled (email `login_hint`) - -Flow 1 requires hand-rolled PAR and token exchange because the library -cannot pass a raw email as `login_hint`. - -### Step-by-step - -1. User enters their email in your app and clicks "Sign in" -2. Your login handler: - - a. Generates a DPoP key pair and PKCE verifier (see [dpop-pkce.md](dpop-pkce.md)) - - b. POSTs to `/oauth/par` (with DPoP nonce retry) - - c. Stores DPoP private key, code verifier, and state in a signed session cookie - - d. Redirects the browser to `/oauth/authorize?...&login_hint=` - -3. The auth server sees the email, immediately sends the OTP, and shows the - code entry screen (no email form shown) -4. User reads OTP from email and submits it -5. Auth server verifies the code -6. **New users only**: ePDS shows a handle picker -7. ePDS redirects back to your app's callback URL -8. Your callback handler exchanges the code for tokens (with DPoP nonce retry) -9. User is logged in - -### Login handler code +Use `NodeOAuthClient` for email, hosted-form, handle, and DID login. For an +email-first flow: ```typescript -import { - generateDpopKeyPair, - generateCodeVerifier, - generateCodeChallenge, - generateState, - createDpopProof, -} from './auth-helpers' - -const PAR_ENDPOINT = 'https://pds.example.com/oauth/par' -const AUTH_ENDPOINT = 'https://auth.pds.example.com/oauth/authorize' -const CLIENT_ID = 'https://yourapp.example.com/client-metadata.json' -const REDIRECT_URI = 'https://yourapp.example.com/api/oauth/callback' - -export async function handleLogin(email: string) { - const { privateKey, publicJwk, privateJwk } = generateDpopKeyPair() - const codeVerifier = generateCodeVerifier() - const codeChallenge = generateCodeChallenge(codeVerifier) - const state = generateState() - - const parBody = new URLSearchParams({ - client_id: CLIENT_ID, - redirect_uri: REDIRECT_URI, - response_type: 'code', - scope: - 'atproto include:org.hypercerts.authWrite include:app.certified.authWrite', - state, - code_challenge: codeChallenge, - code_challenge_method: 'S256', - }) - - // ePDS always requires a nonce on the first attempt — retry automatically - const makeProof = (nonce?: string) => - createDpopProof({ - privateKey, - jwk: publicJwk, - method: 'POST', - url: PAR_ENDPOINT, - nonce, - }) - - let parRes = await fetch(PAR_ENDPOINT, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - DPoP: makeProof(), - }, - body: parBody.toString(), - }) - if (!parRes.ok) { - const nonce = parRes.headers.get('dpop-nonce') - if (nonce && parRes.status === 400) { - parRes = await fetch(PAR_ENDPOINT, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - DPoP: makeProof(nonce), - }, - body: parBody.toString(), - }) - } - } - if (!parRes.ok) throw new Error(`PAR failed: ${parRes.status}`) - const { request_uri } = await parRes.json() - - // Save session data in a signed cookie for the callback - setSessionCookie({ state, codeVerifier, dpopPrivateJwk: privateJwk }) - - // Redirect user to auth server — include login_hint so OTP screen shows immediately - const authUrl = new URL(AUTH_ENDPOINT) - authUrl.searchParams.set('client_id', CLIENT_ID) - authUrl.searchParams.set('request_uri', request_uri) - authUrl.searchParams.set('login_hint', email) - return redirect(authUrl.toString()) -} +const url = await client.authorize(epdsUrl) +url.searchParams.set('login_hint', email) ``` -### Callback handler code +When fresh authentication is required, also pass `prompt=login` to +`authorize()` so it enters PAR, then append it to returned URL for ePDS +auth-service session handling: ```typescript -import { restoreDpopKeyPair, createDpopProof } from './auth-helpers' - -const TOKEN_ENDPOINT = 'https://pds.example.com/oauth/token' - -export async function handleCallback(params: { code: string; state: string }) { - const session = getSessionFromCookie() - if (params.state !== session.state) throw new Error('state mismatch') - - const { privateKey, publicJwk } = restoreDpopKeyPair(session.dpopPrivateJwk) - - const tokenBody = new URLSearchParams({ - grant_type: 'authorization_code', - code: params.code, - redirect_uri: REDIRECT_URI, - client_id: CLIENT_ID, - code_verifier: session.codeVerifier, - }) - - const makeProof = (nonce?: string) => - createDpopProof({ - privateKey, - jwk: publicJwk, - method: 'POST', - url: TOKEN_ENDPOINT, - nonce, - }) - - let tokenRes = await fetch(TOKEN_ENDPOINT, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - DPoP: makeProof(), - }, - body: tokenBody.toString(), - }) - if (!tokenRes.ok) { - const nonce = tokenRes.headers.get('dpop-nonce') - if (nonce) { - tokenRes = await fetch(TOKEN_ENDPOINT, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - DPoP: makeProof(nonce), - }, - body: tokenBody.toString(), - }) - } - } - if (!tokenRes.ok) throw new Error(`Token exchange failed: ${tokenRes.status}`) - - const { access_token, sub: userDid } = await tokenRes.json() - - // userDid is e.g. "did:plc:abc123..." — resolve to handle via PLC directory - const plcRes = await fetch(`https://plc.directory/${userDid}`) - const { alsoKnownAs } = await plcRes.json() - const handle = alsoKnownAs - ?.find((u: string) => u.startsWith('at://')) - ?.replace('at://', '') - - // Store access_token and userDid in your session — user is now logged in -} -``` - ---- - -## Sequence diagrams - -### Flow 1 — App passes email as `login_hint` - -```mermaid -sequenceDiagram - actor User - participant App as Your App - participant PDS as ePDS - participant Auth as Auth Server - participant Inbox as User's Inbox - - User->>App: Enters email, clicks Sign in - App->>PDS: POST /oauth/par (DPoP + PKCE) - PDS-->>App: { request_uri } - App-->>User: Redirect to /oauth/authorize?...&login_hint=email - - User->>Auth: GET /oauth/authorize - Auth->>Inbox: Sends 8-digit OTP - Auth-->>User: Shows OTP entry screen - - User->>Auth: Submits OTP code - Auth-->>User: Redirect to your callback URL - - User->>App: GET /api/oauth/callback?code=... - App->>PDS: POST /oauth/token (DPoP + code_verifier) - PDS-->>App: { access_token, user DID } - App-->>User: Logged in +const url = await client.authorize(epdsUrl, { prompt: 'login' }) +url.searchParams.set('login_hint', email) +url.searchParams.set('prompt', 'login') ``` -### Flow 2 — No identifier (via `NodeOAuthClient`) - -```mermaid -sequenceDiagram - actor User - participant App as Your App - participant Lib as NodeOAuthClient - participant PDS as ePDS - participant Auth as Auth Server - participant Inbox as User's Inbox - - User->>App: Clicks Sign in - App->>Lib: authorize('https://pds.example.com') - Lib->>PDS: POST /oauth/par (auto DPoP + PKCE) - PDS-->>Lib: { request_uri } - Lib-->>App: auth URL - App-->>User: Redirect to auth URL - - User->>Auth: GET /oauth/authorize - Auth-->>User: Shows email input form - - User->>Auth: Submits email - Auth->>Inbox: Sends 8-digit OTP - Auth-->>User: Shows OTP entry screen - - User->>Auth: Submits OTP code - Auth-->>User: Redirect to callback URL - - User->>App: GET /api/oauth/callback?code=...&state=... - App->>Lib: callback(params) - Lib->>PDS: POST /oauth/token (auto DPoP) - PDS-->>Lib: { tokens } - Lib-->>App: { session, state } - App-->>User: Logged in -``` - -### Flow 2 with handle or DID - -Same as the diagram above except: +Email `login_hint` belongs only on the returned browser authorization URL, +never in the PAR body. `NodeOAuthClient` must retain ownership of PAR, PKCE, +DPoP, nonce retry, token exchange, and OAuth state/session storage. -- `authorize('alice.pds.example.com')` or `authorize('did:plc:abc123...')` -- Library resolves the identity to the user's PDS -- Auth server skips the email form and sends OTP directly +See [`../SKILL.md`](../SKILL.md) for maintained flow, callback, and application +session guidance. From 84253202c30886c94eaa60908650099275c552fc Mon Sep 17 00:00:00 2001 From: kzoeps Date: Fri, 4 Sep 2026 17:12:56 +0600 Subject: [PATCH 78/84] epds-login: correct OAuth integration guidance --- .agents/skills/epds-login/SKILL.md | 103 +++--------------- .../epds-login/references/client-metadata.md | 14 ++- .../skills/epds-login/references/dpop-pkce.md | 10 +- .agents/skills/epds-login/references/flows.md | 25 +++-- 4 files changed, 44 insertions(+), 108 deletions(-) diff --git a/.agents/skills/epds-login/SKILL.md b/.agents/skills/epds-login/SKILL.md index 547b7c34..33c619c3 100644 --- a/.agents/skills/epds-login/SKILL.md +++ b/.agents/skills/epds-login/SKILL.md @@ -1,21 +1,18 @@ --- name: epds-login -description: Implement AT Protocol OAuth login against an ePDS instance with @atproto/oauth-client-node. Covers email-first OTP, hosted email entry, handle/DID login, client metadata, application sessions, callbacks, and debugging. Use when building passwordless or social login against ePDS, configuring confidential/public OAuth clients, or integrating NodeOAuthClient. +description: Implement AT Protocol OAuth login against an ePDS instance with @atproto/oauth-client-node. Covers email-first OTP, hosted email entry, handle/DID login, client metadata, callbacks, and debugging. Use when building passwordless login against ePDS, configuring confidential/public OAuth clients, or integrating NodeOAuthClient. --- # Implementing ePDS Login -ePDS lets users sign in to AT Protocol apps using email OTP, Google, GitHub, -or another provider supported by Better Auth. New users receive a DID, handle, -and data repository automatically. +ePDS lets users sign in to AT Protocol apps using email OTP. New users receive +a DID, handle, and data repository automatically. -From a client's perspective, ePDS uses standard AT Protocol OAuth with PAR, -PKCE, and DPoP. Use `@atproto/oauth-client-node` for every login variant. Do -not implement those protocol mechanisms yourself. - -The ePDS repository demonstrates server behavior and UI. Its `packages/demo` -OAuth client still contains a legacy hand-rolled flow; do not copy that client -implementation. +Earlier versions of this skill recommended a hand-rolled OAuth flow. That +guidance is deprecated in favor of `@atproto/oauth-client-node`, which handles +PAR, PKCE, DPoP, token exchange, and OAuth state. The repository's +`packages/demo` client still contains the earlier implementation and is not the +current integration reference. For protocol-level guidance beyond ePDS specifics—granular scope design, identity verification after token exchange, session storage, and refresh-token @@ -216,62 +213,10 @@ await client.callback(callbackParams, { }) ``` -### 5. Create an Application Session - -OAuth session storage and browser application sessions have different jobs: - -- `NodeOAuthClient.sessionStore` stores OAuth tokens and DPoP material by DID. -- Application session store maps a random ID to the signed-in DID. -- Browser cookie contains only that random ID, never tokens, email, or DID. - -```typescript -interface AppSession { - did: string -} - -const APP_SESSION_TTL_SECONDS = 60 * 60 * 24 * 30 -const { session: oauthSession } = await client.callback(callbackParams) -const appSessionId = crypto.randomUUID() - -await appSessionStore.set( - appSessionId, - { did: oauthSession.did }, - { ttlSeconds: APP_SESSION_TTL_SECONDS }, -) - -setCookie('app_session', appSessionId, { - httpOnly: true, - secure: process.env.NODE_ENV === 'production', - sameSite: 'lax', - path: '/', - maxAge: APP_SESSION_TTL_SECONDS, -}) -``` +Application-session management is a generic AT Protocol OAuth concern; follow +the `atproto-oauth` skill for that guidance. -The cookie is opaque: its random value reveals no identity or credentials. -`httpOnly` prevents browser JavaScript from reading it. On later requests, load -the DID server-side and restore the OAuth session: - -```typescript -const appSession = await appSessionStore.get(appSessionId) -if (!appSession) throw new Error('Application session expired; sign in again') - -const oauthSession = await client.restore(appSession.did) -``` - -Generate session IDs with a cryptographically secure random source. Rotate the -ID after authentication, delete it during logout, and protect state-changing -routes against CSRF. Do not treat `httpOnly` alone as complete session security. - -`NodeOAuthClient` stores OAuth credentials by DID. Multiple opaque application -sessions for one DID therefore share one underlying OAuth session; they are not -independent device credentials. A later login may replace that stored OAuth -session. Deleting one application session should normally delete only its random -ID. Calling OAuth `signOut()` revokes shared credentials and may affect other -application sessions for that DID. Choose and document intended multi-device -logout behavior. - -### 6. Serve Metadata and JWKS +### 5. Serve Metadata and JWKS ```typescript app.get('/client-metadata.json', (_request, response) => { @@ -293,22 +238,6 @@ remote ePDS cannot fetch an endpoint bound only to your local machine; use an HTTPS tunnel or deployed development URL. Loopback clients follow separate AT Protocol metadata rules and are not confidential clients. -## Do Not Hand-Roll OAuth Primitives - -Do not manually implement: - -- PKCE verifier or challenge generation -- DPoP key generation, proof JWTs, or nonce retry -- PAR requests -- authorization-code token exchange -- `private_key_jwt` client assertions -- authorization-server discovery -- OAuth state/session object construction - -`NodeOAuthClient` already coordinates these operations and their persisted -state. Reimplementing only part of that lifecycle risks mismatched keys, -incorrect issuer or audience values, replay vulnerabilities, and broken refresh. - ## Forcing Fresh Sign-In Existing ePDS cookies may lead to account reuse or an account chooser. Put @@ -385,8 +314,10 @@ debugging. ## Handles -New users choose a handle during signup. Local part must be 5–20 characters, -alphanumeric with hyphens. Handles are not derived from email addresses. +New users receive a handle during signup. With `epds_handle_mode` set to +`picker` or `picker-with-random`, they can choose a local part of 5–20 +characters using letters, numbers, and hyphens. With `random`, ePDS assigns the +handle without showing a picker. Handles are not derived from email addresses. Do not expect `OAuthSession` to expose `session.handle`; its stable identity is `session.did`. Resolve current handle through an AT Protocol identity resolver @@ -401,5 +332,5 @@ separate PDS and auth-service hostnames; that is normal. ## Reference Files - [Client metadata](references/client-metadata.md): confidential/public clients, JWKS, branding, email templates -- [Legacy flow walkthrough](references/flows.md): historical hand-rolled flow; do not use for new integrations -- [Legacy PKCE/DPoP helpers](references/dpop-pkce.md): historical reference only; prefer `NodeOAuthClient` +- [Flow walkthrough](references/flows.md): hosted-form and email-first login examples +- [PKCE and DPoP](references/dpop-pkce.md): responsibilities handled by `NodeOAuthClient` diff --git a/.agents/skills/epds-login/references/client-metadata.md b/.agents/skills/epds-login/references/client-metadata.md index afb94f39..ab258af9 100644 --- a/.agents/skills/epds-login/references/client-metadata.md +++ b/.agents/skills/epds-login/references/client-metadata.md @@ -283,9 +283,11 @@ allow-list. ## Local development A remote ePDS must be able to fetch a discoverable client metadata URL and any -remote `jwks_uri`. It cannot reach a server bound only to your localhost. Use an -HTTPS tunnel or deployed development URL for a confidential client. - -AT Protocol loopback clients use a separate metadata encoding and public-client -rules. Do not assume an ordinary `http://localhost/.../client-metadata.json` URL -will be fetched successfully by a remote ePDS. +remote `jwks_uri`. It cannot reach a server bound only to your localhost. Every +ordinary web client, including a public client, must use an HTTPS tunnel or +deployed HTTPS development URL. + +AT Protocol loopback clients are the only exception. They use a separate +metadata encoding and public-client rules. Do not assume an ordinary +`http://localhost/.../client-metadata.json` URL will be fetched successfully by +a remote ePDS. diff --git a/.agents/skills/epds-login/references/dpop-pkce.md b/.agents/skills/epds-login/references/dpop-pkce.md index 3c27fe3c..c73585db 100644 --- a/.agents/skills/epds-login/references/dpop-pkce.md +++ b/.agents/skills/epds-login/references/dpop-pkce.md @@ -1,11 +1,7 @@ -# Legacy PKCE and DPoP Helpers +# PKCE and DPoP -This reference previously contained hand-rolled PKCE and DPoP helpers. They were -removed because partial OAuth implementations risk mismatched keys, invalid -issuer or audience values, replay vulnerabilities, broken nonce handling, and -refresh races. - -Use `NodeOAuthClient`, including for email-first login: +`NodeOAuthClient` handles PKCE, DPoP, and the associated OAuth state, including +for email-first login: ```typescript const url = await client.authorize(epdsUrl) diff --git a/.agents/skills/epds-login/references/flows.md b/.agents/skills/epds-login/references/flows.md index fcdc55a4..23266383 100644 --- a/.agents/skills/epds-login/references/flows.md +++ b/.agents/skills/epds-login/references/flows.md @@ -1,10 +1,19 @@ -# Legacy Flow Walkthrough +# Flow Walkthrough -This reference previously documented a hand-rolled email-first OAuth flow. -Do not use it for new integrations. +Use `NodeOAuthClient` for email, hosted-form, handle, and DID login. -Use `NodeOAuthClient` for email, hosted-form, handle, and DID login. For an -email-first flow: +For a hosted-form flow, where the client does not collect the user's email: + +```typescript +const url = await client.authorize(epdsUrl) +// Redirect without adding login_hint. +``` + +The ePDS presents its hosted login form, where the user supplies their email. +“No email” means the client does not collect the email before redirecting; it +does not mean the account has no email. + +For an email-first flow: ```typescript const url = await client.authorize(epdsUrl) @@ -22,8 +31,6 @@ url.searchParams.set('prompt', 'login') ``` Email `login_hint` belongs only on the returned browser authorization URL, -never in the PAR body. `NodeOAuthClient` must retain ownership of PAR, PKCE, -DPoP, nonce retry, token exchange, and OAuth state/session storage. +never in the PAR body. -See [`../SKILL.md`](../SKILL.md) for maintained flow, callback, and application -session guidance. +See [`../SKILL.md`](../SKILL.md) for maintained flow and callback guidance. From afe6be7daac04193a2a59fe3cf934ee659e74768 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Mon, 14 Sep 2026 16:27:34 +0600 Subject: [PATCH 79/84] pds: upgrade upstream PDS and OAuth packages --- .changeset/pds-0-5-34-upgrade.md | 13 + docs/design/pds-0.5.34-upgrade.md | 535 ++++ docs/design/pds-white-boxing.md | 9 +- docs/design/testing-gaps.md | 2 +- .../session-reuse-bugs.steps.ts | 4 +- e2e/step-definitions/session-reuse.steps.ts | 12 +- packages/pds-core/package.json | 6 +- packages/pds-core/src/chooser-enrichment.ts | 22 +- packages/shared/package.json | 3 +- packages/shared/src/__tests__/version.test.ts | 74 + packages/shared/src/version.ts | 5 +- pnpm-lock.yaml | 2570 ++++++++++++++--- vitest.config.ts | 15 +- 13 files changed, 2775 insertions(+), 495 deletions(-) create mode 100644 .changeset/pds-0-5-34-upgrade.md create mode 100644 docs/design/pds-0.5.34-upgrade.md create mode 100644 packages/shared/src/__tests__/version.test.ts diff --git a/.changeset/pds-0-5-34-upgrade.md b/.changeset/pds-0-5-34-upgrade.md new file mode 100644 index 00000000..3f904e94 --- /dev/null +++ b/.changeset/pds-0-5-34-upgrade.md @@ -0,0 +1,13 @@ +--- +'ePDS': minor +--- + +Update the account selection and approval screens while keeping account switching in the email sign-in flow. + +**Affects:** End users, Client app developers, Operators + +**End users:** Selecting “Another account” continues to open the email sign-in form instead of the server's password form. + +**Client app developers:** Review consent and account-picker branding against the updated screens; CSS targeting old gray/slate utility classes may no longer match. Unresolved supported handles now return `HandleNotFound`, and OAuth/account-management request bodies over 100 KiB return HTTP 413. + +**Operators:** This upgrades PDS to 0.5.34 and shared safe-fetch to 0.4.0. Rebuild both core and auth images, verify startup with their bundled Node/Undici versions, and validate outbound identity/service resolution against the stricter URL policy before rollout. No new upstream database migrations were found in the assessed release range; rehearse rollback against copied data rather than assuming runtime compatibility. diff --git a/docs/design/pds-0.5.34-upgrade.md b/docs/design/pds-0.5.34-upgrade.md new file mode 100644 index 00000000..59f738a2 --- /dev/null +++ b/docs/design/pds-0.5.34-upgrade.md @@ -0,0 +1,535 @@ +# PDS 0.5.34 Upgrade Assessment + +## Status + +This document records the read-only assessment of upgrading ePDS from +`@atproto/pds` 0.5.23 to 0.5.34. It is an implementation guide, not evidence +that the upgrade has been completed. + +The assessment compared local upstream release tags and ePDS source. No package +installation, build, test, database operation, deployment, or production +configuration inspection was performed. + +## Source versions + +| Package | Current ePDS version | Target companion version | +| ----------------------------- | -------------------- | --------------------------------------- | +| `@atproto/pds` | 0.5.23 | 0.5.34 | +| `@atproto/oauth-provider` | 0.21.1 | 0.22.8 | +| `@atproto/oauth-provider-ui` | 0.8.9 | 0.10.3 | +| `@atproto/oauth-provider-api` | 0.7.7 | 0.8.3 through the target provider graph | + +Local upstream release commits used by the assessment: + +- PDS 0.5.23: `5dc34f6846bad7c6f649e0a733186f4a6a467fcb` +- PDS 0.5.34: `7ca16cc6989f8247637615aca17c5abb911b8fb1` +- OAuth provider UI 0.10.3: + `1f2604f0546754aef1cf93b7ac3855cf31751b1c` + +The committed ePDS lockfile is the authority for the current dependency graph. +Local `node_modules` links were stale during the assessment and pointed to +older package versions, so runtime observations from that installation are not +trustworthy until dependencies are installed cleanly. + +## Overall conclusion + +The upgrade appears manageable, but it is not a PDS-only version bump. + +Required work: + +1. Align PDS, OAuth provider, and OAuth provider UI versions. +2. Fix the `Another account` control lookup for the redesigned provider UI. +3. Render and validate upstream authorization pages with ePDS and client + branding. +4. Add a small Ma Earth compatibility patch only if rendering confirms visual + regressions. +5. Run full OAuth, PDS, container, and rollback validation. + +No new upstream database migration was found in this release range. Core OAuth +provider methods used by ePDS remain compatible in the inspected source, but +many are private upstream interfaces and still require runtime validation. + +## Required dependency alignment + +Upgrade these direct dependencies together: + +```json +{ + "@atproto/pds": "0.5.34", + "@atproto/oauth-provider": "0.22.8", + "@atproto/oauth-provider-ui": "0.10.3" +} +``` + +The current `0.x` caret ranges for provider and provider UI do not accept the +target companion versions. Updating only PDS can create a mixed package graph. + +### `HandleUnavailableError` constructor mismatch + +ePDS imports `HandleUnavailableError` and uses `instanceof` while handling +account creation. If PDS creates the error through provider 0.22.8 but ePDS +checks it against provider 0.21.1, the constructors differ. A taken or reserved +handle can fall through to the generic account-creation error instead of +redirecting to handle selection. + +Alignment of the direct provider version is required. This is separate from the +upstream `HandleNotFound` response change for handle resolution. + +### Provider UI manifest mismatch + +ePDS preview routes resolve the provider UI bundle manifest through ePDS's +direct dependency. The provider resolves its own manifest separately. Mixed UI +versions can make preview HTML reference old asset filenames while the provider +serves new assets. + +Align the direct provider UI dependency and verify chooser and consent preview +assets after lockfile regeneration. + +### Shared safe-fetch dependency + +Also upgrade the direct `@atproto-labs/fetch-node` dependency in +`packages/shared/package.json` from `^0.2.0` to `0.4.0`. The target PDS 0.5.34 +release changelog lists fetch-node 0.4.0; 0.3.6 belongs to the current dependency +graph and is not the target companion version. Updating PDS alone does not +upgrade shared's independent `^0.2.0` dependency. + +Source comparison found no incompatible use of the wrapper options in ePDS. +The shared wrapper retains HTTPS-only/private-IP protection by default, a +5-second timeout, a 100 KB response limit, and unconditional redirect rejection. +It serves client metadata, remote email templates, and preview validation. +Auth-service requests directly to `PDS_INTERNAL_URL` use ordinary fetch and are +not intercepted by this wrapper. + +Fetch-node 0.4.0 applies URL policy at dispatch time, including every redirect +hop, and keeps the dispatcher guard when private-IP checks are relaxed. Because +ePDS already rejects redirects, hop validation is defense-in-depth for the +shared wrapper. Custom request dispatchers are rejected even in relaxed mode; +no current ePDS caller supplies one. The removed +`dangerouslyForceKeepAliveAgent` option is also unused. Current callers catch +fetch failures generically, so changes to error wrapping do not establish a +functional break. + +Validate metadata, template and preview success/fallback behavior, HTTP and +private-IP rejection, redirect rejection, timeout and streamed response limits, +and local `EPDS_ALLOW_PRIVATE_IPS=true` behavior. This is source assessment, +not runtime validation under the new dependency graph. + +### Optional dependency cleanup + +Multiple `@atproto/syntax` and `@atproto/crypto` versions exist in the current +graph. No concrete constructor, singleton, or branded-type failure was found for +these packages. Deduplication or removal of an unused direct crypto dependency +is optional unless typechecking or runtime tests prove otherwise. + +## Confirmed provider UI break + +### `Another account` lookup + +Provider UI 0.8.9 renders the control through an element with +`role="button"`. UI 0.10.3 renders a native ` // The aria-label copy changed in oauth-provider-ui 0.8 ("Login to // account…" -> "Sign in to an account…", visible text "Another - // account" -> "Select another account"). Match either aria-label, - // then fall back to either visible-text variant scoped to role=button - // (div OR button) so a future copy tweak degrades rather than breaks. + // account" -> "Select another account"). Match either aria-label + // on either element type, then fall back to either visible-text + // variant on either element type so a future copy tweak degrades + // rather than breaks. var ARIA_LABELS = [ 'Sign in to an account that is not listed', 'Login to account that is not listed', @@ -290,11 +294,15 @@ export function buildChooserEnrichmentScript(): string { var btn = null; for (var a = 0; a < ARIA_LABELS.length && !btn; a++) { btn = root.querySelector( - '[role="button"][aria-label="' + ARIA_LABELS[a] + '"]', + 'button[aria-label="' + + ARIA_LABELS[a] + + '"], [role="button"][aria-label="' + + ARIA_LABELS[a] + + '"]', ); } if (!btn) { - var candidates = root.querySelectorAll('[role="button"]'); + var candidates = root.querySelectorAll('button, [role="button"]'); for (var i = 0; i < candidates.length; i++) { if (TEXTS.indexOf((candidates[i].textContent || '').trim()) >= 0) { btn = candidates[i]; diff --git a/packages/shared/package.json b/packages/shared/package.json index 3d4e9f32..5a2d9abf 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -2,6 +2,7 @@ "name": "@certified-app/shared", "description": "Shared database, crypto, HTML, and utility code for ePDS services.", "private": true, + "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", "scripts": { @@ -9,7 +10,7 @@ "dev": "tsc --build --watch" }, "dependencies": { - "@atproto-labs/fetch-node": "^0.2.0", + "@atproto-labs/fetch-node": "0.4.0", "@atproto/syntax": "0.4.3", "better-sqlite3": "^12.0.0", "pino": "^10.3.1" diff --git a/packages/shared/src/__tests__/version.test.ts b/packages/shared/src/__tests__/version.test.ts new file mode 100644 index 00000000..84c020cd --- /dev/null +++ b/packages/shared/src/__tests__/version.test.ts @@ -0,0 +1,74 @@ +import type * as fs from 'node:fs' +import { join } from 'node:path' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const readFileSyncMock = vi.hoisted(() => vi.fn()) + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, readFileSync: readFileSyncMock } +}) + +import { getEpdsVersion } from '../version.js' + +describe('getEpdsVersion', () => { + const originalVersion = process.env.EPDS_VERSION + + beforeEach(() => { + delete process.env.EPDS_VERSION + readFileSyncMock.mockReset() + }) + + afterAll(() => { + if (originalVersion === undefined) delete process.env.EPDS_VERSION + else process.env.EPDS_VERSION = originalVersion + }) + + it('prefers the EPDS_VERSION environment override', () => { + process.env.EPDS_VERSION = '0.5.34+test' + + expect(getEpdsVersion()).toBe('0.5.34+test') + expect(readFileSyncMock).not.toHaveBeenCalled() + }) + + it('uses the build-time .epds-version file before package.json', () => { + readFileSyncMock.mockImplementation((path: unknown) => { + if (String(path).endsWith('.epds-version')) return '0.5.34+build\n' + throw new Error(`Unexpected read: ${String(path)}`) + }) + + expect(getEpdsVersion()).toBe('0.5.34+build') + expect(readFileSyncMock).toHaveBeenCalledWith( + expect.stringMatching(/(?:^|\/)\.epds-version$/), + 'utf8', + ) + }) + + it('falls back to the root package.json when .epds-version is absent', () => { + readFileSyncMock + .mockImplementationOnce(() => { + throw new Error('missing .epds-version') + }) + .mockImplementationOnce(() => JSON.stringify({ version: '0.8.0' })) + + expect(getEpdsVersion()).toBe('0.8.0') + expect(readFileSyncMock).toHaveBeenNthCalledWith( + 2, + join(process.cwd(), 'package.json'), + 'utf8', + ) + }) + + it('falls back to the root package.json when .epds-version is empty', () => { + readFileSyncMock + .mockImplementationOnce(() => ' \n') + .mockImplementationOnce(() => JSON.stringify({ version: '0.8.0' })) + + expect(getEpdsVersion()).toBe('0.8.0') + expect(readFileSyncMock).toHaveBeenNthCalledWith( + 2, + join(process.cwd(), 'package.json'), + 'utf8', + ) + }) +}) diff --git a/packages/shared/src/version.ts b/packages/shared/src/version.ts index 495038f8..cd252365 100644 --- a/packages/shared/src/version.ts +++ b/packages/shared/src/version.ts @@ -1,8 +1,9 @@ import { readFileSync } from 'node:fs' -import { join } from 'node:path' +import { dirname, join } from 'node:path' +import { fileURLToPath } from 'node:url' // packages/shared/{src,dist}/ → repo root is always three levels up -const ROOT = join(__dirname, '..', '..', '..') +const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..', '..') /** * Resolve the ePDS version string. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a56903ae..ad533c09 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,7 +32,7 @@ importers: version: 7.0.5 '@vitest/coverage-v8': specifier: ^4.1.0 - version: 4.1.0(vitest@4.1.0(@types/node@22.19.11)(vite@7.3.1(@types/node@22.19.11)(tsx@4.21.0)(yaml@2.8.3))) + version: 4.1.0(vitest@4.1.0(@opentelemetry/api@1.9.1)(@types/node@22.19.11)(vite@7.3.1(@types/node@22.19.11)(tsx@4.21.0)(yaml@2.8.3))) dotenv: specifier: ^17.3.1 version: 17.3.1 @@ -59,7 +59,7 @@ importers: version: 8.56.0(eslint@10.0.1)(typescript@5.9.3) vitest: specifier: ^4.1.0 - version: 4.1.0(@types/node@22.19.11)(vite@7.3.1(@types/node@22.19.11)(tsx@4.21.0)(yaml@2.8.3)) + version: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@22.19.11)(vite@7.3.1(@types/node@22.19.11)(tsx@4.21.0)(yaml@2.8.3)) packages/auth-service: dependencies: @@ -68,7 +68,7 @@ importers: version: link:../shared better-auth: specifier: ^1.4.18 - version: 1.4.18(better-sqlite3@12.6.2)(next@15.5.12(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(@types/node@20.19.33)(vite@7.3.1(@types/node@20.19.33)(tsx@4.21.0)(yaml@2.8.3))) + version: 1.4.18(better-sqlite3@12.6.2)(next@15.5.12(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(@opentelemetry/api@1.9.1)(@types/node@20.19.33)(vite@7.3.1(@types/node@20.19.33)(tsx@4.21.0)(yaml@2.8.3))) better-sqlite3: specifier: ^12.0.0 version: 12.6.2 @@ -117,7 +117,7 @@ importers: version: 6.1.3 next: specifier: ^15.1.0 - version: 15.5.12(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + version: 15.5.12(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: specifier: ^19.0.0 version: 19.2.4 @@ -144,14 +144,14 @@ importers: specifier: ^0.4.5 version: 0.4.5 '@atproto/oauth-provider': - specifier: ^0.21.1 - version: 0.21.1 + specifier: 0.22.8 + version: 0.22.8 '@atproto/oauth-provider-ui': - specifier: ^0.8.9 - version: 0.8.9 + specifier: 0.10.3 + version: 0.10.3 '@atproto/pds': - specifier: ^0.5.23 - version: 0.5.23 + specifier: 0.5.34 + version: 0.5.34 '@atproto/syntax': specifier: ^0.7.2 version: 0.7.2 @@ -190,8 +190,8 @@ importers: packages/shared: dependencies: '@atproto-labs/fetch-node': - specifier: ^0.2.0 - version: 0.2.0 + specifier: 0.4.0 + version: 0.4.0 '@atproto/syntax': specifier: 0.4.3 version: 0.4.3 @@ -214,63 +214,57 @@ importers: packages: - '@atproto-labs/did-resolver@0.3.6': - resolution: {integrity: sha512-5Bb3xfDgOw4r+AjAVuZvk7lWQlUkq7dxJoRMHI46UTLEt99CQGqvKOBbA0xAXehYc76LJyaG9fulVQuWmVuqUA==} + '@atproto-labs/did-resolver@0.3.9': + resolution: {integrity: sha512-KBgUdQQ76XL/xjQbuMfVBOEa5jaragR9NPq1uMoM4iXKnW5GGcEMHMPu+WrTxYNBku0b7MhPUfCs1pKtyLBP6g==} engines: {node: '>=22'} - '@atproto-labs/fetch-node@0.2.0': - resolution: {integrity: sha512-Krq09nH/aeoiU2s9xdHA0FjTEFWG9B5FFenipv1iRixCcPc7V3DhTNDawxG9gI8Ny0k4dBVS9WTRN/IDzBx86Q==} - engines: {node: '>=18.7.0'} - - '@atproto-labs/fetch-node@0.3.6': - resolution: {integrity: sha512-sGBMlRMCRu0xAnesRe7WCe9e2TMdVMd4I5WrNa43dFs14KxBzHXaPLDM1ZiYe/wGf7midNG3md5/BXvdC6vPCA==} + '@atproto-labs/fetch-node@0.4.0': + resolution: {integrity: sha512-5Yi8fz/JDGEoObRgRJuglcodB/MJeQnnoqokOGSQK5ItISObSttbGIgHE1UMU1njcwxbvKtbGMWHUCRgqML2Dw==} engines: {node: '>=22'} - '@atproto-labs/fetch@0.2.3': - resolution: {integrity: sha512-NZtbJOCbxKUFRFKMpamT38PUQMY0hX0p7TG5AEYOPhZKZEP7dHZ1K2s1aB8MdVH0qxmqX7nQleNrrvLf09Zfdw==} - - '@atproto-labs/fetch@0.3.5': - resolution: {integrity: sha512-iDFZEoNqfL17EVKE+uNzdUD7YF8LWhEhxeBtP6x+PNuAxoXv3ip9vLmB8nNzNxFwVn++FipfDtSiPqmziv1bdA==} + '@atproto-labs/fetch@0.3.6': + resolution: {integrity: sha512-ro/J/cnqIp4bnSzDl8N3SkruvQcE3Pze+1H70mKdFfyFcdhPr+7O4lggl4VCbhI84Yqgz47c0xC6F/Yl39SxDA==} engines: {node: '>=22'} - '@atproto-labs/pipe@0.1.1': - resolution: {integrity: sha512-hdNw2oUs2B6BN1lp+32pF7cp8EMKuIN5Qok2Vvv/aOpG/3tNSJ9YkvfI0k6Zd188LeDDYRUpYpxcoFIcGH/FNg==} + '@atproto-labs/opentelemetry-node@0.2.1': + resolution: {integrity: sha512-/xShvM6fi0YrpcDf9Z6hRBjzm95YxMGepJIzSb9JOQmcAcp2Vp0KkglI0YhBrrFjcWljLInTy36k7bO4orB6Jw==} + engines: {node: '>=22'} '@atproto-labs/pipe@0.2.4': resolution: {integrity: sha512-n67jCcrC+ouAeO10cWkpPzzLMlDi/lDCU30Us+LGqhOPhT6c4t5ASdBLQi9W3jUQtRzQBt3G9zipF+xKWNvVbw==} engines: {node: '>=22'} - '@atproto-labs/simple-store-memory@0.2.5': - resolution: {integrity: sha512-Qme5VqkHKZA0w218/3pncvT+KYQzVdtP4aF72eCswh53SLrdGCG6F/NtllmJuYmp9uPGnHdql/Yt0BY1slAKjQ==} + '@atproto-labs/simple-store-memory@0.2.6': + resolution: {integrity: sha512-DD1v7MEfYF3BAcEpMTTpzfBLWLoI2HuyBhku2YpjHoqPrRrhCtE1alkxfPHjtjJVIjuU/XNU0cF/wB3+5FiKsA==} engines: {node: '>=22'} - '@atproto-labs/simple-store-redis@0.1.5': - resolution: {integrity: sha512-pr8RflgKBiKFTl+wjXs2M+gYX2kEf0K/QOTn5qy5Opy6mLGhoxcbKvc1VWG6nNTu33RdjrZTbeOAQG4ZjbpGOg==} + '@atproto-labs/simple-store-redis@0.1.6': + resolution: {integrity: sha512-KNRvO4/DDAYJtn45YlSu3kZZmtv08wteziLzL6aEtpUPxolfbHBcVSnsWm8d/EyUAZMDqv/BSJANiaMRgPCQhg==} engines: {node: '>=22'} peerDependencies: ioredis: ^5.3.2 - '@atproto-labs/simple-store@0.5.0': - resolution: {integrity: sha512-tA/BmCahnrLV+seUZzjkC1xVZQbjogiooQ5C4kkC6we2mP68v7SreQTuv/XPhK2wsSES6c1SYCgDkkLlDTzALA==} + '@atproto-labs/simple-store@0.5.1': + resolution: {integrity: sha512-vfvoDhu6ds6BT3Pqe+d2/LBU1WpDRtt69y6zltlfMCoucPm82m1j50/Wb6xTvv+oZ+2j0JyZVXsmXQ12SWYQJg==} engines: {node: '>=22'} - '@atproto-labs/xrpc-utils@0.1.12': - resolution: {integrity: sha512-ecAsf6eRT2e8hn0jQdXcGREFhsKyqthVa2oN10Rlj2T8WflRVeV5PtiaxB4/L7Zre6eUN1xQbQtAcnIKL+Uccw==} + '@atproto-labs/xrpc-utils@0.1.22': + resolution: {integrity: sha512-F46syACWCpU4stp0cS8HlUxC7LC+aoPcdDp426555yVlDybHZWhmBpmS6D+p1KbbNZ3B6zzDQlVmNUhja1n16w==} engines: {node: '>=22'} - '@atproto/aws@0.3.9': - resolution: {integrity: sha512-UPx2vXBiD0Ly4CRpGDXN5GzF/3ASc+TB3zsp/JFgYJqgBd1uuYGJgEkzsJ/dcnQHQbiPyniOkwCuUyFjkUUXhg==} + '@atproto/aws@0.3.17': + resolution: {integrity: sha512-rEpjX/JjvrE2doo4piLzatkcAyGPid8qk8pKVJ9YNyN6R+0dpSWLFsOcbBLPnEPwgcjbDtHoZ9v0Ul5M5rIYLA==} engines: {node: '>=22'} - '@atproto/common-web@0.5.7': - resolution: {integrity: sha512-lvU8lFO5UByacsl3AReXkpTWjcPBS8mBqt40B45HwCt+s58a+xT/YI/+AUpmzSmsPVZCpeCmP6wgsOSC8RILXg==} + '@atproto/common-web@0.5.12': + resolution: {integrity: sha512-FumNnUOUsk68gP+jU0sXY2wY2piqFV9/qU2YaoQq/IZknhJBlLYCzjQXXCN0FCzxAZWD0YUzgydeyuRvFoAxfA==} engines: {node: '>=22'} '@atproto/common@0.1.1': resolution: {integrity: sha512-GYwot5wF/z8iYGSPjrLHuratLc0CVgovmwfJss7+BUOB6y2/Vw8+1Vw0n9DDI0gb5vmx3UI8z0uJgC8aa8yuJg==} - '@atproto/common@0.7.3': - resolution: {integrity: sha512-pmu+B6n83exJDJSCXazVVOpk+RuXsMK8VxKUnkiSXHP03+nRDDeNXaWEp1COBBU2dfAzYZZB6k4M8G3gq3zkcQ==} + '@atproto/common@0.8.3': + resolution: {integrity: sha512-y4rE89CNn8DTtv+rJggcRnqv/nZRD7aZ/Vwf0GRvYe/790RItMDoeGORdXLsh9wwhkg1hvWfnPADE/XwF13NMA==} engines: {node: '>=22'} '@atproto/crypto@0.1.0': @@ -280,16 +274,16 @@ packages: resolution: {integrity: sha512-n40aKkMoCatP0u9Yvhrdk6fXyOHFDDbkdm4h4HCyWW+KlKl8iXfD5iV+ECq+w5BM+QH25aIpt3/j6EUNerhLxw==} engines: {node: '>=18.7.0'} - '@atproto/crypto@0.5.4': - resolution: {integrity: sha512-UR0BkuYNYuFtw+dA+y/oPPxzX0SWRnGJ+1Cfh/jGP1BvjRUyezK3omjpeLms5fYrXbM9vnfX+ckJFJqkBgLOdw==} + '@atproto/crypto@0.5.5': + resolution: {integrity: sha512-m33okNTaZWEXEjp9ZIDM1qIxwfrlxciL2d61BLUo4sX+j5PaHbrlePS2cMJ8o+C/9BcZJWpJXhjC9tMsgJD6/w==} engines: {node: '>=22'} - '@atproto/did@0.5.4': - resolution: {integrity: sha512-BlnwQ+obL+4ZA71KH/EzZ3TY+cpSxnLiUI85mjBJIQwvDF/oN2sQA18wIj9jpduviIt2b/cMtlJuzHjzBkfXvw==} + '@atproto/did@0.5.5': + resolution: {integrity: sha512-ibCxTPsPQtrtX+iidnE0bkMBHuoWFBdVfPFfCAlUKY6HfkqdUUxItzvQu9AMucYDhP6vJ15HLVj+QhNY2jtIvw==} engines: {node: '>=22'} - '@atproto/identity@0.5.7': - resolution: {integrity: sha512-a2/pFBZH3l1olWx1VJgPhnTSV47rOyTfeoaWHtxQqZcpjx3pbXaaNMTGvUZblgTpVj7y9rhFL2ieU7YrNDt3+Q==} + '@atproto/identity@0.5.13': + resolution: {integrity: sha512-YZO+1x/QPzfYXiIiq54Q3jVUG0t93bF23yWcaa1wBljqNDfqAbFSd01uQeKYSHsfetaUGb4MKuYzlgzwl/gLrA==} engines: {node: '>=22'} '@atproto/jwk-jose@0.2.4': @@ -300,77 +294,77 @@ packages: resolution: {integrity: sha512-tq7TUDmNfe1yDfpRgdGQMJdl9TUlJmREQNCag9yg5w8Evu+TOiFiLgiOCbo7X4ouRPSgd1DpOzXbUa8UyKKMZA==} engines: {node: '>=22'} - '@atproto/lex-builder@0.1.9': - resolution: {integrity: sha512-NfvrxND66jibS9BDRCaKFVjubeaCenVz0j++yFGv10L+POu1XU6cc4naIjnLg9uvZrB4GXG8fvZJpyvfNghn0A==} + '@atproto/lex-builder@0.1.15': + resolution: {integrity: sha512-+muLNaHCr/UQIV6Q6i7xH5kjLhYNhy76PZFm0Y3e4gZke7k4asRdXnuSofqd2nfA0cJhps8ZttJ3BqKKo5DcIQ==} engines: {node: '>=22'} - '@atproto/lex-cbor@0.1.5': - resolution: {integrity: sha512-JKDVu1/+QrRXy+cM72Ze7N7N3GUxhs3+wmwf3DyRQbDO0bdseC8mvZ9TYXhthM1Y92+pnCHIPci41etx8wz75Q==} + '@atproto/lex-cbor@0.1.6': + resolution: {integrity: sha512-PxLQy7jzV8u9zTGURNF8ZczLPk+ZH+WNx/2z/z7RacJbu7sWNpx26U2pC7cIkWtt4jyDaruaLhssw3w3atLotA==} engines: {node: '>=22'} - '@atproto/lex-client@0.3.1': - resolution: {integrity: sha512-GK5rhqOtIhzxd2BEByQTs1/K7u1QJ+0NkkksV94HgrTAHG+0S3DBg5P5+RPQu2uhHIDKFzXFhP5L+fFJubX4GQ==} + '@atproto/lex-client@0.3.6': + resolution: {integrity: sha512-8tPDTyCdMN8qtyODl00oVcBlh26a0HeurOdtJnz9Fcq+dBuRo2XuEcxCUfvwtulOY+zghyw3zAG6fqwxUe2lvw==} engines: {node: '>=22'} - '@atproto/lex-data@0.1.6': - resolution: {integrity: sha512-rvovRrYWDEuerX+ZeQbdI5dKs0bYvuMjhwDOtT+pkOvdY4MkfN41DJCz/RAYVAQ7noPdaalB6pAX9euukheE/w==} + '@atproto/lex-data@0.1.7': + resolution: {integrity: sha512-kW/dPLqo/WgCLV+XESR4JKwV6c1rZWJGOfuPupZGTjEDAKoBbKXdaEzX9/1vKQYbZ9U3j0DS/n7OFFK7wBugyQ==} engines: {node: '>=22'} - '@atproto/lex-document@0.1.7': - resolution: {integrity: sha512-DcJagPJpjg3MTPhpDQRkjJDmSDPZy4F9MRftsni757qLZ1NCE5dYjfg9XX6DPya6/JvscAV7qga/CJrHeUFyiw==} + '@atproto/lex-document@0.1.11': + resolution: {integrity: sha512-0zOSenJ2QqFxryHpGQi6j6IA2HJdYkWTcmdwXdN+1LFHwL09MWbaCn5+8PEPXCeXwgQdz57kR6mMPAfSyqnW5g==} engines: {node: '>=22'} - '@atproto/lex-installer@0.1.11': - resolution: {integrity: sha512-7sH0GcRKneaR7GBdgkM/f59huz2jebTrsfgQ50RonfZKPqfO/6uNwIjsJb8r6BjSxM7I7Os2z6LWdPVMYyoYDg==} + '@atproto/lex-installer@0.1.20': + resolution: {integrity: sha512-IrC+vmWRYxD1xc7B2/3Xh94R6AGIYkd9mVTPuf0h39uf2FUkchEYCUQwnIQEU7LINPyXpaoyrCsFhlXE8NLMrw==} engines: {node: '>=22'} - '@atproto/lex-json@0.1.5': - resolution: {integrity: sha512-qOp6+Za5p/xDXACYIP3vySbKaf9CJDwiZEN78ghPT1gRoJohKG3ONt/WmvOxBVJqc6VTRaqEpbG9vX9oLF2qsA==} + '@atproto/lex-json@0.1.6': + resolution: {integrity: sha512-mvrAd0lbyuecIHjyld8QN6MN6CBf4j0GCxLzegsvLh0SvDf+GbYWklkcQqmITL44yFQOwmA/QNIQj0Uvh7+R/g==} engines: {node: '>=22'} - '@atproto/lex-resolver@0.2.4': - resolution: {integrity: sha512-S7lxWp8CT5Par3eTq+VUXVpu6W5zHarq9oKZ6UneN0925Ey6juJ/kZI0jucxIpV/DXM+EF3GntOMbackFPxieg==} + '@atproto/lex-resolver@0.2.13': + resolution: {integrity: sha512-Pj2ix5wGX4N6vSRuozpr/XibU4TKw97WKFKL4/W7qpohAck3esQhmRFJ3LbSTqWprACnPtqt2CmnXspcqSY1Sg==} engines: {node: '>=22'} - '@atproto/lex-schema@0.2.3': - resolution: {integrity: sha512-218xvlL7EdEdREY7ieAhaVPnBeGAf5h3tNkzE+X1Ob4tFLrWwA1sOQqliIURG9owE8v0ctqf9Uftx15eN0U0qQ==} + '@atproto/lex-schema@0.2.7': + resolution: {integrity: sha512-K+Ce0SyFPcP19tSo4AhLLEOYnJzKh6sVzhGuB4ZCSto9tHgaVZ3iTQT895IPviZSPjd3+XIRA/rpqWSA2wPGEg==} engines: {node: '>=22'} - '@atproto/lex@0.3.2': - resolution: {integrity: sha512-/HcdVoq2BtkHxdWRo3vCciT6Cge9LfHVYtatnBrGlgHw6LYvjG2LHxmquhfPj26mKa3n2zVM8+3ia9RpoemSxw==} + '@atproto/lex@0.3.11': + resolution: {integrity: sha512-cHH5h6RwOkf2qxKSu90WoOhmLIWZyQlgT2nGu2XXdk9f7TRNonCJ+77wt4pMTxfzK6f+Okfes9stWPZD3tuVTg==} engines: {node: '>=22'} hasBin: true - '@atproto/lexicon@0.7.9': - resolution: {integrity: sha512-/NoDu8xE0EmT0uCgTy03r9FIEjBSNN+xVHacJ+h+MW4MTMgegLdKlLnCySl2dzhYPu3/TK28CY4xIeEHz//GAw==} + '@atproto/lexicon@0.7.14': + resolution: {integrity: sha512-hDG2+61JTrvxDFtoojDFxKmZkFVl/lR/jFi82gSN0zNDjr+dlpUGKlG9MoPBkUKK83+Pi+I2jZ55xXboJq6JTA==} engines: {node: '>=22'} - '@atproto/oauth-provider-api@0.7.7': - resolution: {integrity: sha512-CCC/p2LWqrCwPbvDFiNkZekk9nwqyldOAK0nr8OSIjOZ1eMQ9M335ghwD5+zm/TS55Z/Bf1AxlqX0JIK60dg/Q==} + '@atproto/oauth-provider-api@0.8.3': + resolution: {integrity: sha512-8/rIPfW7aUSMS6CTaGwFoMX6RG5Km31eTkMHMt0axeQbUIhLfIFbhRR9qWNutcbrQjfwZqAlbPy0aQLLV6ON6g==} engines: {node: '>=22'} - '@atproto/oauth-provider-ui@0.8.9': - resolution: {integrity: sha512-RKSEoIRv2aCcJPBP0b1k1hxPgu8p8+L1JmCxw7jLF9IbOWe2zSsNMbwQbE4tygKwIClx3JSUL1OCYZgbylsz8g==} + '@atproto/oauth-provider-ui@0.10.3': + resolution: {integrity: sha512-VBWyaRyU+N8GEnhlVfWGISoPfCmIlnAVDy+gDFb4PwVgewiACp9R0orRd3fXQNPLalBJwF/JcZDBG4MSqV21rg==} engines: {node: '>=22'} - '@atproto/oauth-provider@0.21.1': - resolution: {integrity: sha512-C3O82D0bAD0oGy/h6Afwgnx2Z+A+IBf+lS3uz6ow9edZEk61QVc4PM9z+uJtusZ0ZJqUCrFAwT3lkDFUcSEPYQ==} + '@atproto/oauth-provider@0.22.8': + resolution: {integrity: sha512-2vxpL6mrPNazZGiPej8rSul4dGY1Sj2UzmKZrqsx6EDL+JQmmjjk1gHeiXjHZCat9SFCA7OuuUlX22nZpLRXaw==} engines: {node: '>=22'} - '@atproto/oauth-scopes@0.5.7': - resolution: {integrity: sha512-RWhz93nR8R0R2vbwjbUemktjsVIkjrw6IAlNlcSQyzFvmsI/grWeZIHdRLGK1CC1WIQlyDYPWG8hChlQq63Iyw==} + '@atproto/oauth-scopes@0.5.12': + resolution: {integrity: sha512-IGMq3w0qSizOejm6MRoGVMGbqyAPafEAXnlqn2x2AtDlPIBD1jtBE9ekWBXoaawkkLeeGZP+NECoiotBgBJHxA==} engines: {node: '>=22'} - '@atproto/oauth-types@0.7.5': - resolution: {integrity: sha512-x75O0HsKB1IGfBikAQrrTX6EL8Rt4Q0+wMcwhZQRGPk/N/WqbYbsW3Powj4R8ZJKSfWCpVfaw32Piu1pTi891Q==} + '@atproto/oauth-types@0.7.6': + resolution: {integrity: sha512-FgFe2mJOUgJSQBxKBwLWv+lFyDxBnlqA+wfSLabCNnabtNuSO3UQI0CGI72K7/OQRQvdwkcYQMoUuw3Rqex/mg==} engines: {node: '>=22'} - '@atproto/pds@0.5.23': - resolution: {integrity: sha512-3zE1dDEuYsLP6p1F1pYUa2Dwh7mJ3sudhG2gcaVaHc48YyvInrpn4wI62sg7vL4KBdAJdmmaUh1lSiVMd6SSgw==} + '@atproto/pds@0.5.34': + resolution: {integrity: sha512-gyRclVmhjPiAfGauX+46lQd3OeoXBsipGnIGFN+1cKFsi2kgzk+fzjI4haMDVyYuhuHwkxfaVd5422kePDXDIA==} engines: {node: '>=22'} - '@atproto/repo@0.10.7': - resolution: {integrity: sha512-nMV3y4d/KERrt9VOe3hcKyl/FVQeo6+54YKCt6bLiMLl8CPEtjwdVGwWlSkekaCoayjL0rTQ4zvjmKs+vCA1Ew==} + '@atproto/repo@0.10.14': + resolution: {integrity: sha512-RzQjMefu4piO5uA/rDVGu3Re9f6EXOUdMSI/s5fboRY1DlsHYJxlnSvoHD3S0YmECsd3lpWftqz6boB37vCnuA==} engines: {node: '>=22'} '@atproto/syntax@0.4.3': @@ -380,16 +374,20 @@ packages: resolution: {integrity: sha512-tZ1Tr0R9pK4bI4Zs69t29cjMlCFQvRNBeNkqJC5pGeNuCt64D0eoF7s/AlqeVmYppClmQ0xJquggGgsMDP6j7w==} engines: {node: '>=22'} - '@atproto/ws-client@0.1.8': - resolution: {integrity: sha512-EX9SpoOJY4QhDKXe/olJw2h+Lj10SMd0ErDSE8pf3aFmg4MEEA+KBMg7HO8b9U6SNSS6n2nl7GPe37D3rIJ5zw==} + '@atproto/syntax@0.7.6': + resolution: {integrity: sha512-luKQTcWw1H1jLmYvX34ldBqNjz2orF082njmcF9fxbWTqVhsO+TpY0FHRKVPoV7dwL0Y3L16DNz1ma0LFGH25g==} engines: {node: '>=22'} - '@atproto/xrpc-server@0.11.13': - resolution: {integrity: sha512-i8wM2Cx022QhVqkafy+qMFZ8aGFb66siF3JpFch0R8/qVNKT8UqBlKzc7TI0Ef2Yh6c3BExVQ5kyWwdzL952zw==} + '@atproto/ws-client@0.2.1': + resolution: {integrity: sha512-HCGkqH/Ksxpgg/xCyIvH5hK6o8Lg2PxcEA790GCEV9MspCZB/xIesHZVgJN0cEvklGe+6pNCOZE1B5kj9q1ONA==} engines: {node: '>=22'} - '@atproto/xrpc@0.8.8': - resolution: {integrity: sha512-5cZIaP775QpTZjSgqvt++ZEe5ZmSHFYNEMwa9IjXLkannitBPfEhIaGuHOI7If3Juc/sIkqVbAxedLSsRk1F6Q==} + '@atproto/xrpc-server@0.13.1': + resolution: {integrity: sha512-Wbv+HE2d26iV2NNnZw+L5rhzl+O/x8yNmHw6ZRW71+ocAt3lkGKzR2vcNc2HpQveZFdNPxJT+MuY+6czq3gNwg==} + engines: {node: '>=22'} + + '@atproto/xrpc@0.8.13': + resolution: {integrity: sha512-htpi93/Zqyk5WsT+RrWemxMcJFJx2M4VT8i8avuWJTyr7AZ42qvPsIdVsaPdOOS3rZwodq/SdZXJFgspJ4yc5g==} engines: {node: '>=22'} '@aws-sdk/checksums@3.1000.24': @@ -411,6 +409,9 @@ packages: '@aws-sdk/core@3.977.4': resolution: {integrity: sha512-CEkcQlMOQJCvul60U7wdAOACjtdgFWDsfJI+6wUOGdhGNV2lGbuJpi/R50QLpFG3Tp+sQxa/RmzC3X7KHbhuTA==} engines: {node: '>=20.0.0'} + deprecated: |- + Deprecated due to Document number parsing bug in JSON, see + https://github.com/aws/aws-sdk-js-v3/issues/8246. Newer version available. '@aws-sdk/credential-provider-env@3.972.65': resolution: {integrity: sha512-lJT2aRw9wCV8jPHyFJjdZLD4HTydL6/22AnCSOB8e/LqOc55nEJGLHkJQeSxhn8QiqyjFwPKQFtMw0ovjRUY/g==} @@ -444,11 +445,11 @@ packages: resolution: {integrity: sha512-e4dwiRltGAaQ+2yxw57Hj0l/BF3BHiG14+QpYE7bGYBlpAq/fkIri2BDhjWon8c0mhhtd2txQBAkQb9BcTStFg==} engines: {node: '>=20.0.0'} - '@aws-sdk/lib-storage@3.1101.0': - resolution: {integrity: sha512-S2kYJzmX8a9SPgesOn8la1Ezuto8OqI4e85yNdiOJ/trLMrBxxEZoz8CtOk0uT3FjEJXVbYApzLNmVGVLvUwxA==} - engines: {node: '>=20.0.0'} + '@aws-sdk/lib-storage@3.879.0': + resolution: {integrity: sha512-FAb3vOfLIrf8lPuDoxKRu18DxXfQLEFm7MoXi0jd8ooFjD09jpVCQGNrRuMCqc688wrx7zJSovWObtn4LRjvrg==} + engines: {node: '>=18.0.0'} peerDependencies: - '@aws-sdk/client-s3': ^3.1101.0 + '@aws-sdk/client-s3': ^3.879.0 '@aws-sdk/middleware-sdk-s3@3.972.70': resolution: {integrity: sha512-APdP0iODt39AkjCjzTFIoFrxDH/Cz3CpWRDKLcsJg7eOnfE1htkxL9BhDoe/xL7cXdoMwh2HBYv3DiT1uf64NQ==} @@ -528,6 +529,9 @@ packages: '@better-fetch/fetch@1.1.21': resolution: {integrity: sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A==} + '@borewit/text-codec@0.2.2': + resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} + '@changesets/apply-release-plan@7.1.0': resolution: {integrity: sha512-yq8ML3YS7koKQ/9bk1PqO0HMzApIFNwjlwCnwFEXMzNe8NpzeeYYKCmnhWJGkN8g7E51MnWaSbqRcTcdIxUgnQ==} @@ -858,6 +862,15 @@ packages: resolution: {integrity: sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@grpc/grpc-js@1.14.4': + resolution: {integrity: sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==} + engines: {node: '>=12.10.0'} + + '@grpc/proto-loader@0.8.1': + resolution: {integrity: sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg==} + engines: {node: '>=6'} + hasBin: true + '@hapi/accept@6.0.3': resolution: {integrity: sha512-p72f9k56EuF0n3MwlBNThyVE5PXX40g+aQh+C/xbKrfzahM2Oispv3AXmOIU51t3j77zay1qrX7IIziZXspMlw==} @@ -1059,6 +1072,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@js-sdsl/ordered-map@4.4.2': + resolution: {integrity: sha512-iUKgm52T8HOE/makSxjqoWhe95ZJA1/G1sYsGev2JDKUSS14KAgg1LHb+Ba+IPow0xflbnSkOsZcO08C7w1gYw==} + '@manypkg/find-root@1.1.0': resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} @@ -1150,6 +1166,495 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@opentelemetry/api-logs@0.208.0': + resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api-logs@0.219.0': + resolution: {integrity: sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/api@1.9.1': + resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==} + engines: {node: '>=8.0.0'} + + '@opentelemetry/auto-instrumentations-node@0.77.0': + resolution: {integrity: sha512-LkF930Cs+v+ZO/qV6LolbocvFkJJ812BBDyRNjQpwllBA+rFvGtP/voXPuh24QV3JKl5/3c3GulLHvMn8spqkQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.4.1 + '@opentelemetry/core': ^2.0.0 + + '@opentelemetry/configuration@0.219.0': + resolution: {integrity: sha512-wXZUYv4ngu43nA4WEhuXNacm46LW+17LRM8nKyIhBzroRA24PBYjMnakwzR/w777nFUB5xlgsYTTeuXxumZM1Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + + '@opentelemetry/context-async-hooks@2.8.0': + resolution: {integrity: sha512-/3FIraneMcng67SUJCxvyInk/oxzwsxyadufk0wwfOBLf5wqtAGX4MoQASwSbndBPeARzBryUM9Azr5kHIdWLw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.11.0': + resolution: {integrity: sha512-7YP44XH0tV6+Mb54x2YGf84i7yi+31MBZlE8JwvozkxyTvXbSp10X7cI7YE49ChJ3shMJoBmCJF3+1QFBJctGA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/core@2.8.0': + resolution: {integrity: sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/exporter-logs-otlp-grpc@0.219.0': + resolution: {integrity: sha512-7SvzDCIclHWAcCwZ1MTOLcwn4BVNPGI3QxS/DJraPNe1TTL+4TvUBq5zeQV8tsnYvtDN7wKW2qocVmaCP2l7sQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-logs-otlp-http@0.219.0': + resolution: {integrity: sha512-mhl2HL6GmZI8b8PwPfqMws/5ovJfbRTxwc9Y5agVVHiQ+e5SL1btsFr/kJDgt7YCexDtsUn5HAreHQO9szFS0A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-logs-otlp-proto@0.219.0': + resolution: {integrity: sha512-Ayw4Gf71PS9jhBVaYywa4WsajnqfDehMkTdVH3TSAVHqPcsAv/AhH/wTNRYNt99szeYr6Gbd/D6RjZD77wAxHg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-grpc@0.219.0': + resolution: {integrity: sha512-6LaaSrPxK5L55bXevWajvOMxGOpNm0n12tG53TeZaUeNzXwLPg6d2KCC1zAlGsojan+xRG71mA4Qqs9K2VVrKQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-http@0.219.0': + resolution: {integrity: sha512-6CaDRbMVHZSDWzNXwrR8y/H4B/Z1eMNnkHiPQlTx3Ojz2OHY4X/aff/UC4P/3pHUQSuTfi3oh2UsPPZppw+Vrg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-metrics-otlp-proto@0.219.0': + resolution: {integrity: sha512-DUS7XyIiEnoeccQUvuKy0G2/YqeKhpN8FVIrGbrLNIVMj10yeIFLRzRv0tibCI2kXXvlTTABVexGAk78wHk2ug==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-prometheus@0.219.0': + resolution: {integrity: sha512-TxOnJ85eWJY5JyOJsNMXiRTYlkDcOv0u3KbXEzWCc+tUS9sjL/BC6BcdxZ0B9r2OFVqsrZFXUzSD2sZUy42Ucw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-grpc@0.219.0': + resolution: {integrity: sha512-BkDNv1UD6BscW19MxbAxVmSYSSFuyeqR6buV2/HTYqA7GrR0EbTFzqG6h86T3PtXmpdbsWjMGLDdjG2rikG27Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-http@0.219.0': + resolution: {integrity: sha512-9t6SvBXXBEjOBcIzgozvBbd3jWrv3Gt3ngGhl1fhdZ/zRc7oZDVOFEqbi2zlBpW9BXhgDMKv422J0DL/3iQWfw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-trace-otlp-proto@0.219.0': + resolution: {integrity: sha512-lF/LUBfhOFmxJa+SQsLN7ziV4MHa2pyKgOM6JNehSOfU+npjM4gwm9oIKEJrzrWcexMcqydiyoFy0XCb1Ql3wQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/exporter-zipkin@2.8.0': + resolution: {integrity: sha512-Mj84UkEa17BK2o903VTXW3wM8CrSZexGs4tRGVZVIMM9ni1T6TuGx5IrRfoWKAbshx42D5/kc7YV+axypLPYyA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/instrumentation-amqplib@0.66.0': + resolution: {integrity: sha512-lyJgobzP0Ce+tRGOkdnrb60apfqU89xB9FeMTmo1TJU007KTMLiLFd4iCfTiBEXzBsVplx7kwrVN4FqGBUcD2Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-aws-lambda@0.71.0': + resolution: {integrity: sha512-9Sv6flQDeNNF6ZbiLgn+NYJa220yRZdDSIdgDZsqNubpDnYAPF33OHcQDlE8mFiaOq14mngoPS48JnYc8JT+Ug==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-aws-sdk@0.74.0': + resolution: {integrity: sha512-EMLUGgx2wJSXdwMEFdwd3IaW+mkUF8PENdzDFQ1FRdztzMG1d1XN76ORIjAMsXcKQISlRRcz93AWPQeBPn4EKA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-bunyan@0.64.0': + resolution: {integrity: sha512-jrRNFvpREutmpoWhk1T8n9q/RYdxbViXwSUPHN8yQR1bzgtwfOl/y8G/p8Xfudlky9GGsqw5WRc6q6QrfgF3pw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-cassandra-driver@0.64.0': + resolution: {integrity: sha512-KN+iOsmPI0nkX2lfgNgHBrHNaDuxDwIbwFrlvyrZ4bAT8bTKcCOXhne70O9qihM8+T1F4tjvPXpCfhKZbmsYiw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-connect@0.62.0': + resolution: {integrity: sha512-ZGV2sOyeffqMiqoh4RpsPTs/TUI5cCS+cEWvC9wUfvaEekR5omR6P/ClG+QDwasGBlKx2zfFPjSYPpzUo81XAw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-cucumber@0.35.0': + resolution: {integrity: sha512-H9NsbcFiVOFOcOu40+VOOjxdTeonu0VHI3mte5ie5ka/bazzIPGncQoHpL6su53C3/sgMdKjE6ZuwsB7Y8gQpA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/instrumentation-dataloader@0.36.0': + resolution: {integrity: sha512-gE0mTk+EnVaBN0mPRM1V6FqzQ9VckTp6ZFIssU5hxy+e3sqspYILBhV/0IHZ33qxGa3B9buLVZnuzVjUISfyoQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-dns@0.62.0': + resolution: {integrity: sha512-6v0X8wEqhIyv2b7MXhmipyCitJfm0vnF5mLBTWXojovoHp7P1EpN5sb12LgdhVlozhGwRZdzb6OvKUVsxKMD8g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-express@0.67.0': + resolution: {integrity: sha512-1WTWX2YNZIV+jPmEqdf/Vd1gHMT92TKA/0pf/iIItWhV6+RhzhnUUW4kSWQn8L3qVcgWEzQ860/ZOwaIwayi8A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-fs@0.38.0': + resolution: {integrity: sha512-6OBofWODg0RcPkl3bA+7yPf0e4Vi3O7ZxlFGY5QHPMMLxWVMnjWPEXQ2NlLBk19Sr8LcvEyyZOStdLJrT5o2dQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-generic-pool@0.62.0': + resolution: {integrity: sha512-IhO2y/MaK1oZ4EbUgdkSVT+XViPq86IAz4lwPe9jaba00M2yDWs8+f9xjcH3tK5IC3dZuAxXmifGmfvuJp92jw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-graphql@0.67.0': + resolution: {integrity: sha512-NMUmuhtYvv3AwkK4zsHQbTCXS81QS63hbNZRKfXc7W8f4KbWeKLmqKqvnfjUcqZoGS0eAw2kNKNYcbtbQ7baAg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-grpc@0.219.0': + resolution: {integrity: sha512-GyW1Kfbf7uiJXeBZovB/uXPUdkaZYWzB2ZPCdY2CU7+6V207u8wlCM+zVd3UwhEjOBrMbZAGq+m38aBEI/EVtA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-hapi@0.65.0': + resolution: {integrity: sha512-Whhas9iU0SfK/7XBcgCwfW5c9AaxjxtZpzW21t3Ml8XZ6Irc3hF36JDolMmFa7nUjML9n9bSUAZjwCgrK+2UdQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-host-metrics@0.2.0': + resolution: {integrity: sha512-NIttCEOLdg1ebbDiJpCf0Ly1OGIa10isesik+K2dnXy2P99q4muUFjpaLtTnhkENrt9SmR0Zrxzq7B+W/VNWyw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-http@0.219.0': + resolution: {integrity: sha512-nNt1fqpyah/OKjNHdEOu8xLwISppRU2qJuF8aR+fCcftVwdFkPgtworBLA+TI1HU2iF508jcQBF2gerWczJAXg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-ioredis@0.67.0': + resolution: {integrity: sha512-dv64vQ4aXbJvRMMAFrMUSzDeJrNv/uQMLjfaav4LHAOar7Xn08W3pkoYoYEnzq/n2+fGgG96rp9S0gQ+VnLDiw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-kafkajs@0.28.0': + resolution: {integrity: sha512-dztkg70nJds3Uc0Xo3NFlRqL5iYgGYWh8myuuGfRC6NnXJchY0Kw9QnBjTZxBSldXU+P6nv2snDVMmlxuy6fEw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-knex@0.63.0': + resolution: {integrity: sha512-XrpRahI/9vTrfSUfkhy8jGX8KMRKecQIPU9GyEZ8gkR030iJwQYsMmKGO5TK9R80cQGUopXwDvV55zmVNEkcPg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-koa@0.67.0': + resolution: {integrity: sha512-QOGY4mjqvF85LDcrzwrQXMcsu1wMTALeL1OHyTkLpN/7cnoDtv0W/qMBjHVq4IKYK6yDH4ZDNdwlonJPhwCGcw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.9.0 + + '@opentelemetry/instrumentation-lru-memoizer@0.63.0': + resolution: {integrity: sha512-DlZRNXfiosmREoLbEGbYuxF70cYXjrYqoaO1sJE167i1+ARWXTq0YMPJ97i53Ws/xkZWllJNYU4mpY2LM2yTlA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-memcached@0.62.0': + resolution: {integrity: sha512-kAajd/MtdRBh9PCrMM4fnusFRNPJDU1dv5w9cgnKtMfutRE6K03wBbGSeT3FD1M56sMYWVqPhiKUhfSZCMZrLg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mongodb@0.72.0': + resolution: {integrity: sha512-WYgGzvlHzdoxHlrhysYtjxE4RC23j/iFZ66hdMJuAoczOWoD/xb6LhRwaz4CM+LKyKtcSIadAjSUyGv2B+SNwg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mongoose@0.65.0': + resolution: {integrity: sha512-P0iT4oKuinEFZlTIKPJC5hhnmiV9De9lEiLkGzSwnNLdkyHIWug8BfRp5ZROrYAQ9mm47H+WLB/ozvUvgzEvEA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mysql2@0.65.0': + resolution: {integrity: sha512-Om6BJ/bmFBzNkGbAzj/UV5sCKX6jCGzhTl1Gqgtim/O0dnPE7F2zN65u16Fq3JgyypGAwT2iwh13tYdWkc8/RA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-mysql@0.65.0': + resolution: {integrity: sha512-sh1wRjFaTt+8DOhJ0134rFtJoHVUXKI8faIWTbj4zZNw847dzUgmkO8xOluJ9Css0JzT4vCeZofJmq9mQmmESA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-nestjs-core@0.65.0': + resolution: {integrity: sha512-/q8fN2M2zGl+gQRaF79dzqvyvVqHAI11c7xAQZy9W1eAtQScNwaQtPm0EUo5+aOgakaTksBrsiHUF0rQS24NsQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-net@0.63.0': + resolution: {integrity: sha512-fvmVdL4SlsYZf74mq6iLBPd6JJHRAe5utzN7Wt9e4nwa2S3pgExA9poOEmBL1CvIR47MceTA/A8vgVCF23ebbw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-openai@0.17.0': + resolution: {integrity: sha512-X3aEZnzj7SJkn1nmqEoD9IljmqENnGrd0vcJngcEApT8uqhFaeimONqKeIzKYvUgC7k4OkAUxC7yfXzxIK1KlA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-oracledb@0.44.0': + resolution: {integrity: sha512-ncEfP4rzuZXBHJJtzWLYctK4Pq/FvZRiASxlFY9CJG9kGjaFTfzBUys0NiP27alYPvpjj0uDAMheDk9nihO8ZA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-pg@0.71.0': + resolution: {integrity: sha512-jAhfyZeOkEKh3cQ5nm1tNWqHg7HFARyAe+p4BSoDHnB79c1woyEvDKqS11Hj/DjtceP+vrurIfcDs7Fqiy11mQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-pino@0.65.0': + resolution: {integrity: sha512-p6eh+NRmzi1F+/4QG7XDqRk4ICdCNTsM5vdcwUPnpMie2MddgY1/ENmWvCF9r0Kh6QQRA2kkpnhoJFaiRQ5kVw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-redis@0.67.0': + resolution: {integrity: sha512-TBjO4bPvfGH6bRjJJ+KrJhEqpHg3SWCFZ84MqsTWF639RQZmvkMhT2/DJsBAqqkq33IvHoDJysserqAfZN1s+Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-restify@0.64.0': + resolution: {integrity: sha512-X+gL4KpfPAx7Y07zKQVtyJPckhCcYJdSlEz0Kq0iR5nkQ8/AVWJ05/txl4voZbZdCuNdn+uLZTtJ60bAQwputQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-router@0.63.0': + resolution: {integrity: sha512-zIpsZSHGvbaqiEazwUm1X0FkPnLXIwZcL/llu/UplkeGNU58bsw70l2uMqNqTb7J+tzsBC09CLVPty5BhW3X9Q==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-runtime-node@0.32.0': + resolution: {integrity: sha512-Jo1jSgrHlah3lPpGNPsIpF0q52D5uSLRJrztWUoPc1/Tli2ZWZ+cArgNtcdmiLuKhW21MwYbbcrNw1fbOPeR3A==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-socket.io@0.66.0': + resolution: {integrity: sha512-XrZmLkFJktVLd3biQiP8BAhupRwPWLHGIiDCfyDAnWI6borIL0wD6BpwFKKPT/etpu4/5OaeAQ7qs/S+FY9nhQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-tedious@0.38.0': + resolution: {integrity: sha512-9sRWyIMBHDqJvxRVZ+eQ7jHJ9Iu+DapO27WLZbQF1nyD8xIvEMuDonQ/HnlQiaRdwnqFknXSQTFusJUT3mNVyQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation-undici@0.29.0': + resolution: {integrity: sha512-SnA+0XgGc595jtnwFVfWy7Vgfr5hle4D5YKIlm0U4z8aK9YoCZVUn1xAkVZ2evaJyykiDF50FBzr1XZ0uj8CPA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.7.0 + + '@opentelemetry/instrumentation-winston@0.63.0': + resolution: {integrity: sha512-NFMHLYODph0rWGfT/QLv75hCsu1sxVAV79L8HduBCMo181jOTSRZHaqxfrvDtFOsvgYBaiUBa7Ga50w47wM3FA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation@0.208.0': + resolution: {integrity: sha512-Eju0L4qWcQS+oXxi6pgh7zvE2byogAkcsVv0OjHF/97iOz1N/aKE6etSGowYkie+YA1uo6DNwdSxaaNnLvcRlA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/instrumentation@0.219.0': + resolution: {integrity: sha512-X5t7I8GyIO9rmGHwoedZLREpQqrF1WW2nxzNNym6HOKpFiE+rvqV3ngC0xcZVO2YwIGf3KKmRdWrYwdwz3H9RQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-exporter-base@0.219.0': + resolution: {integrity: sha512-zvIxQX/AZUVKDU+hCuYx+7UkiP7GRdnk1ZbFQRYzHvYp47cAWR4j3IhoPhV9KaeXEv2xdGq3IA6PnpzDmLcmSA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-grpc-exporter-base@0.219.0': + resolution: {integrity: sha512-iIk/s8QQu39zpTrRRmsW/Eg3SE2+Hg8tLWepr2FLRgmwUpNd0IpCTLJEHJ77hpt4hgIS8MAh44UYI4xQPZwWlw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/otlp-transformer@0.219.0': + resolution: {integrity: sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.3.0 + + '@opentelemetry/propagator-aws-xray@2.2.0': + resolution: {integrity: sha512-Yjvt2EjL+tfpkVOdKbhTPgpM4SIAez9nG6Q/QjQ3yfcJcjIWp59ph70SLfvmkSL6++3DCnuBG3iWcB18PwWavQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/propagator-b3@2.8.0': + resolution: {integrity: sha512-SazlvuSKi5533rPHTW2TwBwdMakhjZST4SYs0YauuvfGDkT13KbG1gJS75hV0uWVeevhtVP9sAIlaZLTHdSbMg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/propagator-jaeger@2.8.0': + resolution: {integrity: sha512-Xnz9zZvvQzUw+9DrOn0MomR7BxFCkA2pcfXBQuHC28ndJpSbjLs7knzYb05kw5SyCjSsEWombkZMgGcJSk8JVg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/redis-common@0.38.3': + resolution: {integrity: sha512-VCghU1JYs/4gP6Gqf/xro9MEsZ7LrMv2uONVsaESKL38ZOB9BqnI98FfS23wjMnHlpuE+TTaWSoAVNpTwYXzjw==} + engines: {node: ^18.19.0 || >=20.6.0} + + '@opentelemetry/resource-detector-alibaba-cloud@0.34.0': + resolution: {integrity: sha512-hUs4CK7MbRfffw8y5zR4Mo37MJRR3Zt8Ub4rgMkIk7gL8jozjV7k+zwIk9grz5kGAuD406BDDIghaY8090K4Zw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-aws@2.22.0': + resolution: {integrity: sha512-3kM+xfsSHs0hShUAx9IFhX5jniaXxJL3CTjbCMNCFS+EY7bswWbukV3au0jfqEgzeNxGxHEy8RLA1TOhAVLpTQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-azure@0.27.0': + resolution: {integrity: sha512-m6HCEmK12QpEcWbKtvGpQtoDVceXtpB14AaaW/t5f6gHeLuGq1QivkuohkvKMkxgAdwIHxEQ8qof3whWBpd8JA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-container@0.8.13': + resolution: {integrity: sha512-44AKD/FOpC4amfsZkKvtymUnThWLBPTrZavv5tpRPVE3REEtFCbQV0rMT5lTRXmDkg8rk8M/2RX49NZdTSnpjw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resource-detector-gcp@0.54.0': + resolution: {integrity: sha512-u+6sBzQO03QQGFhxjzFa7uNbH6iQpWTcrpWyomxuppH3AN/+1mm3DRVseS1CiRq9VBKrFO0UosWAdD7fWVUrrg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.0.0 + + '@opentelemetry/resources@2.11.0': + resolution: {integrity: sha512-Ie7+8q8MDF4FAEQCKVMTx3ReUvxiIAgIiiW3c9JdmP8+HMcDy20puT+AHjexnExgnbvBxjQ9fjkFDWrikJ2jQA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/resources@2.8.0': + resolution: {integrity: sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-logs@0.219.0': + resolution: {integrity: sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.4.0 <1.10.0' + + '@opentelemetry/sdk-metrics@2.8.0': + resolution: {integrity: sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.9.0 <1.10.0' + + '@opentelemetry/sdk-node@0.219.0': + resolution: {integrity: sha512-NWLpWLEb8gV3+JBHYoIrktbM385wyHpRJoh3J/4Q52d4PR+AlPMNGJT3DzBUrDSUEVbKAXoHR+EDAPxtiNcj8g==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-base@2.8.0': + resolution: {integrity: sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.3.0 <1.10.0' + + '@opentelemetry/sdk-trace-node@2.8.0': + resolution: {integrity: sha512-nZt9OGufioAc3AfoLTqA9bsAeaMJAictYDdI2VcNQ+PmT+3rfKjAZDZvgPfd8VPX0O5Bw1hdQF6kDK8VSpZiWg==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.10.0' + + '@opentelemetry/semantic-conventions@1.43.0': + resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==} + engines: {node: '>=14'} + + '@opentelemetry/sql-common@0.42.0': + resolution: {integrity: sha512-nwUwUU+8O8a4bnLqk6CodWeegGMEANgC94KTAhXcpGWLrW/2/hek/0ajNbjXnSOoNuCX+nteUPs46HFHhou9Xw==} + engines: {node: ^18.19.0 || >=20.6.0} + peerDependencies: + '@opentelemetry/api': ^1.1.0 + '@pinojs/redact@0.4.0': resolution: {integrity: sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==} @@ -1162,6 +1667,33 @@ packages: engines: {node: '>=18'} hasBin: true + '@protobufjs/aspromise@1.1.2': + resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} + + '@protobufjs/base64@1.1.2': + resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==} + + '@protobufjs/codegen@2.0.5': + resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==} + + '@protobufjs/eventemitter@1.1.1': + resolution: {integrity: sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==} + + '@protobufjs/fetch@1.1.1': + resolution: {integrity: sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==} + + '@protobufjs/float@1.0.2': + resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==} + + '@protobufjs/path@1.1.2': + resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==} + + '@protobufjs/pool@1.1.0': + resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==} + + '@protobufjs/utf8@1.1.2': + resolution: {integrity: sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==} + '@rollup/rollup-android-arm-eabi@4.57.1': resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==} cpu: [arm] @@ -1290,10 +1822,18 @@ packages: '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + '@smithy/abort-controller@4.4.2': + resolution: {integrity: sha512-IINJV4TQAvI1+upIy3YaDtlFcLsWQ5Uw6Nos7CNwiJtpRFw98L1XOn38467Pw54pcYuDi8HGnc2CNbkZBEZq6Q==} + engines: {node: '>=18.0.0'} + '@smithy/core@3.31.1': resolution: {integrity: sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg==} engines: {node: '>=18.0.0'} + '@smithy/core@3.34.1': + resolution: {integrity: sha512-dLcOUxz8YCv1RZUMKq6GbyUf95pLbrqh34bPvpCZ1+CByFF31BEAFewZjsGCnVsZTKdThNENfGyAgk2TJqVwSw==} + engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.4.16': resolution: {integrity: sha512-QfuLWAkLzptffFW980AFeHZFdqds2B64rpEd3uJ6lgs3xVn9QegGMUgUcj+4d7dRrAsya3r58ZKpku97WcFb4w==} engines: {node: '>=18.0.0'} @@ -1302,6 +1842,10 @@ packages: resolution: {integrity: sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A==} engines: {node: '>=18.0.0'} + '@smithy/middleware-endpoint@4.7.2': + resolution: {integrity: sha512-7HgK3/pQQHcD5w9lOPtK53/eDMKDp324Nd4KZ8XvxlcKHiWytuM9VwVOB2iy3rutsz/N1WNEWBkaRBayrGnuog==} + engines: {node: '>=18.0.0'} + '@smithy/node-http-handler@4.9.13': resolution: {integrity: sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==} engines: {node: '>=18.0.0'} @@ -1310,10 +1854,18 @@ packages: resolution: {integrity: sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ==} engines: {node: '>=18.0.0'} + '@smithy/smithy-client@4.15.2': + resolution: {integrity: sha512-qweA7dHwcWmq8IN2mIGo7nAfeji1nwWBra+P3nz4R8W8yoQYE6Iy2bN33I0W3d50MN/V5EiDY/nElr4EHITsrg==} + engines: {node: '>=18.0.0'} + '@smithy/types@4.16.1': resolution: {integrity: sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==} engines: {node: '>=18.0.0'} + '@smithy/types@4.18.0': + resolution: {integrity: sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A==} + engines: {node: '>=18.0.0'} + '@stablelib/base64@1.0.1': resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} @@ -1327,6 +1879,10 @@ packages: resolution: {integrity: sha512-ID7fosbc50TbT0MK0EG12O+gAP3W3Aa/Pz4DaTtQtEvlc9Odaqi0de+xuZ7Li2GtK4HzEX7IuRWS/JmZLksR3Q==} engines: {node: '>=14'} + '@tokenizer/inflate@0.4.1': + resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} + engines: {node: '>=18'} + '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} @@ -1336,15 +1892,18 @@ packages: '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} + '@types/aws-lambda@8.10.163': + resolution: {integrity: sha512-+4zuoEB3S8RIhimtOFT7zAEk2SbpwrKjjGl9CyYnQR6k08uynxfauIIB0pDU1ssr05F8oyGiETwpn+8eXZMqPw==} + '@types/better-sqlite3@7.6.13': resolution: {integrity: sha512-NMv9ASNARoKksWtsq/SHakpYAYnhBrQgGD8zkLYk/jaK8jUGn08CfEdTRgYhMypUQAfzSP8W6gNLe0q19/t4VA==} - '@types/bn.js@5.2.0': - resolution: {integrity: sha512-DLbJ1BPqxvQhIGbeu8VbUC1DiAiahHtAYvA0ZEAa4P31F7IaArc8z3C3BRQdWX4mtLQuABG4yzp76ZrS02Ui1Q==} - '@types/body-parser@1.19.6': resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + '@types/bunyan@1.8.11': + resolution: {integrity: sha512-758fRH7umIMk5qt5ELmRMff4mLDlN+xyYzC+dkPTdKwbSkJFvz6xwyScrytPU0QIBbRRwbiE8/BIg8bpajerNQ==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -1359,9 +1918,6 @@ packages: '@types/deep-eql@4.0.2': resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} - '@types/elliptic@6.4.18': - resolution: {integrity: sha512-UseG6H5vjRiNpQvrhy4VF/JXdA3V/Fp5amvveaL+fs28BZ6xIKJBPnUPRlEaZpysD9MbpfaLi8lbl7PGUAkpWw==} - '@types/esrecurse@4.3.1': resolution: {integrity: sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==} @@ -1383,9 +1939,15 @@ packages: '@types/json5@0.0.29': resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + '@types/memcached@2.2.10': + resolution: {integrity: sha512-AM9smvZN55Gzs2wRrqeMHVP7KE8KWgCJO/XL5yCly2xF6EKa4YlbpK+cLSAH4NG/Ah64HrlegmGqW8kYws7Vxg==} + '@types/mime@1.3.5': resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + '@types/mysql@2.15.27': + resolution: {integrity: sha512-YfWiV16IY0OeBfBCk8+hXKmdTKrKlwKN1MNKAPBu5JYxLwBEZl7QzeEpGnlZb3VMGJrrGmB84gXiH+ofs/TezA==} + '@types/node@12.20.55': resolution: {integrity: sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==} @@ -1401,6 +1963,15 @@ packages: '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/oracledb@6.5.2': + resolution: {integrity: sha512-kK1eBS/Adeyis+3OlBDMeQQuasIDLUYXsi2T15ccNJ0iyUpQ4xDF7svFu3+bGVrI0CMBUclPciz+lsQR3JX3TQ==} + + '@types/pg-pool@2.0.7': + resolution: {integrity: sha512-U4CwmGVQcbEuqpyju8/ptOKg6gEC+Tqsvj2xS9o1g71bUh8twxnC6ZL5rZKCsGN0iyH0CwgUyc9VR5owNQF9Ng==} + + '@types/pg@8.15.6': + resolution: {integrity: sha512-NoaMtzhxOrubeL/7UZuNTrejB4MPAJ0RpxZqXQf2qXuVlTPuG6Y8p4u9dKRaue4yjmC7ZhzVO2/Yyyn25znrPQ==} + '@types/pino@7.0.5': resolution: {integrity: sha512-wKoab31pknvILkxAF8ss+v9iNyhw5Iu/0jLtRkUD74cNfOOLJNnqfFKAv0r7wVaTQxRZtWrMpGfShwwBjOcgcg==} deprecated: This is a stub types definition. pino provides its own type definitions, so you do not need this installed. @@ -1431,6 +2002,9 @@ packages: '@types/serve-static@1.15.10': resolution: {integrity: sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==} + '@types/tedious@4.0.14': + resolution: {integrity: sha512-KHPsfX/FoVbUGbyYvk1q9MMQHLPeRZhRJZdO45Q4YjvFkv4hMNghCWTvy7rdKessBsmtz4euWCWAB6/tVpI1Iw==} + '@typescript-eslint/eslint-plugin@8.56.0': resolution: {integrity: sha512-lRyPDLzNCuae71A3t9NEINBiTn7swyOhvUj3MyUOxb8x6g6vPEFoOU+ZRmGMusNC3X3YMhqMIX7i8ShqhT74Pw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1631,6 +2205,11 @@ packages: resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} engines: {node: '>= 0.6'} + acorn-import-attributes@1.9.5: + resolution: {integrity: sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==} + peerDependencies: + acorn: ^8 + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1641,6 +2220,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ajv@6.14.0: resolution: {integrity: sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==} @@ -1712,9 +2295,6 @@ packages: resolution: {integrity: sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==} engines: {node: '>= 0.4'} - asn1.js@5.4.1: - resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==} - assertion-error-formatter@3.0.0: resolution: {integrity: sha512-6YyAVLrEze0kQ7CmJfUgrLHb+Y7XghmL2Ie7ijVa2Y9ynP3LV+VDiwFk62Dn0qtqbmY0BT0ss6p1xxpiF2PYbQ==} @@ -1835,15 +2415,15 @@ packages: resolution: {integrity: sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==} engines: {node: '>=0.6'} + bignumber.js@9.3.1: + resolution: {integrity: sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==} + bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} bl@4.1.0: resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} - bn.js@4.12.3: - resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==} - body-parser@1.20.4: resolution: {integrity: sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -1865,9 +2445,6 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - brorand@1.1.0: - resolution: {integrity: sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==} - buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} @@ -1924,6 +2501,9 @@ packages: chownr@1.1.4: resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + cjs-module-lexer@2.2.1: + resolution: {integrity: sha512-Ca8swihM+/4yKecYHY52kgJd300hi2lADU/a1RxNTRe+RJ9jvqQlESpbz9DnG9mowez8qwXHB8qYdIUw9e+F5Q==} + class-transformer@0.5.1: resolution: {integrity: sha512-SQa1Ws6hUbfC98vKGxZH3KFY0Y1lm5Zm0SY8XX9zbK7FJCyVEac3ATW0RIpwzW+oOfmHE5PMPufDG9hCfoEOMw==} @@ -1934,6 +2514,10 @@ packages: client-only@0.0.1: resolution: {integrity: sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==} + cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + cliui@9.0.1: resolution: {integrity: sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==} engines: {node: '>=20'} @@ -2004,8 +2588,8 @@ packages: resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} engines: {node: '>= 0.6'} - core-js@3.48.0: - resolution: {integrity: sha512-zpEHTy1fjTMZCKLHUZoVeylt9XrzaIN2rbPXEt0k+q7JE5CkCZdo6bNq55bn24a69CH7ErAVLKijxJja4fw+UQ==} + core-js@3.50.0: + resolution: {integrity: sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==} cors@2.8.6: resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} @@ -2018,6 +2602,10 @@ packages: csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + data-uri-to-buffer@4.0.1: + resolution: {integrity: sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==} + engines: {node: '>= 12'} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -2162,9 +2750,6 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - elliptic@6.6.1: - resolution: {integrity: sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==} - emoji-regex@10.6.0: resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} @@ -2206,6 +2791,9 @@ packages: es-module-lexer@2.0.0: resolution: {integrity: sha512-5POEcUuZybH7IdmGsD8wlf0AI55wMecM9rVBTI/qEAy2c1kTOm3DjFYjrBdI2K3BaJjJYfYFeRtM0t9ssnRuxw==} + es-module-lexer@3.0.2: + resolution: {integrity: sha512-BuIB67FngDSyQ/dpQNOZybwdEBDUGJQvOqwWr4ha/ufYiqzuEwPkKO2zLhRAgay28tStRIHUeWmszZAJo3GCOg==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -2386,6 +2974,9 @@ packages: resolution: {integrity: sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==} engines: {node: '>= 0.10.0'} + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + extendable-error@0.1.7: resolution: {integrity: sha512-UOiS2in6/Q0FK0R0q6UY9vYpQ21mr/Qn1KOnte7vsACuNJf514WvCCUHSRCPcgjPT2bAhNIJdlE6bVap1GKmeg==} @@ -2425,6 +3016,10 @@ packages: picomatch: optional: true + fetch-blob@3.2.0: + resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} + engines: {node: ^12.20 || >= 14.13} + figures@3.2.0: resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==} engines: {node: '>=8'} @@ -2433,9 +3028,9 @@ packages: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} - file-type@16.5.4: - resolution: {integrity: sha512-/yFHK0aGjFEgDJjEKP0pWCplsPFPhwyfwevf/pVxiN0tmE4L9LmwWxWukdJSHdoCli4VgQLehjJtwQBnqmsKcw==} - engines: {node: '>=10'} + file-type@22.1.0: + resolution: {integrity: sha512-fbvf9u6jnHWr1EP6V8R99E9iLMhNLXgZkUzWBiPpJ5/89UJ/Atuxjn5YXR+XaZWo2hc2/8mGfTez+NDAgOcpLA==} + engines: {node: '>=22'} file-uri-to-path@1.0.0: resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==} @@ -2488,6 +3083,13 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + formdata-polyfill@4.0.10: + resolution: {integrity: sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==} + engines: {node: '>=12.20.0'} + + forwarded-parse@2.1.2: + resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} + forwarded@0.2.0: resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} engines: {node: '>= 0.6'} @@ -2527,6 +3129,14 @@ packages: functions-have-names@1.2.3: resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + gaxios@7.1.3: + resolution: {integrity: sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ==} + engines: {node: '>=18'} + + gcp-metadata@8.1.4: + resolution: {integrity: sha512-iJ9KMsiu+xKtNRX0PmGLSaIU3bUBAyzWTyqKemKPzNPsmmsBCQYmlNg+brEbES7IHSXtdVwzBPzx1vz3FAaipw==} + engines: {node: '>=18'} + generator-function@2.0.1: resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==} engines: {node: '>= 0.4'} @@ -2586,6 +3196,10 @@ packages: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} + google-logging-utils@1.1.3: + resolution: {integrity: sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==} + engines: {node: '>=14'} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -2593,8 +3207,8 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} - handlebars@4.7.8: - resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} + handlebars@4.7.9: + resolution: {integrity: sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==} engines: {node: '>=0.4.7'} hasBin: true @@ -2625,9 +3239,6 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} - hash.js@1.1.7: - resolution: {integrity: sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==} - hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -2636,9 +3247,6 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true - hmac-drbg@1.0.1: - resolution: {integrity: sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==} - hosted-git-info@9.0.2: resolution: {integrity: sha512-M422h7o/BR3rmCQ8UHi7cyyMqKltdP9Uo+J2fXK+RSAY+wTcKOIRyhTuKv4qn+DJf3g+PL890AzId5KZpX+CBg==} engines: {node: ^20.17.0 || >=22.9.0} @@ -2662,6 +3270,10 @@ packages: resolution: {integrity: sha512-JLjck1EzPaWjsmIf8bziM3p9fgR1Y3JoUKAkyYEbZmFrIvJM6I8vVJfBGWlEtV9IWOvzNnaTtjuwZeBY2kwB4g==} engines: {node: '>=14'} + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + human-id@4.1.3: resolution: {integrity: sha512-tsYlhAYpjCKa//8rXZ9DqKEawhPoSytweBC2eNvcaDK+57RZLHGqNs3PZTQO6yekLFSuvA6AlnAfrw1uBvtb+Q==} hasBin: true @@ -2685,6 +3297,13 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + import-in-the-middle@2.0.6: + resolution: {integrity: sha512-3vZV3jX0XRFW3EJDTwzWoZa+RH1b8eTTx6YOCjglrLyPuepwoBti1k3L2dKwdCUrnVEfc5CuRuGstaC/uQJJaw==} + + import-in-the-middle@3.5.1: + resolution: {integrity: sha512-mPKuL8bPQzecui2KK6Gb+M8JvJoHnhS1FeYGa22QopBmlevF5F0FE6ued/B5EgHDeIoMTONIpDWWFKUPOG0DBQ==} + engines: {node: '>=18'} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -2894,6 +3513,9 @@ packages: resolution: {integrity: sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==} hasBin: true + json-bigint@1.0.0: + resolution: {integrity: sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==} + json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} @@ -2910,9 +3532,6 @@ packages: jsonfile@4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} - key-encoder@2.0.3: - resolution: {integrity: sha512-fgBtpAGIr/Fy5/+ZLQZIPPhsZEcbSlYu/Wu96tNDFNSjSACw5lEIOFeaVdQ/iwrb8oxjlWi6wmWdH76hV6GZjg==} - keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -2939,6 +3558,9 @@ packages: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} + lodash.camelcase@4.3.0: + resolution: {integrity: sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==} + lodash.defaults@4.2.0: resolution: {integrity: sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==} @@ -2957,6 +3579,9 @@ packages: lodash.startcase@4.4.0: resolution: {integrity: sha512-+WKqsK294HMSc2jEbNgpHpd0JfIBhp7rEV4aqXWqFr6AlXov+SlcgB1Fv01y2kGe3Gc8nMW7VA0SrGuSkRfIEg==} + long@5.3.2: + resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==} + lower-case@2.0.2: resolution: {integrity: sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==} @@ -3016,6 +3641,10 @@ packages: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mime@1.6.0: resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} engines: {node: '>=4'} @@ -3030,12 +3659,6 @@ packages: resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} engines: {node: '>=10'} - minimalistic-assert@1.0.1: - resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==} - - minimalistic-crypto-utils@1.0.1: - resolution: {integrity: sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==} - minimatch@10.2.2: resolution: {integrity: sha512-+G4CpNBxa5MprY+04MbgOw1v7So6n5JY166pFi9KfYwT78fxScCeSNQSNzp6dpPSW2rONOps6Ocam1wFhCgoVw==} engines: {node: 18 || 20 || >=22} @@ -3062,6 +3685,9 @@ packages: engines: {node: '>=10'} hasBin: true + module-details-from-path@1.0.4: + resolution: {integrity: sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w==} + mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} engines: {node: '>=4'} @@ -3140,6 +3766,11 @@ packages: resolution: {integrity: sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ==} engines: {node: '>=10'} + node-domexception@1.0.0: + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==} + engines: {node: '>=10.5.0'} + deprecated: Use your platform's native DOMException instead + node-exports-info@1.6.0: resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} engines: {node: '>= 0.4'} @@ -3153,6 +3784,10 @@ packages: encoding: optional: true + node-fetch@3.3.2: + resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + nodemailer-html-to-text@3.2.0: resolution: {integrity: sha512-RJUC6640QV1PzTHHapOrc6IzrAJUZtk2BdVdINZ9VTLm+mcQNyBO9LYyhrnufkzqiD9l8hPLJ97rSyK4WanPNg==} engines: {node: '>= 10.23.0'} @@ -3218,7 +3853,12 @@ packages: one-webcrypto@1.0.3: resolution: {integrity: sha512-fu9ywBVBPx0gS9K0etIROTiCkvI5S1TDjFsYFb3rC1ewFxeOqsbzq7aIMBHsYfrTHBcGXJaONXXjTl8B01cW1Q==} - optionator@0.9.4: + opentelemetry-plugin-better-sqlite3@1.13.0: + resolution: {integrity: sha512-JHRGpq1KyTdlt50XmWCZ6P7nxEMGmn/EvdJKFPkZAnVtk9pQDnDwYobX/L/xeRkriihRKwHQ3UWoeIGUeolNtQ==} + peerDependencies: + better-sqlite3: ^7.1.1 || ^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0 + + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -3327,9 +3967,16 @@ packages: pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} - peek-readable@4.1.0: - resolution: {integrity: sha512-ZI3LnwUv5nOGbQzD9c2iDG6toheuXSZP5esSHBjopsXH4dg19soufvpUGA3uohi5anFtGb2lhAVdHzH6R/Evvg==} - engines: {node: '>=8'} + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-protocol@1.16.0: + resolution: {integrity: sha512-sILXutLVjCLjcDuOmvhX5e2Z4cS5qG/6Bu3VkpFwdf/633ElGLpEh9bgmuI5I4sqKqkifQiGyiCcx1HdtrK7tg==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -3391,6 +4038,22 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} @@ -3411,6 +4074,11 @@ packages: engines: {node: '>=14'} hasBin: true + prettier@3.9.6: + resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==} + engines: {node: '>=14'} + hasBin: true + process-warning@3.0.0: resolution: {integrity: sha512-mqn0kFRl0EoqhnL0GQ0veqFHyIN1yig9RHh/InzORTUiZHFRAur+aMtRkELNwGs9aNwKS6tg/An4NYBPGwvtzQ==} @@ -3428,6 +4096,10 @@ packages: property-expr@2.0.6: resolution: {integrity: sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==} + protobufjs@7.6.6: + resolution: {integrity: sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==} + engines: {node: '>=12.0.0'} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -3499,10 +4171,6 @@ packages: resolution: {integrity: sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - readable-web-to-node-stream@3.0.4: - resolution: {integrity: sha512-9nX56alTf5bwXQ3ZDipHJhusu9NTQJ/CVPtb/XHAJCXihZeitfJvIRS4GqQ/mfIoOE3IelHMrpayVrosdHBuLw==} - engines: {node: '>=8'} - real-require@0.2.0: resolution: {integrity: sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==} engines: {node: '>= 12.13.0'} @@ -3537,6 +4205,14 @@ packages: resolution: {integrity: sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==} engines: {node: '>=0.10'} + require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + + require-in-the-middle@8.0.1: + resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==} + engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'} + resolve-from@5.0.0: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} @@ -3553,6 +4229,10 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rimraf@5.0.10: + resolution: {integrity: sha512-l0OE8wL34P4nJH/H2ffoaniAokM2qSmrtXHmlpvYr5AVVX8msAyW0l8NVJFDxlSK4u3Uh/f41cQheDVdnYijwQ==} + hasBin: true + roarr@7.21.4: resolution: {integrity: sha512-qvfUKCrpPzhWmQ4NxRYnuwhkI5lwmObhBU06BCK/lpj6PID9nL4Hk6XDwek2foKI+TMaV+Yw//XZshGF2Lox/Q==} engines: {node: '>=18.0'} @@ -3568,9 +4248,6 @@ packages: run-parallel@1.2.0: resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} - rxjs@7.8.2: - resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} - safe-array-concat@1.1.3: resolution: {integrity: sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q==} engines: {node: '>=0.4'} @@ -3806,9 +4483,9 @@ packages: resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} engines: {node: '>=0.10.0'} - strtok3@6.3.0: - resolution: {integrity: sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw==} - engines: {node: '>=10'} + strtok3@10.3.5: + resolution: {integrity: sha512-ki4hZQfh5rX0QDLLkOCj+h+CVNkqmp/CMf8v8kZpkNVK6jGQooMytqzLZYUVYIZcFZ6yDB70EfD8POcFXiF5oA==} + engines: {node: '>=18'} styled-jsx@5.1.6: resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==} @@ -3838,6 +4515,12 @@ packages: svix@1.96.1: resolution: {integrity: sha512-l+GyPS6gjL0okiXplLazEY2GbBsv1jZ4UIwtjp553YkDDv635xMKbM5sABpIdiWy1BYxgyV8M6tHeTwY0gyXlQ==} + systeminformation@5.33.10: + resolution: {integrity: sha512-/NXbMVASt2UbSVgmWto4aBTIAeuYY7zOELpZybmNxqpsrbo5uYHpuEPf5nkyrPng0uG5YOb+Yv6s0FyKY4mDQg==} + engines: {node: '>=10.0.0'} + os: [darwin, linux, win32, freebsd, openbsd, netbsd, sunos, android] + hasBin: true + tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -3893,9 +4576,9 @@ packages: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} - token-types@4.2.1: - resolution: {integrity: sha512-6udB24Q737UD/SDsKAHI9FCRP7Bqc9D/MQUV02ORQg5iskjtLJlZJNdN4kKtcdtwCeWIwIHDGaUsTsCCAa8sFQ==} - engines: {node: '>=10'} + token-types@6.1.2: + resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} + engines: {node: '>=14.16'} toposort@2.0.2: resolution: {integrity: sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==} @@ -3966,9 +4649,6 @@ packages: resolution: {integrity: sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==} engines: {node: '>= 0.4'} - typed-emitter@2.1.0: - resolution: {integrity: sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA==} - typescript-eslint@8.56.0: resolution: {integrity: sha512-c7toRLrotJ9oixgdW7liukZpsnq5CZ7PuKztubGYlNppuTqhIoWfhgHo/7EU0v06gS2l/x0i2NEFK1qMIf0rIg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -3986,6 +4666,10 @@ packages: engines: {node: '>=0.8.0'} hasBin: true + uint8array-extras@1.5.0: + resolution: {integrity: sha512-rvKSBiC5zqCCiDZ9kAOszZcDvdAHwwIKJG33Ykj43OKcWsnmcBRL09YTU4nOeHZ8Y2a7l1MgTd08SBe9A8Qj6A==} + engines: {node: '>=18'} + uint8arrays@3.0.0: resolution: {integrity: sha512-HRCx0q6O9Bfbp+HHSfQQKD7wU70+lydKVt4EghkdOvlK/NlrF90z+eXV34mUd48rNvVJXwkrMSPpCATkct8fJA==} @@ -4130,6 +4814,10 @@ packages: jsdom: optional: true + web-streams-polyfill@3.3.3: + resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} + engines: {node: '>= 8'} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -4200,6 +4888,10 @@ packages: resolution: {integrity: sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg==} engines: {node: '>=8.0'} + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -4209,10 +4901,18 @@ packages: engines: {node: '>= 14.6'} hasBin: true + yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + yargs-parser@22.0.0: resolution: {integrity: sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + yargs@18.1.0: resolution: {integrity: sha512-2rAgRKu54VsHkqI0/tYkmluGXHD4KW7yZoycuqDQ15QOTnc2VVfy0nN/1eMhnQLO00A+dwtK20xuCnc1YGeUyg==} engines: {node: ^20.19.0 || ^22.12.0 || >=23} @@ -4232,84 +4932,88 @@ packages: snapshots: - '@atproto-labs/did-resolver@0.3.6': + '@atproto-labs/did-resolver@0.3.9': dependencies: - '@atproto-labs/fetch': 0.3.5 + '@atproto-labs/fetch': 0.3.6 '@atproto-labs/pipe': 0.2.4 - '@atproto-labs/simple-store': 0.5.0 - '@atproto-labs/simple-store-memory': 0.2.5 - '@atproto/did': 0.5.4 + '@atproto-labs/simple-store': 0.5.1 + '@atproto-labs/simple-store-memory': 0.2.6 + '@atproto/did': 0.5.5 zod: 3.25.76 - '@atproto-labs/fetch-node@0.2.0': - dependencies: - '@atproto-labs/fetch': 0.2.3 - '@atproto-labs/pipe': 0.1.1 - ipaddr.js: 2.3.0 - undici: 6.23.0 - - '@atproto-labs/fetch-node@0.3.6': + '@atproto-labs/fetch-node@0.4.0': dependencies: - '@atproto-labs/fetch': 0.3.5 + '@atproto-labs/fetch': 0.3.6 '@atproto-labs/pipe': 0.2.4 ipaddr.js: 2.3.0 undici_v6: undici@6.23.0 undici_v7: undici@7.29.0 undici_v8: undici@8.10.0 - '@atproto-labs/fetch@0.2.3': - dependencies: - '@atproto-labs/pipe': 0.1.1 - - '@atproto-labs/fetch@0.3.5': + '@atproto-labs/fetch@0.3.6': dependencies: '@atproto-labs/pipe': 0.2.4 - '@atproto-labs/pipe@0.1.1': {} + '@atproto-labs/opentelemetry-node@0.2.1': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/auto-instrumentations-node': 0.77.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1)) + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-express': 0.67.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-http': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-pino': 0.65.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-runtime-node': 0.32.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-undici': 0.29.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-container': 0.8.13(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-node': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + import-in-the-middle: 3.5.1 + transitivePeerDependencies: + - supports-color '@atproto-labs/pipe@0.2.4': {} - '@atproto-labs/simple-store-memory@0.2.5': + '@atproto-labs/simple-store-memory@0.2.6': dependencies: - '@atproto-labs/simple-store': 0.5.0 + '@atproto-labs/simple-store': 0.5.1 lru-cache: 10.4.3 - '@atproto-labs/simple-store-redis@0.1.5(ioredis@5.9.3)': + '@atproto-labs/simple-store-redis@0.1.6(ioredis@5.9.3)': dependencies: - '@atproto-labs/simple-store': 0.5.0 + '@atproto-labs/simple-store': 0.5.1 ioredis: 5.9.3 - '@atproto-labs/simple-store@0.5.0': {} + '@atproto-labs/simple-store@0.5.1': {} - '@atproto-labs/xrpc-utils@0.1.12': + '@atproto-labs/xrpc-utils@0.1.22': dependencies: - '@atproto/xrpc': 0.8.8 - '@atproto/xrpc-server': 0.11.13 + '@atproto/xrpc': 0.8.13 + '@atproto/xrpc-server': 0.13.1 transitivePeerDependencies: - bufferutil - supports-color - utf-8-validate - '@atproto/aws@0.3.9': + '@atproto/aws@0.3.17': dependencies: - '@atproto/common': 0.7.3 - '@atproto/common-web': 0.5.7 - '@atproto/crypto': 0.5.4 - '@atproto/repo': 0.10.7 + '@atproto/common': 0.8.3 + '@atproto/common-web': 0.5.12 + '@atproto/crypto': 0.5.5 + '@atproto/repo': 0.10.14 '@aws-sdk/client-cloudfront': 3.1101.0 '@aws-sdk/client-kms': 3.1101.0 '@aws-sdk/client-s3': 3.1101.0 - '@aws-sdk/lib-storage': 3.1101.0(@aws-sdk/client-s3@3.1101.0) + '@aws-sdk/lib-storage': 3.879.0(@aws-sdk/client-s3@3.1101.0) '@noble/curves': 1.9.7 - key-encoder: 2.0.3 multiformats: 13.4.2 uint8arrays: 5.1.1 - '@atproto/common-web@0.5.7': + '@atproto/common-web@0.5.12': dependencies: - '@atproto/lex-data': 0.1.6 - '@atproto/lex-json': 0.1.5 - '@atproto/syntax': 0.7.2 + '@atproto/lex-data': 0.1.7 + '@atproto/lex-json': 0.1.6 + '@atproto/syntax': 0.7.6 zod: 3.25.76 '@atproto/common@0.1.1': @@ -4319,11 +5023,11 @@ snapshots: pino: 8.21.0 zod: 3.25.76 - '@atproto/common@0.7.3': + '@atproto/common@0.8.3': dependencies: - '@atproto/common-web': 0.5.7 - '@atproto/lex-cbor': 0.1.5 - '@atproto/lex-data': 0.1.6 + '@atproto/common-web': 0.5.12 + '@atproto/lex-cbor': 0.1.6 + '@atproto/lex-data': 0.1.7 multiformats: 13.4.2 pino: 10.3.1 @@ -4341,20 +5045,21 @@ snapshots: '@noble/hashes': 1.8.0 uint8arrays: 3.0.0 - '@atproto/crypto@0.5.4': + '@atproto/crypto@0.5.5': dependencies: '@noble/curves': 1.9.7 '@noble/hashes': 1.8.0 uint8arrays: 5.1.1 - '@atproto/did@0.5.4': + '@atproto/did@0.5.5': dependencies: zod: 3.25.76 - '@atproto/identity@0.5.7': + '@atproto/identity@0.5.13': dependencies: - '@atproto/common-web': 0.5.7 - '@atproto/crypto': 0.5.4 + '@atproto-labs/fetch-node': 0.4.0 + '@atproto/common-web': 0.5.12 + '@atproto/crypto': 0.5.5 '@atproto/jwk-jose@0.2.4': dependencies: @@ -4366,119 +5071,119 @@ snapshots: multiformats: 13.4.2 zod: 3.25.76 - '@atproto/lex-builder@0.1.9': + '@atproto/lex-builder@0.1.15': dependencies: - '@atproto/lex-document': 0.1.7 - '@atproto/lex-schema': 0.2.3 - prettier: 3.8.1 + '@atproto/lex-document': 0.1.11 + '@atproto/lex-schema': 0.2.7 + prettier: 3.9.6 ts-morph: 27.0.2 tslib: 2.8.1 - '@atproto/lex-cbor@0.1.5': + '@atproto/lex-cbor@0.1.6': dependencies: - '@atproto/lex-data': 0.1.6 + '@atproto/lex-data': 0.1.7 cborg: 4.5.8 tslib: 2.8.1 - '@atproto/lex-client@0.3.1': + '@atproto/lex-client@0.3.6': dependencies: - '@atproto/lex-data': 0.1.6 - '@atproto/lex-json': 0.1.5 - '@atproto/lex-schema': 0.2.3 + '@atproto/lex-data': 0.1.7 + '@atproto/lex-json': 0.1.6 + '@atproto/lex-schema': 0.2.7 tslib: 2.8.1 - '@atproto/lex-data@0.1.6': + '@atproto/lex-data@0.1.7': dependencies: multiformats: 13.4.2 tslib: 2.8.1 unicode-segmenter: 0.14.5 - '@atproto/lex-document@0.1.7': + '@atproto/lex-document@0.1.11': dependencies: - '@atproto/lex-schema': 0.2.3 - core-js: 3.48.0 + '@atproto/lex-schema': 0.2.7 + core-js: 3.50.0 tslib: 2.8.1 - '@atproto/lex-installer@0.1.11': + '@atproto/lex-installer@0.1.20': dependencies: - '@atproto/lex-builder': 0.1.9 - '@atproto/lex-cbor': 0.1.5 - '@atproto/lex-data': 0.1.6 - '@atproto/lex-document': 0.1.7 - '@atproto/lex-resolver': 0.2.4 - '@atproto/lex-schema': 0.2.3 - '@atproto/syntax': 0.7.2 + '@atproto/lex-builder': 0.1.15 + '@atproto/lex-cbor': 0.1.6 + '@atproto/lex-data': 0.1.7 + '@atproto/lex-document': 0.1.11 + '@atproto/lex-resolver': 0.2.13 + '@atproto/lex-schema': 0.2.7 + '@atproto/syntax': 0.7.6 tslib: 2.8.1 - '@atproto/lex-json@0.1.5': + '@atproto/lex-json@0.1.6': dependencies: - '@atproto/lex-data': 0.1.6 + '@atproto/lex-data': 0.1.7 tslib: 2.8.1 - '@atproto/lex-resolver@0.2.4': + '@atproto/lex-resolver@0.2.13': dependencies: - '@atproto-labs/did-resolver': 0.3.6 - '@atproto/crypto': 0.5.4 - '@atproto/lex-client': 0.3.1 - '@atproto/lex-data': 0.1.6 - '@atproto/lex-document': 0.1.7 - '@atproto/lex-schema': 0.2.3 - '@atproto/repo': 0.10.7 - '@atproto/syntax': 0.7.2 + '@atproto-labs/did-resolver': 0.3.9 + '@atproto/crypto': 0.5.5 + '@atproto/lex-client': 0.3.6 + '@atproto/lex-data': 0.1.7 + '@atproto/lex-document': 0.1.11 + '@atproto/lex-schema': 0.2.7 + '@atproto/repo': 0.10.14 + '@atproto/syntax': 0.7.6 tslib: 2.8.1 - '@atproto/lex-schema@0.2.3': + '@atproto/lex-schema@0.2.7': dependencies: - '@atproto/lex-data': 0.1.6 - '@atproto/syntax': 0.7.2 + '@atproto/lex-data': 0.1.7 + '@atproto/syntax': 0.7.6 '@standard-schema/spec': 1.1.0 tslib: 2.8.1 - '@atproto/lex@0.3.2': + '@atproto/lex@0.3.11': dependencies: - '@atproto/lex-builder': 0.1.9 - '@atproto/lex-client': 0.3.1 - '@atproto/lex-data': 0.1.6 - '@atproto/lex-installer': 0.1.11 - '@atproto/lex-json': 0.1.5 - '@atproto/lex-schema': 0.2.3 + '@atproto/lex-builder': 0.1.15 + '@atproto/lex-client': 0.3.6 + '@atproto/lex-data': 0.1.7 + '@atproto/lex-installer': 0.1.20 + '@atproto/lex-json': 0.1.6 + '@atproto/lex-schema': 0.2.7 tslib: 2.8.1 yargs: 18.1.0 - '@atproto/lexicon@0.7.9': + '@atproto/lexicon@0.7.14': dependencies: - '@atproto/common-web': 0.5.7 - '@atproto/syntax': 0.7.2 + '@atproto/common-web': 0.5.12 + '@atproto/syntax': 0.7.6 multiformats: 13.4.2 zod: 3.25.76 - '@atproto/oauth-provider-api@0.7.7': + '@atproto/oauth-provider-api@0.8.3': dependencies: '@atproto/jwk': 0.7.4 - '@atproto/oauth-types': 0.7.5 - '@atproto/syntax': 0.7.2 + '@atproto/oauth-types': 0.7.6 + '@atproto/syntax': 0.7.6 - '@atproto/oauth-provider-ui@0.8.9': + '@atproto/oauth-provider-ui@0.10.3': dependencies: - '@atproto/oauth-provider-api': 0.7.7 + '@atproto/oauth-provider-api': 0.8.3 - '@atproto/oauth-provider@0.21.1': + '@atproto/oauth-provider@0.22.8': dependencies: - '@atproto-labs/fetch-node': 0.3.6 + '@atproto-labs/fetch-node': 0.4.0 '@atproto-labs/pipe': 0.2.4 - '@atproto-labs/simple-store': 0.5.0 - '@atproto-labs/simple-store-memory': 0.2.5 - '@atproto/common': 0.7.3 - '@atproto/did': 0.5.4 + '@atproto-labs/simple-store': 0.5.1 + '@atproto-labs/simple-store-memory': 0.2.6 + '@atproto/common': 0.8.3 + '@atproto/did': 0.5.5 '@atproto/jwk': 0.7.4 '@atproto/jwk-jose': 0.2.4 - '@atproto/lex-document': 0.1.7 - '@atproto/lex-resolver': 0.2.4 - '@atproto/oauth-provider-api': 0.7.7 - '@atproto/oauth-provider-ui': 0.8.9 - '@atproto/oauth-scopes': 0.5.7 - '@atproto/oauth-types': 0.7.5 - '@atproto/syntax': 0.7.2 + '@atproto/lex-document': 0.1.11 + '@atproto/lex-resolver': 0.2.13 + '@atproto/oauth-provider-api': 0.8.3 + '@atproto/oauth-provider-ui': 0.10.3 + '@atproto/oauth-scopes': 0.5.12 + '@atproto/oauth-types': 0.7.6 + '@atproto/syntax': 0.7.6 '@hapi/accept': 6.0.3 '@hapi/address': 5.1.1 '@hapi/bourne': 3.0.0 @@ -4493,61 +5198,65 @@ snapshots: transitivePeerDependencies: - supports-color - '@atproto/oauth-scopes@0.5.7': + '@atproto/oauth-scopes@0.5.12': dependencies: - '@atproto/did': 0.5.4 - '@atproto/syntax': 0.7.2 + '@atproto/did': 0.5.5 + '@atproto/syntax': 0.7.6 - '@atproto/oauth-types@0.7.5': + '@atproto/oauth-types@0.7.6': dependencies: - '@atproto/did': 0.5.4 + '@atproto/did': 0.5.5 '@atproto/jwk': 0.7.4 zod: 3.25.76 - '@atproto/pds@0.5.23': - dependencies: - '@atproto-labs/fetch-node': 0.3.6 - '@atproto-labs/simple-store': 0.5.0 - '@atproto-labs/simple-store-memory': 0.2.5 - '@atproto-labs/simple-store-redis': 0.1.5(ioredis@5.9.3) - '@atproto-labs/xrpc-utils': 0.1.12 - '@atproto/aws': 0.3.9 - '@atproto/common': 0.7.3 - '@atproto/crypto': 0.5.4 - '@atproto/did': 0.5.4 - '@atproto/identity': 0.5.7 - '@atproto/lex': 0.3.2 - '@atproto/lex-cbor': 0.1.5 - '@atproto/lex-data': 0.1.6 - '@atproto/lex-json': 0.1.5 - '@atproto/oauth-provider': 0.21.1 - '@atproto/oauth-scopes': 0.5.7 - '@atproto/repo': 0.10.7 - '@atproto/syntax': 0.7.2 - '@atproto/xrpc': 0.8.8 - '@atproto/xrpc-server': 0.11.13 + '@atproto/pds@0.5.34': + dependencies: + '@atproto-labs/fetch-node': 0.4.0 + '@atproto-labs/opentelemetry-node': 0.2.1 + '@atproto-labs/simple-store': 0.5.1 + '@atproto-labs/simple-store-memory': 0.2.6 + '@atproto-labs/simple-store-redis': 0.1.6(ioredis@5.9.3) + '@atproto-labs/xrpc-utils': 0.1.22 + '@atproto/aws': 0.3.17 + '@atproto/common': 0.8.3 + '@atproto/crypto': 0.5.5 + '@atproto/did': 0.5.5 + '@atproto/identity': 0.5.13 + '@atproto/lex': 0.3.11 + '@atproto/lex-cbor': 0.1.6 + '@atproto/lex-data': 0.1.7 + '@atproto/lex-json': 0.1.6 + '@atproto/oauth-provider': 0.22.8 + '@atproto/oauth-scopes': 0.5.12 + '@atproto/repo': 0.10.14 + '@atproto/syntax': 0.7.6 + '@atproto/xrpc-server': 0.13.1 '@did-plc/lib': 0.0.4 '@hapi/address': 5.1.1 + '@noble/curves': 1.9.7 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.219.0 + '@opentelemetry/instrumentation-aws-sdk': 0.74.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-ioredis': 0.67.0(@opentelemetry/api@1.9.1) better-sqlite3: 12.6.2 compression: 1.8.1 cors: 2.8.6 disposable-email-domains-js: 1.21.0 express: 4.22.1 express-async-errors: 3.1.1(express@4.22.1) - file-type: 16.5.4 - glob: 10.5.0 - handlebars: 4.7.8 + file-type: 22.1.0 + handlebars: 4.7.9 http-terminator: 3.2.0 ioredis: 5.9.3 jose: 5.10.0 - key-encoder: 2.0.3 kysely: 0.29.4 nodemailer: 6.10.1 nodemailer-html-to-text: 3.2.0 + opentelemetry-plugin-better-sqlite3: 1.13.0(better-sqlite3@12.6.2) p-queue: 8.1.1 pino: 10.3.1 pino-http: 11.0.0 - typed-emitter: 2.1.0 + strtok3: 10.3.5 uint8arrays: 5.1.1 undici: 8.10.0 zod: 3.25.76 @@ -4557,14 +5266,14 @@ snapshots: - supports-color - utf-8-validate - '@atproto/repo@0.10.7': + '@atproto/repo@0.10.14': dependencies: - '@atproto/common': 0.7.3 - '@atproto/common-web': 0.5.7 - '@atproto/crypto': 0.5.4 - '@atproto/lex-cbor': 0.1.5 - '@atproto/lex-data': 0.1.6 - '@atproto/syntax': 0.7.2 + '@atproto/common': 0.8.3 + '@atproto/common-web': 0.5.12 + '@atproto/crypto': 0.5.5 + '@atproto/lex-cbor': 0.1.6 + '@atproto/lex-data': 0.1.7 + '@atproto/syntax': 0.7.6 varint: 6.0.0 zod: 3.25.76 @@ -4577,29 +5286,33 @@ snapshots: iso-datestring-validator: 2.2.2 tslib: 2.8.1 - '@atproto/ws-client@0.1.8': + '@atproto/syntax@0.7.6': + dependencies: + iso-datestring-validator: 2.2.2 + tslib: 2.8.1 + + '@atproto/ws-client@0.2.1': dependencies: - '@atproto/common': 0.7.3 ws: 8.19.0 transitivePeerDependencies: - bufferutil - utf-8-validate - '@atproto/xrpc-server@0.11.13': - dependencies: - '@atproto/common': 0.7.3 - '@atproto/crypto': 0.5.4 - '@atproto/lex-cbor': 0.1.5 - '@atproto/lex-client': 0.3.1 - '@atproto/lex-data': 0.1.6 - '@atproto/lex-json': 0.1.5 - '@atproto/lex-schema': 0.2.3 - '@atproto/lexicon': 0.7.9 - '@atproto/ws-client': 0.1.8 - '@atproto/xrpc': 0.8.8 + '@atproto/xrpc-server@0.13.1': + dependencies: + '@atproto/common': 0.8.3 + '@atproto/crypto': 0.5.5 + '@atproto/lex-cbor': 0.1.6 + '@atproto/lex-client': 0.3.6 + '@atproto/lex-data': 0.1.7 + '@atproto/lex-json': 0.1.6 + '@atproto/lex-schema': 0.2.7 + '@atproto/lexicon': 0.7.14 + '@atproto/ws-client': 0.2.1 + '@atproto/xrpc': 0.8.13 express: 4.22.1 http-errors: 2.0.1 - mime-types: 2.1.35 + mime-types: 3.0.2 rate-limiter-flexible: 2.4.2 ws: 8.19.0 transitivePeerDependencies: @@ -4607,9 +5320,9 @@ snapshots: - supports-color - utf-8-validate - '@atproto/xrpc@0.8.8': + '@atproto/xrpc@0.8.13': dependencies: - '@atproto/lexicon': 0.7.9 + '@atproto/lexicon': 0.7.14 zod: 3.25.76 '@aws-sdk/checksums@3.1000.24': @@ -4751,11 +5464,12 @@ snapshots: '@smithy/types': 4.16.1 tslib: 2.8.1 - '@aws-sdk/lib-storage@3.1101.0(@aws-sdk/client-s3@3.1101.0)': + '@aws-sdk/lib-storage@3.879.0(@aws-sdk/client-s3@3.1101.0)': dependencies: '@aws-sdk/client-s3': 3.1101.0 - '@smithy/core': 3.31.1 - '@smithy/types': 4.16.1 + '@smithy/abort-controller': 4.4.2 + '@smithy/middleware-endpoint': 4.7.2 + '@smithy/smithy-client': 4.15.2 buffer: 5.6.0 events: 3.3.0 stream-browserify: 3.0.0 @@ -4853,6 +5567,8 @@ snapshots: '@better-fetch/fetch@1.1.21': {} + '@borewit/text-codec@0.2.2': {} + '@changesets/apply-release-plan@7.1.0': dependencies: '@changesets/config': 3.1.3 @@ -5261,6 +5977,18 @@ snapshots: '@eslint/core': 1.1.0 levn: 0.4.1 + '@grpc/grpc-js@1.14.4': + dependencies: + '@grpc/proto-loader': 0.8.1 + '@js-sdsl/ordered-map': 4.4.2 + + '@grpc/proto-loader@0.8.1': + dependencies: + lodash.camelcase: 4.3.0 + long: 5.3.2 + protobufjs: 7.6.6 + yargs: 17.7.3 + '@hapi/accept@6.0.3': dependencies: '@hapi/boom': 10.0.1 @@ -5406,94 +6134,811 @@ snapshots: '@isaacs/cliui@8.0.2': dependencies: - string-width: 5.1.2 - string-width-cjs: string-width@4.2.3 - strip-ansi: 7.1.2 - strip-ansi-cjs: strip-ansi@6.0.1 - wrap-ansi: 8.1.0 - wrap-ansi-cjs: wrap-ansi@7.0.0 + string-width: 5.1.2 + string-width-cjs: string-width@4.2.3 + strip-ansi: 7.1.2 + strip-ansi-cjs: strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: wrap-ansi@7.0.0 + + '@jridgewell/resolve-uri@3.1.2': {} + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@jridgewell/trace-mapping@0.3.31': + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.5 + + '@js-sdsl/ordered-map@4.4.2': {} + + '@manypkg/find-root@1.1.0': + dependencies: + '@babel/runtime': 7.29.2 + '@types/node': 12.20.55 + find-up: 4.1.0 + fs-extra: 8.1.0 + + '@manypkg/get-packages@1.1.3': + dependencies: + '@babel/runtime': 7.29.2 + '@changesets/types': 4.1.0 + '@manypkg/find-root': 1.1.0 + fs-extra: 8.1.0 + globby: 11.1.0 + read-yaml-file: 1.1.0 + + '@napi-rs/wasm-runtime@0.2.12': + dependencies: + '@emnapi/core': 1.9.2 + '@emnapi/runtime': 1.8.1 + '@tybys/wasm-util': 0.10.1 + optional: true + + '@next/env@15.5.12': {} + + '@next/swc-darwin-arm64@15.5.12': + optional: true + + '@next/swc-darwin-x64@15.5.12': + optional: true + + '@next/swc-linux-arm64-gnu@15.5.12': + optional: true + + '@next/swc-linux-arm64-musl@15.5.12': + optional: true + + '@next/swc-linux-x64-gnu@15.5.12': + optional: true + + '@next/swc-linux-x64-musl@15.5.12': + optional: true + + '@next/swc-win32-arm64-msvc@15.5.12': + optional: true + + '@next/swc-win32-x64-msvc@15.5.12': + optional: true + + '@noble/ciphers@2.1.1': {} + + '@noble/curves@1.9.7': + dependencies: + '@noble/hashes': 1.8.0 + + '@noble/hashes@1.8.0': {} + + '@noble/hashes@2.0.1': {} + + '@noble/secp256k1@1.7.2': {} + + '@nodelib/fs.scandir@2.1.5': + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + + '@nodelib/fs.stat@2.0.5': {} + + '@nodelib/fs.walk@1.2.8': + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.20.1 + + '@opentelemetry/api-logs@0.208.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api-logs@0.219.0': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/api@1.9.1': {} + + '@opentelemetry/auto-instrumentations-node@0.77.0(@opentelemetry/api@1.9.1)(@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1))': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-amqplib': 0.66.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-aws-lambda': 0.71.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-aws-sdk': 0.74.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-bunyan': 0.64.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-cassandra-driver': 0.64.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-connect': 0.62.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-cucumber': 0.35.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-dataloader': 0.36.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-dns': 0.62.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-express': 0.67.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-fs': 0.38.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-generic-pool': 0.62.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-graphql': 0.67.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-grpc': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-hapi': 0.65.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-host-metrics': 0.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-http': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-ioredis': 0.67.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-kafkajs': 0.28.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-knex': 0.63.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-koa': 0.67.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-lru-memoizer': 0.63.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-memcached': 0.62.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mongodb': 0.72.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mongoose': 0.65.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mysql': 0.65.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-mysql2': 0.65.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-nestjs-core': 0.65.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-net': 0.63.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-openai': 0.17.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-oracledb': 0.44.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-pg': 0.71.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-pino': 0.65.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-redis': 0.67.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-restify': 0.64.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-router': 0.63.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-runtime-node': 0.32.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-socket.io': 0.66.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-tedious': 0.38.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-undici': 0.29.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation-winston': 0.63.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-alibaba-cloud': 0.34.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-aws': 2.22.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-azure': 0.27.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-container': 0.8.13(@opentelemetry/api@1.9.1) + '@opentelemetry/resource-detector-gcp': 0.54.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-node': 0.219.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/configuration@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + yaml: 2.8.3 + + '@opentelemetry/context-async-hooks@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + + '@opentelemetry/core@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/core@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/exporter-logs-otlp-grpc@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.4 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.219.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-logs-otlp-http@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.219.0 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.219.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-logs-otlp-proto@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.219.0 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-grpc@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.4 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.8.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-http@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.8.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-metrics-otlp-proto@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.8.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-prometheus@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/exporter-trace-otlp-grpc@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.4 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-http@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-trace-otlp-proto@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/exporter-zipkin@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/instrumentation-amqplib@0.66.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-aws-lambda@0.71.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-aws-xray': 2.2.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + '@types/aws-lambda': 8.10.163 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-aws-sdk@0.74.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-bunyan@0.64.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.219.0 + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + '@types/bunyan': 1.8.11 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-cassandra-driver@0.64.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-connect@0.62.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + '@types/connect': 3.4.38 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-cucumber@0.35.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-dataloader@0.36.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-dns@0.62.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-express@0.67.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-fs@0.38.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-generic-pool@0.62.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-graphql@0.67.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-grpc@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-hapi@0.65.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-host-metrics@0.2.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + systeminformation: 5.33.10 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-http@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + forwarded-parse: 2.1.2 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-ioredis@0.67.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/redis-common': 0.38.3 + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-kafkajs@0.28.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-knex@0.63.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-koa@0.67.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-lru-memoizer@0.63.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-memcached@0.62.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + '@types/memcached': 2.2.10 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mongodb@0.72.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mongoose@0.65.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mysql2@0.65.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + '@opentelemetry/sql-common': 0.42.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-mysql@0.65.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + '@types/mysql': 2.15.27 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-nestjs-core@0.65.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-net@0.63.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-openai@0.17.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.219.0 + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-oracledb@0.44.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + '@types/oracledb': 6.5.2 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-pg@0.71.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + '@opentelemetry/sql-common': 0.42.0(@opentelemetry/api@1.9.1) + '@types/pg': 8.15.6 + '@types/pg-pool': 2.0.7 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-pino@0.65.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.219.0 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-redis@0.67.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/redis-common': 0.38.3 + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-restify@0.64.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-router@0.63.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color + + '@opentelemetry/instrumentation-runtime-node@0.32.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.219.0 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color - '@jridgewell/resolve-uri@3.1.2': {} + '@opentelemetry/instrumentation-socket.io@0.66.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color - '@jridgewell/sourcemap-codec@1.5.5': {} + '@opentelemetry/instrumentation-tedious@0.38.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + '@types/tedious': 4.0.14 + transitivePeerDependencies: + - supports-color - '@jridgewell/trace-mapping@0.3.31': + '@opentelemetry/instrumentation-undici@0.29.0(@opentelemetry/api@1.9.1)': dependencies: - '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.5.5 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color - '@manypkg/find-root@1.1.0': + '@opentelemetry/instrumentation-winston@0.63.0(@opentelemetry/api@1.9.1)': dependencies: - '@babel/runtime': 7.29.2 - '@types/node': 12.20.55 - find-up: 4.1.0 - fs-extra: 8.1.0 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.219.0 + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + transitivePeerDependencies: + - supports-color - '@manypkg/get-packages@1.1.3': + '@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.1)': dependencies: - '@babel/runtime': 7.29.2 - '@changesets/types': 4.1.0 - '@manypkg/find-root': 1.1.0 - fs-extra: 8.1.0 - globby: 11.1.0 - read-yaml-file: 1.1.0 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.208.0 + import-in-the-middle: 2.0.6 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color - '@napi-rs/wasm-runtime@0.2.12': + '@opentelemetry/instrumentation@0.219.0(@opentelemetry/api@1.9.1)': dependencies: - '@emnapi/core': 1.9.2 - '@emnapi/runtime': 1.8.1 - '@tybys/wasm-util': 0.10.1 - optional: true + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.219.0 + import-in-the-middle: 3.5.1 + require-in-the-middle: 8.0.1 + transitivePeerDependencies: + - supports-color - '@next/env@15.5.12': {} + '@opentelemetry/otlp-exporter-base@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.219.0(@opentelemetry/api@1.9.1) - '@next/swc-darwin-arm64@15.5.12': - optional: true + '@opentelemetry/otlp-grpc-exporter-base@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@grpc/grpc-js': 1.14.4 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-transformer': 0.219.0(@opentelemetry/api@1.9.1) - '@next/swc-darwin-x64@15.5.12': - optional: true + '@opentelemetry/otlp-transformer@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.219.0 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.1) - '@next/swc-linux-arm64-gnu@15.5.12': - optional: true + '@opentelemetry/propagator-aws-xray@2.2.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 - '@next/swc-linux-arm64-musl@15.5.12': - optional: true + '@opentelemetry/propagator-b3@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) - '@next/swc-linux-x64-gnu@15.5.12': - optional: true + '@opentelemetry/propagator-jaeger@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) - '@next/swc-linux-x64-musl@15.5.12': - optional: true + '@opentelemetry/redis-common@0.38.3': {} - '@next/swc-win32-arm64-msvc@15.5.12': - optional: true + '@opentelemetry/resource-detector-alibaba-cloud@0.34.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) - '@next/swc-win32-x64-msvc@15.5.12': - optional: true + '@opentelemetry/resource-detector-aws@2.22.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 - '@noble/ciphers@2.1.1': {} + '@opentelemetry/resource-detector-azure@0.27.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 - '@noble/curves@1.9.7': + '@opentelemetry/resource-detector-container@0.8.13(@opentelemetry/api@1.9.1)': dependencies: - '@noble/hashes': 1.8.0 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) - '@noble/hashes@1.8.0': {} + '@opentelemetry/resource-detector-gcp@0.54.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.11.0(@opentelemetry/api@1.9.1) + gcp-metadata: 8.1.4 + transitivePeerDependencies: + - supports-color - '@noble/hashes@2.0.1': {} + '@opentelemetry/resources@2.11.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/resources@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-logs@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.219.0 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + + '@opentelemetry/sdk-metrics@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + + '@opentelemetry/sdk-node@0.219.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/api-logs': 0.219.0 + '@opentelemetry/configuration': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/context-async-hooks': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-grpc': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-http': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-logs-otlp-proto': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-grpc': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-http': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-metrics-otlp-proto': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-prometheus': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-grpc': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-http': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-trace-otlp-proto': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/exporter-zipkin': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-exporter-base': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/otlp-grpc-exporter-base': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-b3': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/propagator-jaeger': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-logs': 0.219.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-metrics': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-node': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + transitivePeerDependencies: + - supports-color - '@noble/secp256k1@1.7.2': {} + '@opentelemetry/sdk-trace-base@2.8.0(@opentelemetry/api@1.9.1)': + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/resources': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 - '@nodelib/fs.scandir@2.1.5': + '@opentelemetry/sdk-trace-node@2.8.0(@opentelemetry/api@1.9.1)': dependencies: - '@nodelib/fs.stat': 2.0.5 - run-parallel: 1.2.0 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/context-async-hooks': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/core': 2.8.0(@opentelemetry/api@1.9.1) + '@opentelemetry/sdk-trace-base': 2.8.0(@opentelemetry/api@1.9.1) - '@nodelib/fs.stat@2.0.5': {} + '@opentelemetry/semantic-conventions@1.43.0': {} - '@nodelib/fs.walk@1.2.8': + '@opentelemetry/sql-common@0.42.0(@opentelemetry/api@1.9.1)': dependencies: - '@nodelib/fs.scandir': 2.1.5 - fastq: 1.20.1 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) '@pinojs/redact@0.4.0': {} @@ -5504,6 +6949,26 @@ snapshots: dependencies: playwright: 1.58.2 + '@protobufjs/aspromise@1.1.2': {} + + '@protobufjs/base64@1.1.2': {} + + '@protobufjs/codegen@2.0.5': {} + + '@protobufjs/eventemitter@1.1.1': {} + + '@protobufjs/fetch@1.1.1': + dependencies: + '@protobufjs/aspromise': 1.1.2 + + '@protobufjs/float@1.0.2': {} + + '@protobufjs/path@1.1.2': {} + + '@protobufjs/pool@1.1.0': {} + + '@protobufjs/utf8@1.1.2': {} + '@rollup/rollup-android-arm-eabi@4.57.1': optional: true @@ -5581,11 +7046,21 @@ snapshots: '@rtsao/scc@1.1.0': {} + '@smithy/abort-controller@4.4.2': + dependencies: + '@smithy/types': 4.18.0 + tslib: 2.8.1 + '@smithy/core@3.31.1': dependencies: '@smithy/types': 4.16.1 tslib: 2.8.1 + '@smithy/core@3.34.1': + dependencies: + '@smithy/types': 4.18.0 + tslib: 2.8.1 + '@smithy/credential-provider-imds@4.4.16': dependencies: '@smithy/core': 3.31.1 @@ -5598,6 +7073,11 @@ snapshots: '@smithy/types': 4.16.1 tslib: 2.8.1 + '@smithy/middleware-endpoint@4.7.2': + dependencies: + '@smithy/core': 3.34.1 + tslib: 2.8.1 + '@smithy/node-http-handler@4.9.13': dependencies: '@smithy/core': 3.31.1 @@ -5610,10 +7090,20 @@ snapshots: '@smithy/types': 4.16.1 tslib: 2.8.1 + '@smithy/smithy-client@4.15.2': + dependencies: + '@smithy/core': 3.34.1 + '@smithy/types': 4.18.0 + tslib: 2.8.1 + '@smithy/types@4.16.1': dependencies: tslib: 2.8.1 + '@smithy/types@4.18.0': + dependencies: + tslib: 2.8.1 + '@stablelib/base64@1.0.1': {} '@standard-schema/spec@1.1.0': {} @@ -5624,6 +7114,13 @@ snapshots: '@teppeis/multimaps@3.0.0': {} + '@tokenizer/inflate@0.4.1': + dependencies: + debug: 4.4.3(supports-color@8.1.1) + token-types: 6.1.2 + transitivePeerDependencies: + - supports-color + '@tokenizer/token@0.3.0': {} '@ts-morph/common@0.28.1': @@ -5637,17 +7134,19 @@ snapshots: tslib: 2.8.1 optional: true + '@types/aws-lambda@8.10.163': {} + '@types/better-sqlite3@7.6.13': dependencies: '@types/node': 22.19.11 - '@types/bn.js@5.2.0': + '@types/body-parser@1.19.6': dependencies: + '@types/connect': 3.4.38 '@types/node': 22.19.11 - '@types/body-parser@1.19.6': + '@types/bunyan@1.8.11': dependencies: - '@types/connect': 3.4.38 '@types/node': 22.19.11 '@types/chai@5.2.3': @@ -5665,10 +7164,6 @@ snapshots: '@types/deep-eql@4.0.2': {} - '@types/elliptic@6.4.18': - dependencies: - '@types/bn.js': 5.2.0 - '@types/esrecurse@4.3.1': {} '@types/estree@1.0.8': {} @@ -5693,8 +7188,16 @@ snapshots: '@types/json5@0.0.29': {} + '@types/memcached@2.2.10': + dependencies: + '@types/node': 22.19.11 + '@types/mime@1.3.5': {} + '@types/mysql@2.15.27': + dependencies: + '@types/node': 22.19.11 + '@types/node@12.20.55': {} '@types/node@20.19.33': @@ -5711,6 +7214,20 @@ snapshots: '@types/normalize-package-data@2.4.4': {} + '@types/oracledb@6.5.2': + dependencies: + '@types/node': 22.19.11 + + '@types/pg-pool@2.0.7': + dependencies: + '@types/pg': 8.15.6 + + '@types/pg@8.15.6': + dependencies: + '@types/node': 22.19.11 + pg-protocol: 1.16.0 + pg-types: 2.2.0 + '@types/pino@7.0.5': dependencies: pino: 10.3.1 @@ -5744,6 +7261,10 @@ snapshots: '@types/node': 22.19.11 '@types/send': 0.17.6 + '@types/tedious@4.0.14': + dependencies: + '@types/node': 22.19.11 + '@typescript-eslint/eslint-plugin@8.56.0(@typescript-eslint/parser@8.56.0(eslint@10.0.1)(typescript@5.9.3))(eslint@10.0.1)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 @@ -5894,7 +7415,7 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true - '@vitest/coverage-v8@4.1.0(vitest@4.1.0(@types/node@22.19.11)(vite@7.3.1(@types/node@22.19.11)(tsx@4.21.0)(yaml@2.8.3)))': + '@vitest/coverage-v8@4.1.0(vitest@4.1.0(@opentelemetry/api@1.9.1)(@types/node@22.19.11)(vite@7.3.1(@types/node@22.19.11)(tsx@4.21.0)(yaml@2.8.3)))': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.1.0 @@ -5906,7 +7427,7 @@ snapshots: obug: 2.1.1 std-env: 4.0.0 tinyrainbow: 3.0.3 - vitest: 4.1.0(@types/node@22.19.11)(vite@7.3.1(@types/node@22.19.11)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@22.19.11)(vite@7.3.1(@types/node@22.19.11)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/expect@4.1.0': dependencies: @@ -5967,12 +7488,18 @@ snapshots: mime-types: 2.1.35 negotiator: 0.6.3 + acorn-import-attributes@1.9.5(acorn@8.16.0): + dependencies: + acorn: 8.16.0 + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 acorn@8.16.0: {} + agent-base@7.1.4: {} + ajv@6.14.0: dependencies: fast-deep-equal: 3.1.3 @@ -6058,13 +7585,6 @@ snapshots: get-intrinsic: 1.3.0 is-array-buffer: 3.0.5 - asn1.js@5.4.1: - dependencies: - bn.js: 4.12.3 - inherits: 2.0.4 - minimalistic-assert: 1.0.1 - safer-buffer: 2.1.2 - assertion-error-formatter@3.0.0: dependencies: diff: 4.0.4 @@ -6103,7 +7623,7 @@ snapshots: base64-js@1.5.1: {} - better-auth@1.4.18(better-sqlite3@12.6.2)(next@15.5.12(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(@types/node@20.19.33)(vite@7.3.1(@types/node@20.19.33)(tsx@4.21.0)(yaml@2.8.3))): + better-auth@1.4.18(better-sqlite3@12.6.2)(next@15.5.12(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(vitest@4.1.0(@opentelemetry/api@1.9.1)(@types/node@20.19.33)(vite@7.3.1(@types/node@20.19.33)(tsx@4.21.0)(yaml@2.8.3))): dependencies: '@better-auth/core': 1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0) '@better-auth/telemetry': 1.4.18(@better-auth/core@1.4.18(@better-auth/utils@0.3.0)(@better-fetch/fetch@1.1.21)(better-call@1.1.8(zod@4.3.6))(jose@6.1.3)(kysely@0.28.11)(nanostores@1.1.0)) @@ -6119,10 +7639,10 @@ snapshots: zod: 4.3.6 optionalDependencies: better-sqlite3: 12.6.2 - next: 15.5.12(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + next: 15.5.12(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - vitest: 4.1.0(@types/node@20.19.33)(vite@7.3.1(@types/node@20.19.33)(tsx@4.21.0)(yaml@2.8.3)) + vitest: 4.1.0(@opentelemetry/api@1.9.1)(@types/node@20.19.33)(vite@7.3.1(@types/node@20.19.33)(tsx@4.21.0)(yaml@2.8.3)) better-call@1.1.8(zod@4.3.6): dependencies: @@ -6144,6 +7664,8 @@ snapshots: big-integer@1.6.52: {} + bignumber.js@9.3.1: {} + bindings@1.5.0: dependencies: file-uri-to-path: 1.0.0 @@ -6154,8 +7676,6 @@ snapshots: inherits: 2.0.4 readable-stream: 3.6.2 - bn.js@4.12.3: {} - body-parser@1.20.4: dependencies: bytes: 3.1.2 @@ -6192,8 +7712,6 @@ snapshots: dependencies: fill-range: 7.1.1 - brorand@1.1.0: {} - buffer-from@1.1.2: {} buffer@5.6.0: @@ -6253,6 +7771,8 @@ snapshots: chownr@1.1.4: {} + cjs-module-lexer@2.2.1: {} + class-transformer@0.5.1: {} cli-table3@0.6.5: @@ -6263,6 +7783,12 @@ snapshots: client-only@0.0.1: {} + cliui@8.0.1: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + cliui@9.0.1: dependencies: string-width: 7.2.0 @@ -6326,7 +7852,7 @@ snapshots: cookie@0.7.2: {} - core-js@3.48.0: {} + core-js@3.50.0: {} cors@2.8.6: dependencies: @@ -6341,6 +7867,8 @@ snapshots: csstype@3.2.3: {} + data-uri-to-buffer@4.0.1: {} + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -6459,16 +7987,6 @@ snapshots: ee-first@1.1.1: {} - elliptic@6.6.1: - dependencies: - bn.js: 4.12.3 - brorand: 1.1.0 - hash.js: 1.1.7 - hmac-drbg: 1.0.1 - inherits: 2.0.4 - minimalistic-assert: 1.0.1 - minimalistic-crypto-utils: 1.0.1 - emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} @@ -6555,6 +8073,8 @@ snapshots: es-module-lexer@2.0.0: {} + es-module-lexer@3.0.2: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -6809,6 +8329,8 @@ snapshots: transitivePeerDependencies: - supports-color + extend@3.0.2: {} + extendable-error@0.1.7: {} fast-deep-equal@3.1.3: {} @@ -6839,6 +8361,11 @@ snapshots: optionalDependencies: picomatch: 4.0.3 + fetch-blob@3.2.0: + dependencies: + node-domexception: 1.0.0 + web-streams-polyfill: 3.3.3 + figures@3.2.0: dependencies: escape-string-regexp: 1.0.5 @@ -6847,11 +8374,14 @@ snapshots: dependencies: flat-cache: 4.0.1 - file-type@16.5.4: + file-type@22.1.0: dependencies: - readable-web-to-node-stream: 3.0.4 - strtok3: 6.3.0 - token-types: 4.2.1 + '@tokenizer/inflate': 0.4.1 + strtok3: 10.3.5 + token-types: 6.1.2 + uint8array-extras: 1.5.0 + transitivePeerDependencies: + - supports-color file-uri-to-path@1.0.0: {} @@ -6909,6 +8439,12 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + formdata-polyfill@4.0.10: + dependencies: + fetch-blob: 3.2.0 + + forwarded-parse@2.1.2: {} + forwarded@0.2.0: {} fresh@0.5.2: {} @@ -6946,6 +8482,23 @@ snapshots: functions-have-names@1.2.3: {} + gaxios@7.1.3: + dependencies: + extend: 3.0.2 + https-proxy-agent: 7.0.6 + node-fetch: 3.3.2 + rimraf: 5.0.10 + transitivePeerDependencies: + - supports-color + + gcp-metadata@8.1.4: + dependencies: + gaxios: 7.1.3 + google-logging-utils: 1.1.3 + json-bigint: 1.0.0 + transitivePeerDependencies: + - supports-color + generator-function@2.0.1: {} get-caller-file@2.0.5: {} @@ -7023,11 +8576,13 @@ snapshots: merge2: 1.4.1 slash: 3.0.0 + google-logging-utils@1.1.3: {} + gopd@1.2.0: {} graceful-fs@4.2.11: {} - handlebars@4.7.8: + handlebars@4.7.9: dependencies: minimist: 1.2.8 neo-async: 2.6.2 @@ -7058,23 +8613,12 @@ snapshots: dependencies: has-symbols: 1.1.0 - hash.js@1.1.7: - dependencies: - inherits: 2.0.4 - minimalistic-assert: 1.0.1 - hasown@2.0.2: dependencies: function-bind: 1.1.2 he@1.2.0: {} - hmac-drbg@1.0.1: - dependencies: - hash.js: 1.1.7 - minimalistic-assert: 1.0.1 - minimalistic-crypto-utils: 1.0.1 - hosted-git-info@9.0.2: dependencies: lru-cache: 11.2.7 @@ -7110,6 +8654,13 @@ snapshots: roarr: 7.21.4 type-fest: 2.19.0 + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3(supports-color@8.1.1) + transitivePeerDependencies: + - supports-color + human-id@4.1.3: {} iconv-lite@0.4.24: @@ -7126,6 +8677,19 @@ snapshots: ignore@7.0.5: {} + import-in-the-middle@2.0.6: + dependencies: + acorn: 8.16.0 + acorn-import-attributes: 1.9.5(acorn@8.16.0) + cjs-module-lexer: 2.2.1 + module-details-from-path: 1.0.4 + + import-in-the-middle@3.5.1: + dependencies: + cjs-module-lexer: 2.2.1 + es-module-lexer: 3.0.2 + module-details-from-path: 1.0.4 + imurmurhash@0.1.4: {} indent-string@4.0.0: {} @@ -7333,6 +8897,10 @@ snapshots: dependencies: argparse: 2.0.1 + json-bigint@1.0.0: + dependencies: + bignumber.js: 9.3.1 + json-buffer@3.0.1: {} json-schema-traverse@0.4.1: {} @@ -7347,13 +8915,6 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 - key-encoder@2.0.3: - dependencies: - '@types/elliptic': 6.4.18 - asn1.js: 5.4.1 - bn.js: 4.12.3 - elliptic: 6.6.1 - keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -7379,6 +8940,8 @@ snapshots: dependencies: p-locate: 5.0.0 + lodash.camelcase@4.3.0: {} + lodash.defaults@4.2.0: {} lodash.isarguments@3.1.0: {} @@ -7391,6 +8954,8 @@ snapshots: lodash.startcase@4.4.0: {} + long@5.3.2: {} + lower-case@2.0.2: dependencies: tslib: 2.8.1 @@ -7438,16 +9003,16 @@ snapshots: dependencies: mime-db: 1.52.0 + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mime@1.6.0: {} mime@3.0.0: {} mimic-response@3.1.0: {} - minimalistic-assert@1.0.1: {} - - minimalistic-crypto-utils@1.0.1: {} - minimatch@10.2.2: dependencies: brace-expansion: 5.0.2 @@ -7468,6 +9033,8 @@ snapshots: mkdirp@3.0.1: {} + module-details-from-path@1.0.4: {} + mri@1.2.0: {} ms@2.0.0: {} @@ -7500,7 +9067,7 @@ snapshots: neo-async@2.6.2: {} - next@15.5.12(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + next@15.5.12(@opentelemetry/api@1.9.1)(@playwright/test@1.58.2)(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: '@next/env': 15.5.12 '@swc/helpers': 0.5.15 @@ -7518,6 +9085,7 @@ snapshots: '@next/swc-linux-x64-musl': 15.5.12 '@next/swc-win32-arm64-msvc': 15.5.12 '@next/swc-win32-x64-msvc': 15.5.12 + '@opentelemetry/api': 1.9.1 '@playwright/test': 1.58.2 sharp: 0.34.5 transitivePeerDependencies: @@ -7533,6 +9101,8 @@ snapshots: dependencies: semver: 7.7.4 + node-domexception@1.0.0: {} + node-exports-info@1.6.0: dependencies: array.prototype.flatmap: 1.3.3 @@ -7544,6 +9114,12 @@ snapshots: dependencies: whatwg-url: 5.0.0 + node-fetch@3.3.2: + dependencies: + data-uri-to-buffer: 4.0.1 + fetch-blob: 3.2.0 + formdata-polyfill: 4.0.10 + nodemailer-html-to-text@3.2.0: dependencies: html-to-text: 7.1.1 @@ -7614,6 +9190,16 @@ snapshots: one-webcrypto@1.0.3: {} + opentelemetry-plugin-better-sqlite3@1.13.0(better-sqlite3@12.6.2): + dependencies: + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.11.0(@opentelemetry/api@1.9.1) + '@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.1) + '@opentelemetry/semantic-conventions': 1.43.0 + better-sqlite3: 12.6.2 + transitivePeerDependencies: + - supports-color + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -7714,7 +9300,17 @@ snapshots: pathe@2.0.3: {} - peek-readable@4.1.0: {} + pg-int8@1.0.1: {} + + pg-protocol@1.16.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 picocolors@1.1.1: {} @@ -7794,6 +9390,16 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + prebuild-install@7.1.3: dependencies: detect-libc: 2.1.2 @@ -7815,6 +9421,8 @@ snapshots: prettier@3.8.1: {} + prettier@3.9.6: {} + process-warning@3.0.0: {} process-warning@5.0.0: {} @@ -7825,6 +9433,20 @@ snapshots: property-expr@2.0.6: {} + protobufjs@7.6.6: + dependencies: + '@protobufjs/aspromise': 1.1.2 + '@protobufjs/base64': 1.1.2 + '@protobufjs/codegen': 2.0.5 + '@protobufjs/eventemitter': 1.1.1 + '@protobufjs/fetch': 1.1.1 + '@protobufjs/float': 1.0.2 + '@protobufjs/path': 1.1.2 + '@protobufjs/pool': 1.1.0 + '@protobufjs/utf8': 1.1.2 + '@types/node': 22.19.11 + long: 5.3.2 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -7909,10 +9531,6 @@ snapshots: process: 0.11.10 string_decoder: 1.3.0 - readable-web-to-node-stream@3.0.4: - dependencies: - readable-stream: 4.7.0 - real-require@0.2.0: {} redis-errors@1.2.0: {} @@ -7951,6 +9569,15 @@ snapshots: repeat-string@1.6.1: {} + require-directory@2.1.1: {} + + require-in-the-middle@8.0.1: + dependencies: + debug: 4.4.3(supports-color@8.1.1) + module-details-from-path: 1.0.4 + transitivePeerDependencies: + - supports-color + resolve-from@5.0.0: {} resolve-pkg-maps@1.0.0: {} @@ -7966,6 +9593,10 @@ snapshots: reusify@1.1.0: {} + rimraf@5.0.10: + dependencies: + glob: 10.5.0 + roarr@7.21.4: dependencies: fast-printf: 1.6.10 @@ -8009,11 +9640,6 @@ snapshots: dependencies: queue-microtask: 1.2.3 - rxjs@7.8.2: - dependencies: - tslib: 2.8.1 - optional: true - safe-array-concat@1.1.3: dependencies: call-bind: 1.0.9 @@ -8315,10 +9941,9 @@ snapshots: strip-json-comments@2.0.1: {} - strtok3@6.3.0: + strtok3@10.3.5: dependencies: '@tokenizer/token': 0.3.0 - peek-readable: 4.1.0 styled-jsx@5.1.6(react@19.2.4): dependencies: @@ -8339,6 +9964,8 @@ snapshots: dependencies: standardwebhooks: 1.0.0 + systeminformation@5.33.10: {} + tagged-tag@1.0.0: {} tar-fs@2.1.4: @@ -8393,8 +10020,9 @@ snapshots: toidentifier@1.0.1: {} - token-types@4.2.1: + token-types@6.1.2: dependencies: + '@borewit/text-codec': 0.2.2 '@tokenizer/token': 0.3.0 ieee754: 1.2.1 @@ -8483,10 +10111,6 @@ snapshots: possible-typed-array-names: 1.1.0 reflect.getprototypeof: 1.0.10 - typed-emitter@2.1.0: - optionalDependencies: - rxjs: 7.8.2 - typescript-eslint@8.56.0(eslint@10.0.1)(typescript@5.9.3): dependencies: '@typescript-eslint/eslint-plugin': 8.56.0(@typescript-eslint/parser@8.56.0(eslint@10.0.1)(typescript@5.9.3))(eslint@10.0.1)(typescript@5.9.3) @@ -8503,6 +10127,8 @@ snapshots: uglify-js@3.19.3: optional: true + uint8array-extras@1.5.0: {} + uint8arrays@3.0.0: dependencies: multiformats: 9.9.0 @@ -8610,7 +10236,7 @@ snapshots: tsx: 4.21.0 yaml: 2.8.3 - vitest@4.1.0(@types/node@20.19.33)(vite@7.3.1(@types/node@20.19.33)(tsx@4.21.0)(yaml@2.8.3)): + vitest@4.1.0(@opentelemetry/api@1.9.1)(@types/node@20.19.33)(vite@7.3.1(@types/node@20.19.33)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.0 '@vitest/mocker': 4.1.0(vite@7.3.1(@types/node@20.19.33)(tsx@4.21.0)(yaml@2.8.3)) @@ -8633,12 +10259,13 @@ snapshots: vite: 7.3.1(@types/node@20.19.33)(tsx@4.21.0)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: + '@opentelemetry/api': 1.9.1 '@types/node': 20.19.33 transitivePeerDependencies: - msw optional: true - vitest@4.1.0(@types/node@22.19.11)(vite@7.3.1(@types/node@22.19.11)(tsx@4.21.0)(yaml@2.8.3)): + vitest@4.1.0(@opentelemetry/api@1.9.1)(@types/node@22.19.11)(vite@7.3.1(@types/node@22.19.11)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.0 '@vitest/mocker': 4.1.0(vite@7.3.1(@types/node@22.19.11)(tsx@4.21.0)(yaml@2.8.3)) @@ -8661,10 +10288,13 @@ snapshots: vite: 7.3.1(@types/node@22.19.11)(tsx@4.21.0)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: + '@opentelemetry/api': 1.9.1 '@types/node': 22.19.11 transitivePeerDependencies: - msw + web-streams-polyfill@3.3.3: {} + webidl-conversions@3.0.1: {} whatwg-url@5.0.0: @@ -8750,12 +10380,26 @@ snapshots: xmlbuilder@15.1.1: {} + xtend@4.0.2: {} + y18n@5.0.8: {} yaml@2.8.3: {} + yargs-parser@21.1.1: {} + yargs-parser@22.0.0: {} + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + yargs@18.1.0: dependencies: cliui: 9.0.1 diff --git a/vitest.config.ts b/vitest.config.ts index 954c8956..00a23a12 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -14,19 +14,14 @@ export default defineConfig({ reporter: ['text', 'html', 'lcov'], reportsDirectory: './coverage', include: ['packages/*/src/**/*.ts'], - exclude: [ - 'packages/*/src/__tests__/**', - '**/*.test.ts', - '**/*.d.ts', - 'packages/shared/src/version.ts', - ], + exclude: ['packages/*/src/__tests__/**', '**/*.test.ts', '**/*.d.ts'], // Ratchet thresholds — update these whenever coverage increases. // See AGENTS.md for the ratcheting policy. thresholds: { - statements: 58, - branches: 57, - functions: 71, - lines: 57, + statements: 61, + branches: 60, + functions: 74, + lines: 60, }, }, }, From 593b02ba222def22f6b1770918a8b3607f36142b Mon Sep 17 00:00:00 2001 From: kzoeps Date: Wed, 16 Sep 2026 11:20:25 +0600 Subject: [PATCH 80/84] docs: clarify email template URL requirements --- .agents/skills/epds-login/references/client-metadata.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.agents/skills/epds-login/references/client-metadata.md b/.agents/skills/epds-login/references/client-metadata.md index ab258af9..8aab47bf 100644 --- a/.agents/skills/epds-login/references/client-metadata.md +++ b/.agents/skills/epds-login/references/client-metadata.md @@ -113,7 +113,7 @@ key generation and serving details. | `dpop_bound_access_tokens` | Yes | Always `true` | | `client_uri` | No | Your app's homepage URL | | `logo_uri` | No | URL to your app logo (shown on login page) | -| `email_template_uri` | No | URL to a custom OTP email HTML template. Trusted clients only. | +| `email_template_uri` | No | URL to a custom OTP email HTML template. Trusted clients only. Must use HTTPS; HTTP is permitted only when `EPDS_ALLOW_PRIVATE_IPS=true` enables `allowHttp` for local development. Keep that setting disabled for internet-facing deployments. | | `email_subject_template` | No | Custom email subject line with `{{code}}` placeholder. Trusted clients only. | | `brand_color` | No | Hex colour for buttons and input focus rings (default: `#1A130F`) | | `epds_handle_mode` | No | ePDS extension. Handle picker variant for new users: `"picker"`, `"random"`, or `"picker-with-random"` (default). See [tutorial](../../../../docs/tutorial.md#optional-control-the-handle-picker). | From 6f41f2d011993c06cf55ef6f8015665570f65247 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Tue, 15 Sep 2026 17:59:57 +0600 Subject: [PATCH 81/84] pds-ui: adapt branding to provider UI 0.10 --- .changeset/pds-0-5-34-upgrade.md | 6 +- docs/design/pds-0.5.34-upgrade.md | 51 +++++----- e2e/step-definitions/client-branding.steps.ts | 2 +- packages/demo/src/__tests__/theme.test.ts | 14 ++- packages/demo/src/lib/theme.ts | 39 ++------ .../src/__tests__/default-branding.test.ts | 34 ++++--- .../src/__tests__/preview-consent.test.ts | 95 ++++++++++++++----- packages/pds-core/src/index.ts | 71 +------------- packages/pds-core/src/lib/default-branding.ts | 75 ++++----------- packages/pds-core/src/lib/preview-consent.ts | 17 ++-- packages/pds-core/src/lib/preview-shared.ts | 3 + packages/pds-core/src/preview-routes.ts | 79 +++++++++++++++ 12 files changed, 255 insertions(+), 231 deletions(-) create mode 100644 packages/pds-core/src/preview-routes.ts diff --git a/.changeset/pds-0-5-34-upgrade.md b/.changeset/pds-0-5-34-upgrade.md index 3f904e94..77dc741d 100644 --- a/.changeset/pds-0-5-34-upgrade.md +++ b/.changeset/pds-0-5-34-upgrade.md @@ -2,12 +2,12 @@ 'ePDS': minor --- -Update the account selection and approval screens while keeping account switching in the email sign-in flow. +Update sign-in and approval screens while keeping account switching in email sign-in and preserving ePDS branding. **Affects:** End users, Client app developers, Operators -**End users:** Selecting “Another account” continues to open the email sign-in form instead of the server's password form. +**End users:** Selecting “Another account” continues to open the email sign-in form instead of the server's password form. Authorization and account-selection screens now use consistent ePDS colors, surfaces, and responsive controls instead of partially falling back to upstream styling. -**Client app developers:** Review consent and account-picker branding against the updated screens; CSS targeting old gray/slate utility classes may no longer match. Unresolved supported handles now return `HandleNotFound`, and OAuth/account-management request bodies over 100 KiB return HTTP 413. +**Client app developers:** Review consent and account-picker branding against the updated screens. Update custom `branding.css` to target provider semantic variables and `data-slot` component attributes; CSS targeting old gray/slate utility classes may no longer match. The demo themes illustrate the supported provider UI 0.10 approach. Unresolved supported handles now return `HandleNotFound`, and OAuth/account-management request bodies over 100 KiB return HTTP 413. **Operators:** This upgrades PDS to 0.5.34 and shared safe-fetch to 0.4.0. Rebuild both core and auth images, verify startup with their bundled Node/Undici versions, and validate outbound identity/service resolution against the stricter URL policy before rollout. No new upstream database migrations were found in the assessed release range; rehearse rollback against copied data rather than assuming runtime compatibility. diff --git a/docs/design/pds-0.5.34-upgrade.md b/docs/design/pds-0.5.34-upgrade.md index 59f738a2..c35e42b0 100644 --- a/docs/design/pds-0.5.34-upgrade.md +++ b/docs/design/pds-0.5.34-upgrade.md @@ -39,11 +39,13 @@ Required work: 1. Align PDS, OAuth provider, and OAuth provider UI versions. 2. Fix the `Another account` control lookup for the redesigned provider UI. -3. Render and validate upstream authorization pages with ePDS and client - branding. -4. Add a small Ma Earth compatibility patch only if rendering confirms visual - regressions. -5. Run full OAuth, PDS, container, and rollback validation. +3. Repair and validate the consent preview against the redesigned provider UI. +4. Update generic ePDS branding selectors for the provider's semantic CSS + targets. +5. Build a temporary Ma Earth-branded MVP of the new consent layout for team + review; do not recreate the old layout or permanently own Ma Earth CSS in + ePDS. +6. Run full OAuth, PDS, container, and rollback validation. No new upstream database migration was found in this release range. Core OAuth provider methods used by ePDS remain compatible in the inspected source, but @@ -196,23 +198,21 @@ Normal exposure: - Handle/DID login for a user on another PDS uses that PDS's UI and branding, not Certified.one's UI. -Ma Earth's source cannot be changed as part of this work. If comparison with the -current consent screen shows visual regressions, use a small ePDS-owned -compatibility stylesheet applied only for the exact Ma Earth client ID and only -on the relevant upstream authorization response. +Provider UI 0.10.3 is a redesigned authorization layout. The goal is no longer +to reproduce the old component geometry. Instead, build a temporary MVP that +adapts Ma Earth's current visual language to the new layout while preserving the +provider's new consent information and behavior. -CSS order should be: +Keep ownership explicit: ```text -1. ePDS default branding CSS -2. existing Ma Earth client CSS -3. ePDS-owned Ma Earth compatibility CSS +permanent: generic ePDS preview and default-branding fixes +temporary: adapted Ma Earth CSS served by the trusted demo for review +future: approved production CSS remains owned by the Ma Earth application ``` -The compatibility stylesheet should preserve the current appearance, not -redesign it. Fix only measured differences. See -[`maearth-consent-branding-compatibility.md`](./maearth-consent-branding-compatibility.md) -for the decision and test plan. +The temporary demo fixture must be reverted after review. ePDS must not retain a +permanent copy of Ma Earth's palette, logo rules, or client-specific stylesheet. ### Gainforest @@ -453,11 +453,14 @@ image from a clean dependency state and verify that the module loads. 6. Fix the native `Another account` button lookup and add a DOM-level regression test for old and new markup. 7. Verify provider error handling and preview asset resolution with the aligned - graph. -8. Render Ma Earth first-grant and expanded-scope consent. Add only the measured - ePDS-owned compatibility CSS needed to preserve the current appearance. -9. Run static checks, tests, coverage, container checks, and e2e validation. -10. Rehearse rollout and rollback against copied production-like data before + graph, including updating the consent fixture to the new `selectedDid` / + `account.did` hydration contract. +8. Update generic ePDS branding selectors to the new semantic provider targets. +9. Temporarily serve an adapted version of Ma Earth's current CSS from the + trusted demo and render the redesigned consent page for team review. +10. Revert the temporary demo branding while retaining generic ePDS fixes. +11. Run static checks, tests, coverage, container checks, and e2e validation. +12. Rehearse rollout and rollback against copied production-like data before deployment. ## Validation matrix @@ -501,8 +504,8 @@ Validate desktop and mobile rendering for: - custom handle page; - upstream consent page; - upstream account chooser; -- Ma Earth first-grant consent; -- Ma Earth expanded-scope consent; +- the temporary Ma Earth MVP on first-grant consent; +- the temporary Ma Earth MVP with expanded-scope consent; - CSP acceptance and client CSS ordering. ### Runtime and PDS behavior diff --git a/e2e/step-definitions/client-branding.steps.ts b/e2e/step-definitions/client-branding.steps.ts index 8d96373e..cc886ded 100644 --- a/e2e/step-definitions/client-branding.steps.ts +++ b/e2e/step-definitions/client-branding.steps.ts @@ -21,7 +21,7 @@ import { fillOtp } from '../support/otp.js' // A short, stable fragment from the demo's branding.css. Any substring that // only the injected CSS would produce works — we pick the dark body bg since // it's also what makes the visual difference in the comparison scenario. -const INJECTED_CSS_SIGNATURE = 'body { background: #1a1208' +const INJECTED_CSS_SIGNATURE = 'body, html { background: #1a1208' // Default page background the auth-service login-page renders when no // branding CSS is injected — the `--page-bg` CSS var's default value. diff --git a/packages/demo/src/__tests__/theme.test.ts b/packages/demo/src/__tests__/theme.test.ts index f081566f..d29a6b14 100644 --- a/packages/demo/src/__tests__/theme.test.ts +++ b/packages/demo/src/__tests__/theme.test.ts @@ -32,10 +32,16 @@ describe('getTheme', () => { const theme = getTheme() expect(theme?.page.primary).toBe('#8b5cf6') - expect(theme?.injectedCss).toContain( - ':root { --branding-color-primary: 139 92 246; --branding-color-primary-contrast: 26 16 51; }', - ) - expect(theme?.injectedCss).toContain(String.raw`.md\:bg-slate-100`) + expect(theme?.injectedCss).toContain('--branding-color-primary: 139 92 246') + expect(theme?.injectedCss).toContain('--background: #251845') + expect(theme?.injectedCss).toContain('--card: #251845') + expect(theme?.injectedCss).toContain('--secondary: #1a1033') + expect(theme?.injectedCss).toContain('--muted: #1a1033') + expect(theme?.injectedCss).toContain('--border: #3d2a5c') + expect(theme?.injectedCss).toContain('[data-slot="card"]') + expect(theme?.injectedCss).not.toContain(String.raw`.md\:bg-slate-100`) + expect(theme?.injectedCss).not.toContain('.text-slate-') + expect(theme?.injectedCss).not.toContain('.bg-gray-') expect(theme?.injectedCss).toContain( '.account-info { background: #2d1a4f; color: #c4b5fd; }', ) diff --git a/packages/demo/src/lib/theme.ts b/packages/demo/src/lib/theme.ts index 1d1f2edb..eac4e033 100644 --- a/packages/demo/src/lib/theme.ts +++ b/packages/demo/src/lib/theme.ts @@ -87,36 +87,17 @@ function buildInjectedCss( } = options return [ - // Provider-UI consent page: recolour Tailwind utilities via the - // --branding-color-* custom props the UI reads through - // `rgb(var(--branding-color-primary))`. Channels are space-separated. - `:root { --branding-color-primary: ${primaryChannels}; --branding-color-primary-contrast: ${primaryContrastChannels}; }`, - // Body background & primary text. Provider-UI sets these on - // via `bg-white text-slate-900 dark:bg-slate-900 dark:text-slate-100`, - // so class-specific selectors (plus !important for dark-mode) are - // needed to override Tailwind's equal-specificity rules. - `body { background: ${page.bg} !important; color: ${page.text} !important; }`, - `html { background: ${page.bg}; }`, - // Consent page's "left strip" header column (md:bg-slate-100 - // md:dark:bg-slate-800) — paint it a shade lighter than body so - // it reads as a distinct surface, matching the demo's own cards. - String.raw`.md\:bg-slate-100, .md\:dark\:bg-slate-800 { background-color: ${page.surface} !important; }`, - String.raw`.md\:dark\:border-slate-700 { border-color: ${page.inputBorder} !important; }`, - `main { background: ${page.surface} !important; border-color: ${page.inputBorder} !important; box-shadow: ${page.surfaceShadow} !important; }`, - // Three-tone text hierarchy on the consent page. Provider-UI uses - // `text-slate-{100,200,300,400}` + `text-neutral-{400,500}` for - // primary / muted / hint text; remap to the theme's three shades. - String.raw`.text-slate-900, .dark\:text-slate-100, .text-slate-800, .dark\:text-slate-200, .text-gray-800, .dark\:text-gray-200 { color: ${page.text} !important; }`, - String.raw`.text-slate-700, .text-slate-600, .dark\:text-slate-300, .dark\:text-slate-400 { color: ${page.textMuted} !important; }`, - String.raw`.text-slate-500, .text-gray-500, .text-neutral-500, .dark\:text-neutral-400, .dark\:text-gray-300, .dark\:text-gray-400 { color: ${page.textHint} !important; }`, - // Consent page secondary buttons (Deny access etc.) default to - // .bg-gray-300 / .dark:bg-slate-600, which reads as a jarring - // slate-grey against the themed card. Tint them to a muted surface - // that harmonises with the palette. - String.raw`.bg-gray-100, .bg-gray-300, .dark\:bg-slate-600, .bg-gray-200, .dark\:bg-gray-800, .dark\:bg-gray-700 { background-color: ${page.inputBorder} !important; color: ${page.text} !important; border-color: ${page.inputBorder} !important; }`, - String.raw`.hover\:bg-gray-200:hover, .dark\:hover\:bg-gray-700:hover { background-color: ${secondarySurfaceHover} !important; }`, + // Provider UI 0.10 consumes semantic color roles. Client CSS loads after + // the provider stylesheet, so these values also win over its dark-media + // defaults without coupling the theme to generated utility-class names. + `:root { color-scheme: light; --branding-color-primary: ${primaryChannels}; --branding-color-primary-contrast: ${primaryContrastChannels}; --background: ${page.surface}; --foreground: ${page.text}; --card: ${page.surface}; --card-foreground: ${page.text}; --popover: ${page.surface}; --popover-foreground: ${page.text}; --secondary: ${page.inputBg}; --secondary-foreground: ${page.text}; --muted: ${page.bg}; --muted-foreground: ${page.textMuted}; --accent: ${secondarySurfaceHover}; --accent-foreground: ${page.text}; --border: ${page.inputBorder}; --input: ${page.inputBorder}; --ring: ${page.focusBorder}; --page-bg: ${page.bg}; --card-bg: ${page.surface}; --card-border: ${page.inputBorder}; --input-bg: ${page.inputBg}; --input-border: ${page.inputBorder}; --focus-border: ${page.focusBorder}; --btn-secondary-border: ${page.inputBorder}; }`, + `body, html { background: ${page.bg} !important; color: ${page.text} !important; }`, + // data-slot is the provider's component contract; unlike its generated + // Tailwind utility sequence, it describes the card across UI rebuilds. + `[data-slot="card"] { border: 1px solid ${page.inputBorder}; box-shadow: ${page.surfaceShadow}; }`, + `[data-slot="card-footer"] { border-color: ${page.inputBorder}; }`, // auth-service hand-rolled markup - `:root { --page-bg: ${page.bg}; --card-bg: ${page.surface}; --card-border: ${page.inputBorder}; --input-bg: ${page.inputBg}; --input-border: ${page.inputBorder}; --muted-foreground: ${page.textMuted}; --focus-border: ${page.focusBorder}; --btn-secondary-border: ${page.inputBorder}; }`, + `.container { background: ${page.surface}; box-shadow: ${page.surfaceShadow}; }`, `h1 { color: ${page.text}; }`, `.subtitle { color: ${page.textMuted}; }`, diff --git a/packages/pds-core/src/__tests__/default-branding.test.ts b/packages/pds-core/src/__tests__/default-branding.test.ts index 3f028232..e053384e 100644 --- a/packages/pds-core/src/__tests__/default-branding.test.ts +++ b/packages/pds-core/src/__tests__/default-branding.test.ts @@ -3,23 +3,29 @@ import { describe, expect, it } from 'vitest' import { DEFAULT_BRANDING_CSS } from '../lib/default-branding.js' describe('DEFAULT_BRANDING_CSS', () => { - it('stacks upstream FormCard action rows on mobile only', () => { - const upstreamActionRowSelector = - '.flex.flex-row-reverse.flex-wrap.items-center.justify-end.space-x-2.space-x-reverse' + it('themes provider UI 0.10 semantic color roles', () => { + expect(DEFAULT_BRANDING_CSS).toContain('--background: #F8F8F8') + expect(DEFAULT_BRANDING_CSS).toContain('--card: #F8F8F8') + expect(DEFAULT_BRANDING_CSS).toContain('--secondary: #FFFFFF') + expect(DEFAULT_BRANDING_CSS).toContain('--muted: #E8E8E8') + expect(DEFAULT_BRANDING_CSS).toContain('--muted-foreground: #6B6B6B') + expect(DEFAULT_BRANDING_CSS).toContain('--accent: #FAFAFA') + expect(DEFAULT_BRANDING_CSS).toContain('--border: #E5E5E5') + }) - expect(DEFAULT_BRANDING_CSS).toContain('@media (max-width: 767.98px)') - expect(DEFAULT_BRANDING_CSS).toContain(upstreamActionRowSelector) - expect(DEFAULT_BRANDING_CSS).toContain( - `${upstreamActionRowSelector} { flex-direction: column-reverse !important;`, - ) - expect(DEFAULT_BRANDING_CSS).toContain( - 'align-items: stretch !important; gap: 0.5rem !important;', - ) + it('styles the provider card through its data-slot contract', () => { + expect(DEFAULT_BRANDING_CSS).toContain('[data-slot="card"]') expect(DEFAULT_BRANDING_CSS).toContain( - `${upstreamActionRowSelector} > * { margin-left: 0 !important; margin-right: 0 !important; margin-inline-start: 0 !important; margin-inline-end: 0 !important; }`, + 'box-shadow: 0 1px 2px rgba(0,0,0,0.03)', ) - expect(DEFAULT_BRANDING_CSS).toContain( - `${upstreamActionRowSelector} > .flex-auto { display: none !important; }`, + }) + + it('does not depend on provider UI 0.8 gray/slate layout utilities', () => { + expect(DEFAULT_BRANDING_CSS).not.toContain('.md\\:bg-slate-100') + expect(DEFAULT_BRANDING_CSS).not.toContain('.text-slate-') + expect(DEFAULT_BRANDING_CSS).not.toContain('.bg-gray-') + expect(DEFAULT_BRANDING_CSS).not.toContain( + '.flex.flex-row-reverse.flex-wrap.items-center', ) }) }) diff --git a/packages/pds-core/src/__tests__/preview-consent.test.ts b/packages/pds-core/src/__tests__/preview-consent.test.ts index 26a76c26..a02b8226 100644 --- a/packages/pds-core/src/__tests__/preview-consent.test.ts +++ b/packages/pds-core/src/__tests__/preview-consent.test.ts @@ -1,9 +1,13 @@ +import { createServer } from 'node:http' +import express from 'express' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createPreviewConsentHandler, renderPreviewIndex, } from '../lib/preview-consent.js' +import { createPreviewChooserHandler } from '../lib/preview-chooser.js' +import { installPreviewRoutes } from '../preview-routes.js' import { mockLogger, mockRes } from './preview-test-helpers.js' describe('createPreviewConsentHandler', () => { @@ -50,34 +54,79 @@ describe('createPreviewConsentHandler', () => { process.env.PDS_PREVIEW_ROUTES = '1' }) - it('renders fixture consent HTML with default client id when none provided', async () => { - const handler = createPreviewConsentHandler({ + it('renders fixture consent HTML through the registered route', async () => { + const deps = { trustedClients: [], resolveClientMetadata: () => Promise.resolve({}), getClientCss: () => null, logger: mockLogger(), - })! - const res = mockRes() - await handler({ query: {} }, res) + } + const app = express() + installPreviewRoutes(app, { + previewConsentHandler: createPreviewConsentHandler(deps)!, + previewChooserHandler: createPreviewChooserHandler({ + ...deps, + authOrigin: 'http://auth.localhost', + })!, + authHostname: 'auth.localhost', + pdsPublicUrl: 'http://pds.localhost', + trustedClients: [], + logger: deps.logger, + }) + const server = createServer(app) + const serverReady = new Promise((resolve, reject) => { + server.once('listening', resolve) + server.once('error', reject) + }) + server.listen(0) - expect(res.headers['Content-Type']).toBe('text/html; charset=utf-8') - expect(res.headers['Cache-Control']).toBe('no-store') - expect(res.headers['Content-Security-Policy']).toContain( - "script-src 'self' 'unsafe-inline'", - ) - expect(res.body).toContain('preview.example/client-metadata.json') - // Drives the SPA to the consent view, not sign-in. The hydration - // data is JSON-stringified twice (once for the value, once for the - // script-literal), so field names appear with escaped quotes. - expect(res.body).toContain(String.raw`\"consentRequired\":true`) - expect(res.body).toContain(String.raw`\"selected\":true`) - // No loginHint — would force sign-in mode in authorize-view.tsx: - expect(res.body).not.toContain(String.raw`\"loginHint\"`) - // Hydration script + entry bundle present: - expect(res.body).toMatch( - /