From c7b7771fa564669f3ebf5afb6cbf13eb0bc99a2d Mon Sep 17 00:00:00 2001 From: Evelyn Osman Date: Tue, 7 Jul 2026 16:30:54 +0200 Subject: [PATCH 01/24] Replace transition:generic with permission sets scopes --- .agents/skills/epds-login/SKILL.md | 20 +++++++++++++++++-- .../epds-login/references/client-metadata.md | 9 +++++---- .agents/skills/epds-login/references/flows.md | 2 +- .../demo/src/app/api/oauth/login/route.ts | 2 +- .../src/app/client-metadata.json/route.ts | 2 +- 5 files changed, 26 insertions(+), 9 deletions(-) diff --git a/.agents/skills/epds-login/SKILL.md b/.agents/skills/epds-login/SKILL.md index 03dfa329..7a520a4d 100644 --- a/.agents/skills/epds-login/SKILL.md +++ b/.agents/skills/epds-login/SKILL.md @@ -15,6 +15,11 @@ AT Protocol universe (a DID, a handle, a data repository) automatically provisio 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). +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. + ## Two Flows | Flow | App provides | How user starts | Implementation | @@ -57,7 +62,7 @@ are mutually exclusive: "client_id": "https://yourapp.example.com/client-metadata.json", "client_name": "Your App", "redirect_uris": ["https://yourapp.example.com/api/oauth/callback"], - "scope": "atproto transition:generic", + "scope": "atproto include:org.hypercerts.authWrite include:org.hyperboards.authWrite include:app.certified.authWrite", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "private_key_jwt", @@ -67,6 +72,17 @@ 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 three hypercerts specific permission sets via the `include:` prefix — +> `include:org.hypercerts.authWrite`, `include:org.hyperboards.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. + 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 @@ -85,7 +101,7 @@ const client = new NodeOAuthClient({ client_id: 'https://yourapp.example.com/client-metadata.json', client_name: 'Your App', redirect_uris: ['https://yourapp.example.com/api/oauth/callback'], - scope: 'atproto transition:generic', + scope: 'atproto include:org.hypercerts.authWrite include:org.hyperboards.authWrite include:app.certified.authWrite', grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'private_key_jwt', diff --git a/.agents/skills/epds-login/references/client-metadata.md b/.agents/skills/epds-login/references/client-metadata.md index b99d9dcb..b775e226 100644 --- a/.agents/skills/epds-login/references/client-metadata.md +++ b/.agents/skills/epds-login/references/client-metadata.md @@ -53,7 +53,7 @@ mutually exclusive — the PDS rejects metadata that has both. "client_id": "https://yourapp.example.com/client-metadata.json", "client_name": "Your App Name", "redirect_uris": ["https://yourapp.example.com/api/oauth/callback"], - "scope": "atproto transition:generic", + "scope": "atproto include:org.hypercerts.authWrite include:org.hyperboards.authWrite include:app.certified.authWrite", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "private_key_jwt", @@ -70,7 +70,7 @@ mutually exclusive — the PDS rejects metadata that has both. "client_id": "https://yourapp.example.com/client-metadata.json", "client_name": "Your App Name", "redirect_uris": ["https://yourapp.example.com/api/oauth/callback"], - "scope": "atproto transition:generic", + "scope": "atproto include:org.hypercerts.authWrite include:org.hyperboards.authWrite include:app.certified.authWrite", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "private_key_jwt", @@ -96,6 +96,7 @@ host — useful for simpler setups. See [Publishing the JWKS document](#publishing-the-jwks-document) below for key generation and serving details. + ## All supported fields | Field | Required | Description | @@ -103,7 +104,7 @@ key generation and serving details. | `client_id` | Yes | Must match the URL where this file is hosted | | `client_name` | Yes | Shown on the login page and in OTP emails | | `redirect_uris` | Yes | Array of allowed callback URLs after login | -| `scope` | Yes | Always `"atproto transition:generic"` | +| `scope` | Yes | Space-separated list. `atproto` is required and must come first; add the scopes/permission sets your app needs (the examples use `include:org.hypercerts.authWrite include:org.hyperboards.authWrite include:app.certified.authWrite`). Avoid the legacy `transition:generic` catch-all — see the `atproto-oauth` skill. | | `grant_types` | Yes | Always `["authorization_code", "refresh_token"]` | | `response_types` | Yes | Always `["code"]` | | `token_endpoint_auth_method` | Yes | `"private_key_jwt"` (recommended) or `"none"` — see above | @@ -266,7 +267,7 @@ above for the trade-offs. "client_id": "https://yourapp.example.com/client-metadata.json", "client_name": "Your App Name", "redirect_uris": ["https://yourapp.example.com/api/oauth/callback"], - "scope": "atproto transition:generic", + "scope": "atproto include:org.hypercerts.authWrite include:org.hyperboards.authWrite include:app.certified.authWrite", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "none", diff --git a/.agents/skills/epds-login/references/flows.md b/.agents/skills/epds-login/references/flows.md index a12ce519..8d3f471a 100644 --- a/.agents/skills/epds-login/references/flows.md +++ b/.agents/skills/epds-login/references/flows.md @@ -156,7 +156,7 @@ export async function handleLogin(email: string) { client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, response_type: 'code', - scope: 'atproto transition:generic', + scope: 'atproto include:org.hypercerts.authWrite include:org.hyperboards.authWrite include:app.certified.authWrite', state, code_challenge: codeChallenge, code_challenge_method: 'S256', diff --git a/packages/demo/src/app/api/oauth/login/route.ts b/packages/demo/src/app/api/oauth/login/route.ts index 30a855e0..1a953994 100644 --- a/packages/demo/src/app/api/oauth/login/route.ts +++ b/packages/demo/src/app/api/oauth/login/route.ts @@ -150,7 +150,7 @@ export async function GET(request: Request) { client_id: clientId, redirect_uri: redirectUri, response_type: 'code', - scope: 'atproto transition:generic', + scope: 'atproto include:org.hypercerts.authWrite include:org.hyperboards.authWrite include:app.certified.authWrite', state, code_challenge: codeChallenge, code_challenge_method: 'S256', diff --git a/packages/demo/src/app/client-metadata.json/route.ts b/packages/demo/src/app/client-metadata.json/route.ts index 0651af16..f02e8bf0 100644 --- a/packages/demo/src/app/client-metadata.json/route.ts +++ b/packages/demo/src/app/client-metadata.json/route.ts @@ -50,7 +50,7 @@ export async function GET() { client_uri: baseUrl, logo_uri: `${baseUrl}/certified-logo.png`, redirect_uris: [`${baseUrl}/api/oauth/callback`], - scope: 'atproto transition:generic', + scope: 'atproto include:org.hypercerts.authWrite include:org.hyperboards.authWrite include:app.certified.authWrite', grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], ...(isConfidential From 990ef2bdba834e29055a760bb94e60c74ef752f4 Mon Sep 17 00:00:00 2001 From: Evelyn Osman Date: Tue, 7 Jul 2026 16:50:12 +0200 Subject: [PATCH 02/24] Drop hyperboards permission set from demo --- .agents/skills/epds-login/SKILL.md | 11 ++++++----- .../skills/epds-login/references/client-metadata.md | 9 ++++----- .agents/skills/epds-login/references/flows.md | 3 ++- docs/tutorial.md | 8 +++++--- packages/demo/src/app/api/oauth/login/route.ts | 3 ++- packages/demo/src/app/client-metadata.json/route.ts | 3 ++- 6 files changed, 21 insertions(+), 16 deletions(-) diff --git a/.agents/skills/epds-login/SKILL.md b/.agents/skills/epds-login/SKILL.md index 7a520a4d..1d5fadc3 100644 --- a/.agents/skills/epds-login/SKILL.md +++ b/.agents/skills/epds-login/SKILL.md @@ -62,7 +62,7 @@ are mutually exclusive: "client_id": "https://yourapp.example.com/client-metadata.json", "client_name": "Your App", "redirect_uris": ["https://yourapp.example.com/api/oauth/callback"], - "scope": "atproto include:org.hypercerts.authWrite include:org.hyperboards.authWrite include:app.certified.authWrite", + "scope": "atproto include:org.hypercerts.authWrite include:app.certified.authWrite", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "private_key_jwt", @@ -74,9 +74,9 @@ 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 three hypercerts specific permission sets via the `include:` prefix — -> `include:org.hypercerts.authWrite`, `include:org.hyperboards.authWrite`, and -> `include:app.certified.authWrite`; substitute the permission sets your own app defines. A +> 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` @@ -101,7 +101,8 @@ const client = new NodeOAuthClient({ client_id: 'https://yourapp.example.com/client-metadata.json', client_name: 'Your App', redirect_uris: ['https://yourapp.example.com/api/oauth/callback'], - scope: 'atproto include:org.hypercerts.authWrite include:org.hyperboards.authWrite include:app.certified.authWrite', + scope: + 'atproto include:org.hypercerts.authWrite include:app.certified.authWrite', grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'private_key_jwt', diff --git a/.agents/skills/epds-login/references/client-metadata.md b/.agents/skills/epds-login/references/client-metadata.md index b775e226..130aceee 100644 --- a/.agents/skills/epds-login/references/client-metadata.md +++ b/.agents/skills/epds-login/references/client-metadata.md @@ -53,7 +53,7 @@ mutually exclusive — the PDS rejects metadata that has both. "client_id": "https://yourapp.example.com/client-metadata.json", "client_name": "Your App Name", "redirect_uris": ["https://yourapp.example.com/api/oauth/callback"], - "scope": "atproto include:org.hypercerts.authWrite include:org.hyperboards.authWrite include:app.certified.authWrite", + "scope": "atproto include:org.hypercerts.authWrite include:app.certified.authWrite", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "private_key_jwt", @@ -70,7 +70,7 @@ mutually exclusive — the PDS rejects metadata that has both. "client_id": "https://yourapp.example.com/client-metadata.json", "client_name": "Your App Name", "redirect_uris": ["https://yourapp.example.com/api/oauth/callback"], - "scope": "atproto include:org.hypercerts.authWrite include:org.hyperboards.authWrite include:app.certified.authWrite", + "scope": "atproto include:org.hypercerts.authWrite include:app.certified.authWrite", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "private_key_jwt", @@ -96,7 +96,6 @@ host — useful for simpler setups. See [Publishing the JWKS document](#publishing-the-jwks-document) below for key generation and serving details. - ## All supported fields | Field | Required | Description | @@ -104,7 +103,7 @@ key generation and serving details. | `client_id` | Yes | Must match the URL where this file is hosted | | `client_name` | Yes | Shown on the login page and in OTP emails | | `redirect_uris` | Yes | Array of allowed callback URLs after login | -| `scope` | Yes | Space-separated list. `atproto` is required and must come first; add the scopes/permission sets your app needs (the examples use `include:org.hypercerts.authWrite include:org.hyperboards.authWrite include:app.certified.authWrite`). Avoid the legacy `transition:generic` catch-all — see the `atproto-oauth` skill. | +| `scope` | Yes | Space-separated list. `atproto` is required and must come first; add the scopes/permission sets your app needs (the examples use `include:org.hypercerts.authWrite include:app.certified.authWrite`). Avoid the legacy `transition:generic` catch-all — see the `atproto-oauth` skill. | | `grant_types` | Yes | Always `["authorization_code", "refresh_token"]` | | `response_types` | Yes | Always `["code"]` | | `token_endpoint_auth_method` | Yes | `"private_key_jwt"` (recommended) or `"none"` — see above | @@ -267,7 +266,7 @@ above for the trade-offs. "client_id": "https://yourapp.example.com/client-metadata.json", "client_name": "Your App Name", "redirect_uris": ["https://yourapp.example.com/api/oauth/callback"], - "scope": "atproto include:org.hypercerts.authWrite include:org.hyperboards.authWrite include:app.certified.authWrite", + "scope": "atproto include:org.hypercerts.authWrite include:app.certified.authWrite", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "none", diff --git a/.agents/skills/epds-login/references/flows.md b/.agents/skills/epds-login/references/flows.md index 8d3f471a..4b6a9a15 100644 --- a/.agents/skills/epds-login/references/flows.md +++ b/.agents/skills/epds-login/references/flows.md @@ -156,7 +156,8 @@ export async function handleLogin(email: string) { client_id: CLIENT_ID, redirect_uri: REDIRECT_URI, response_type: 'code', - scope: 'atproto include:org.hypercerts.authWrite include:org.hyperboards.authWrite include:app.certified.authWrite', + scope: + 'atproto include:org.hypercerts.authWrite include:app.certified.authWrite', state, code_challenge: codeChallenge, code_challenge_method: 'S256', diff --git a/docs/tutorial.md b/docs/tutorial.md index a61f23e7..9b33f34e 100644 --- a/docs/tutorial.md +++ b/docs/tutorial.md @@ -190,7 +190,7 @@ The file must be served with `Content-Type: application/json`: "client_uri": "https://yourapp.example.com", "logo_uri": "https://yourapp.example.com/logo.png", "redirect_uris": ["https://yourapp.example.com/api/oauth/callback"], - "scope": "atproto transition:generic", + "scope": "atproto include:org.hypercerts.authWrite include:app.certified.authWrite", "grant_types": ["authorization_code", "refresh_token"], "response_types": ["code"], "token_endpoint_auth_method": "private_key_jwt", @@ -674,7 +674,8 @@ const client = new NodeOAuthClient({ client_id: 'https://yourapp.example.com/client-metadata.json', client_name: 'Your App', redirect_uris: ['https://yourapp.example.com/api/oauth/callback'], - scope: 'atproto transition:generic', + scope: + 'atproto include:org.hypercerts.authWrite include:app.certified.authWrite', grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], token_endpoint_auth_method: 'private_key_jwt', @@ -772,7 +773,8 @@ const parBody = new URLSearchParams({ client_id: clientId, redirect_uri: redirectUri, response_type: 'code', - scope: 'atproto transition:generic', + scope: + 'atproto include:org.hypercerts.authWrite include:app.certified.authWrite', state, code_challenge: codeChallenge, code_challenge_method: 'S256', diff --git a/packages/demo/src/app/api/oauth/login/route.ts b/packages/demo/src/app/api/oauth/login/route.ts index 1a953994..79c12172 100644 --- a/packages/demo/src/app/api/oauth/login/route.ts +++ b/packages/demo/src/app/api/oauth/login/route.ts @@ -150,7 +150,8 @@ export async function GET(request: Request) { client_id: clientId, redirect_uri: redirectUri, response_type: 'code', - scope: 'atproto include:org.hypercerts.authWrite include:org.hyperboards.authWrite include:app.certified.authWrite', + scope: + 'atproto include:org.hypercerts.authWrite include:app.certified.authWrite', state, code_challenge: codeChallenge, code_challenge_method: 'S256', diff --git a/packages/demo/src/app/client-metadata.json/route.ts b/packages/demo/src/app/client-metadata.json/route.ts index f02e8bf0..a5c0b5cf 100644 --- a/packages/demo/src/app/client-metadata.json/route.ts +++ b/packages/demo/src/app/client-metadata.json/route.ts @@ -50,7 +50,8 @@ export async function GET() { client_uri: baseUrl, logo_uri: `${baseUrl}/certified-logo.png`, redirect_uris: [`${baseUrl}/api/oauth/callback`], - scope: 'atproto include:org.hypercerts.authWrite include:org.hyperboards.authWrite include:app.certified.authWrite', + scope: + 'atproto include:org.hypercerts.authWrite include:app.certified.authWrite', grant_types: ['authorization_code', 'refresh_token'], response_types: ['code'], ...(isConfidential From c730fcef37f387ff8f0bebb15bb7bfc6a0f72d3d Mon Sep 17 00:00:00 2001 From: Evelyn Osman Date: Tue, 7 Jul 2026 17:42:38 +0200 Subject: [PATCH 03/24] Update e2e tests to match permission sets usage --- e2e/step-definitions/consent.steps.ts | 34 +++++++++++--------- e2e/step-definitions/sec-fetch-site.steps.ts | 3 +- 2 files changed, 21 insertions(+), 16 deletions(-) diff --git a/e2e/step-definitions/consent.steps.ts b/e2e/step-definitions/consent.steps.ts index 5982b7a3..45eeb01a 100644 --- a/e2e/step-definitions/consent.steps.ts +++ b/e2e/step-definitions/consent.steps.ts @@ -45,24 +45,28 @@ Then('a consent screen is displayed', async function (this: EpdsWorld) { timeout: 30_000, }) - // 2. The demo clients request `atproto transition:generic`. For that - // scope set, @atproto/oauth-provider-ui's ScopeDescription renders - // multiple permission cards — including one titled "Authenticate" - // via the RpcMethodsDetails component, which fires on - // hasTransitionGeneric. Assert that card is visible: this proves - // the scope was actually parsed and rendered a permission summary, - // not that the page loaded blank with just an Authorize button. + // 2. The demo clients request two granular permission sets — + // `include:org.hypercerts.authWrite` and `include:app.certified.authWrite`. + // @atproto/oauth-provider-ui's ScopeDescription resolves each + // permission-set lexicon and renders a permission card titled from the + // set's `title` field ("Manage your Hypercerts data" / "Manage your + // Certified data"). Assert those cards are visible: this proves the + // scopes were actually parsed, the permission-set lexicons resolved, and + // a permission summary rendered — not that the page loaded blank with + // just an Authorize button. // - // We deliberately do NOT assert on the raw scope strings - // (`atproto`, `transition:generic`) being visible on the page — - // those only appear inside a collapsed "Technical details" - // panel that is hidden (HTML `hidden` attribute + - // aria-hidden="true") until the user clicks its disclosure - // button. Asserting on the user-facing scope card is both more - // meaningful (what users actually see) and more resilient + // We deliberately do NOT assert on the raw scope strings (`atproto`, + // `include:...`) being visible on the page — those only appear inside a + // collapsed "Technical details" panel that is hidden (HTML + // `hidden` attribute + aria-hidden="true") until the user clicks its + // disclosure button. Asserting on the user-facing permission cards is + // both more meaningful (what users actually see) and more resilient // (doesn't depend on the details-panel implementation). await expect( - page.getByRole('heading', { name: 'Authenticate' }), + page.getByRole('heading', { name: 'Manage your Hypercerts data' }), + ).toBeVisible() + await expect( + page.getByRole('heading', { name: 'Manage your Certified data' }), ).toBeVisible() }) diff --git a/e2e/step-definitions/sec-fetch-site.steps.ts b/e2e/step-definitions/sec-fetch-site.steps.ts index bbfddcd1..81e515ce 100644 --- a/e2e/step-definitions/sec-fetch-site.steps.ts +++ b/e2e/step-definitions/sec-fetch-site.steps.ts @@ -33,7 +33,8 @@ When( client_id: clientMetaUrl, redirect_uri: `${testEnv.demoUrl}/api/oauth/callback`, response_type: 'code', - scope: 'atproto transition:generic', + scope: + 'atproto include:org.hypercerts.authWrite include:app.certified.authWrite', state: 'test-state', code_challenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM', code_challenge_method: 'S256', From ce25beba5e231e8300a875f1fc1d2ad37ae75527 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 14 Jul 2026 17:54:16 +0100 Subject: [PATCH 04/24] fix(auth-service): name email recipient log field consistently The client-branded OTP delivery log exposed its recipient under the generic to field, while related authentication logs use email. This made structured log queries inconsistent.\n\nPublish the same recipient value under email and document the operator-facing field rename.\n\nCo-authored-by: OpenAI Codex --- .changeset/email-log-recipient-field.md | 9 +++++++++ packages/auth-service/src/email/sender.ts | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 .changeset/email-log-recipient-field.md diff --git a/.changeset/email-log-recipient-field.md b/.changeset/email-log-recipient-field.md new file mode 100644 index 00000000..5139020e --- /dev/null +++ b/.changeset/email-log-recipient-field.md @@ -0,0 +1,9 @@ +--- +'ePDS': minor +--- + +Email delivery logs use a consistent recipient field. + +**Affects:** Operators + +**Operators:** Update structured-log queries for `Sent client-branded OTP email` to read the recipient from `email` instead of `to`. diff --git a/packages/auth-service/src/email/sender.ts b/packages/auth-service/src/email/sender.ts index 6705d4ac..ba74616c 100644 --- a/packages/auth-service/src/email/sender.ts +++ b/packages/auth-service/src/email/sender.ts @@ -133,7 +133,7 @@ export class EmailSender { html: branded.html, }) logger.info( - { to, clientId: opts.clientId }, + { email: to, clientId: opts.clientId }, 'Sent client-branded OTP email', ) return From aa2e1a4a40d6ce0f79f70a743d50142629b4b70c Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Tue, 14 Jul 2026 17:59:32 +0100 Subject: [PATCH 05/24] fix(auth-service): log all email delivery paths The first pass updated only the client-branded delivery log and overlooked the three other sendMail call sites. That left standard sign-in, welcome, and backup verification deliveries without the same structured recipient context.\n\nAdd an email field to the delivery log for every sendMail path while retaining Nodemailer's required to envelope property.\n\nCo-authored-by: OpenAI Codex --- .changeset/email-log-recipient-field.md | 2 +- packages/auth-service/src/email/sender.ts | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/.changeset/email-log-recipient-field.md b/.changeset/email-log-recipient-field.md index 5139020e..454ed246 100644 --- a/.changeset/email-log-recipient-field.md +++ b/.changeset/email-log-recipient-field.md @@ -6,4 +6,4 @@ Email delivery logs use a consistent recipient field. **Affects:** Operators -**Operators:** Update structured-log queries for `Sent client-branded OTP email` to read the recipient from `email` instead of `to`. +**Operators:** All four email delivery paths now log the recipient as `email`: client-branded OTP, standard sign-in OTP, welcome OTP, and backup-email verification. Update structured-log queries for `Sent client-branded OTP email` to read `email` instead of `to`. diff --git a/packages/auth-service/src/email/sender.ts b/packages/auth-service/src/email/sender.ts index ba74616c..a6fc4bbd 100644 --- a/packages/auth-service/src/email/sender.ts +++ b/packages/auth-service/src/email/sender.ts @@ -165,6 +165,7 @@ export class EmailSender { text, html, }) + logger.info({ email: to }, 'Sent sign-in OTP email') } private async sendWelcomeCode(opts: { @@ -183,6 +184,7 @@ export class EmailSender { text, html, }) + logger.info({ email: to }, 'Sent welcome OTP email') } async sendBackupEmailVerification(opts: { @@ -201,5 +203,6 @@ export class EmailSender { text, html, }) + logger.info({ email: to }, 'Sent backup email verification') } } From dbd5eadc6b5bfcaa0b5dd24a9cfbd31693084243 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 15 Jul 2026 10:10:46 +0100 Subject: [PATCH 06/24] feat(shared): persist Resend delivery events Store Resend email lifecycle events idempotently by Svix ID and expose delivery latency and outcome metrics. Share the ten-minute OTP lifetime between Better Auth and the latency threshold so the two cannot drift. Refs #185 Co-authored-by: OpenAI Codex --- packages/auth-service/src/better-auth.ts | 6 +- packages/auth-service/src/lib/auth-flow.ts | 2 +- .../shared/src/__tests__/db-extended.test.ts | 96 +++++++++- packages/shared/src/db.ts | 173 +++++++++++++++++- packages/shared/src/index.ts | 5 +- packages/shared/src/types.ts | 3 + vitest.config.ts | 6 +- 7 files changed, 280 insertions(+), 11 deletions(-) diff --git a/packages/auth-service/src/better-auth.ts b/packages/auth-service/src/better-auth.ts index c5bd2a2b..eb870772 100644 --- a/packages/auth-service/src/better-auth.ts +++ b/packages/auth-service/src/better-auth.ts @@ -11,7 +11,7 @@ * The instance is mounted at /api/auth/* alongside the existing custom routes. */ import type { EpdsDb } from '@certified-app/shared' -import { createLogger } from '@certified-app/shared' +import { createLogger, OTP_LIFETIME_SECONDS } from '@certified-app/shared' import { betterAuth } from 'better-auth' import { APIError, createAuthMiddleware } from 'better-auth/api' import { generateRandomString } from 'better-auth/crypto' @@ -204,7 +204,7 @@ export async function runBetterAuthMigrations( plugins: [ emailOTP({ otpLength, - expiresIn: 600, + expiresIn: OTP_LIFETIME_SECONDS, allowedAttempts: 5, storeOTP: 'hashed', ...(otpCharset === 'alphanumeric' @@ -305,7 +305,7 @@ export function createBetterAuth( plugins: [ emailOTP({ otpLength, - expiresIn: 600, + expiresIn: OTP_LIFETIME_SECONDS, allowedAttempts: 5, storeOTP: 'hashed', ...(otpCharset === 'alphanumeric' diff --git a/packages/auth-service/src/lib/auth-flow.ts b/packages/auth-service/src/lib/auth-flow.ts index c9596ec5..acb6ff3a 100644 --- a/packages/auth-service/src/lib/auth-flow.ts +++ b/packages/auth-service/src/lib/auth-flow.ts @@ -12,7 +12,7 @@ export const AUTH_FLOW_COOKIE = 'epds_auth_flow' * can use them. * * NOT the OTP code's lifetime — better-auth enforces that separately - * in the `verification` table (`expiresIn: 600` in better-auth.ts). + * in the `verification` table (`OTP_LIFETIME_SECONDS` in the shared package). * * 60 minutes lets a slow user who hits OTP expiry (10 min) and clicks * Resend still have a live auth_flow + cookie to land on /auth/complete diff --git a/packages/shared/src/__tests__/db-extended.test.ts b/packages/shared/src/__tests__/db-extended.test.ts index 7de43e27..a65c216d 100644 --- a/packages/shared/src/__tests__/db-extended.test.ts +++ b/packages/shared/src/__tests__/db-extended.test.ts @@ -261,8 +261,9 @@ describe('Database initialization', () => { fs.rmSync(path.dirname(path.dirname(nestedPath)), { recursive: true, }) - // eslint-disable-next-line no-empty - } catch {} + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err + } }) it('runs migrations idempotently on second open', () => { @@ -275,3 +276,94 @@ describe('Database initialization', () => { db = db2 }) }) + +describe('Resend email delivery events', () => { + const baseTime = Date.parse('2026-07-14T10:00:00.000Z') + + function record( + svixId: string, + emailId: string, + eventType: + | 'email.sent' + | 'email.delivered' + | 'email.delivery_delayed' + | 'email.bounced' + | 'email.failed', + eventCreatedAt: number, + ): boolean { + return db.recordResendEmailEvent({ + svixId, + emailId, + eventType, + eventCreatedAt, + recipients: ['person@example.com'], + sender: 'ePDS ', + subject: 'Your sign-in code', + }) + } + + it('deduplicates retries by Svix ID and returns events by event time', () => { + expect( + record('msg-delivered', 'email-1', 'email.delivered', baseTime + 2_000), + ).toBe(true) + expect(record('msg-sent', 'email-1', 'email.sent', baseTime)).toBe(true) + expect(record('msg-sent', 'email-1', 'email.sent', baseTime)).toBe(false) + + const events = db.getResendEmailEvents('email-1') + expect(events).toHaveLength(2) + expect(events.map((event) => event.eventType)).toEqual([ + 'email.sent', + 'email.delivered', + ]) + expect(events[0]?.recipients).toEqual(['person@example.com']) + }) + + it('computes delivery latency and outcome metrics across out-of-order events', () => { + record( + 'slow-delivered', + 'slow-email', + 'email.delivered', + baseTime + 601_000, + ) + record( + 'slow-delayed', + 'slow-email', + 'email.delivery_delayed', + baseTime + 5_000, + ) + record('slow-sent', 'slow-email', 'email.sent', baseTime) + + record('fast-sent', 'fast-email', 'email.sent', baseTime + 10_000) + record('fast-delivered', 'fast-email', 'email.delivered', baseTime + 70_000) + record('bounced', 'bounced-email', 'email.bounced', baseTime) + record('failed', 'failed-email', 'email.failed', baseTime) + + expect(db.getResendDeliveryMetrics()).toEqual({ + sentEmails: 2, + deliveredEmails: 2, + deliveryDelayedEvents: 1, + bouncedEmails: 1, + failedEmails: 1, + matchedDeliveries: 2, + averageDeliveryLatencyMs: 330_500, + maxDeliveryLatencyMs: 601_000, + deliveriesOverOtpLifetime: 1, + deliveryOverOtpLifetimeFraction: 0.5, + }) + }) + + it('includes empty delivery metrics in the service metrics', () => { + expect(db.getMetrics().resendDelivery).toEqual({ + sentEmails: 0, + deliveredEmails: 0, + deliveryDelayedEvents: 0, + bouncedEmails: 0, + failedEmails: 0, + matchedDeliveries: 0, + averageDeliveryLatencyMs: 0, + maxDeliveryLatencyMs: 0, + deliveriesOverOtpLifetime: 0, + deliveryOverOtpLifetimeFraction: 0, + }) + }) +}) diff --git a/packages/shared/src/db.ts b/packages/shared/src/db.ts index c387880c..13ef46f4 100644 --- a/packages/shared/src/db.ts +++ b/packages/shared/src/db.ts @@ -2,6 +2,7 @@ import Database from 'better-sqlite3' import * as path from 'node:path' import * as fs from 'node:fs' import type { HandleMode } from './handle.js' +import { OTP_LIFETIME_SECONDS } from './types.js' export interface VerificationTokenRow { tokenHash: string @@ -42,6 +43,39 @@ export interface AuthFlowRow { expiresAt: number } +export type ResendEmailEventType = + | 'email.sent' + | 'email.delivered' + | 'email.delivery_delayed' + | 'email.bounced' + | 'email.failed' + +export interface ResendEmailEvent { + svixId: string + emailId: string + eventType: ResendEmailEventType + eventCreatedAt: number + recipients: string[] + sender: string + subject: string + receivedAt: number +} + +export interface ResendDeliveryMetrics { + sentEmails: number + deliveredEmails: number + deliveryDelayedEvents: number + bouncedEmails: number + failedEmails: number + matchedDeliveries: number + averageDeliveryLatencyMs: number + maxDeliveryLatencyMs: number + deliveriesOverOtpLifetime: number + deliveryOverOtpLifetimeFraction: number +} + +const OTP_LIFETIME_MS = OTP_LIFETIME_SECONDS * 1000 + export class EpdsDb { private db: Database.Database @@ -187,6 +221,26 @@ export class EpdsDb { // changed to a no-op since the table is harmless to keep and dropping // it prevents rollback. The table is no longer used by current code. () => {}, + + // v10: Persist signed Resend delivery webhooks for latency analysis. + () => { + this.db.exec(` + CREATE TABLE IF NOT EXISTS resend_email_event ( + svix_id TEXT PRIMARY KEY, + email_id TEXT NOT NULL, + event_type TEXT NOT NULL, + event_created_at INTEGER NOT NULL, + recipients_json TEXT NOT NULL, + sender TEXT NOT NULL, + subject TEXT NOT NULL, + received_at INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_ree_email_time + ON resend_email_event(email_id, event_created_at); + CREATE INDEX IF NOT EXISTS idx_ree_type_time + ON resend_email_event(event_type, event_created_at); + `) + }, ] for (let i = currentVersion; i < migrations.length; i++) { @@ -465,12 +519,124 @@ export class EpdsDb { return result.changes } + // ── Resend Email Delivery Events ── + + /** Persist one verified webhook. Returns false when Svix retries an event. */ + recordResendEmailEvent(event: Omit): boolean { + const result = this.db + .prepare( + `INSERT OR IGNORE INTO resend_email_event + (svix_id, email_id, event_type, event_created_at, recipients_json, sender, subject, received_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + ) + .run( + event.svixId, + event.emailId, + event.eventType, + event.eventCreatedAt, + JSON.stringify(event.recipients), + event.sender, + event.subject, + Date.now(), + ) + return result.changes > 0 + } + + /** Return an email's events in provider timestamp order, not arrival order. */ + getResendEmailEvents(emailId: string): ResendEmailEvent[] { + const rows = this.db + .prepare( + `SELECT svix_id as svixId, email_id as emailId, event_type as eventType, + event_created_at as eventCreatedAt, recipients_json as recipientsJson, + sender, subject, received_at as receivedAt + FROM resend_email_event + WHERE email_id = ? + ORDER BY event_created_at ASC, svix_id ASC`, + ) + .all(emailId) as Array< + Omit & { recipientsJson: string } + > + + return rows.map(({ recipientsJson, ...row }) => { + try { + const recipients: unknown = JSON.parse(recipientsJson) + if ( + !Array.isArray(recipients) || + !recipients.every((recipient) => typeof recipient === 'string') + ) { + throw new TypeError('recipients_json is not a string array') + } + return { ...row, recipients } + } catch (err) { + throw new Error('Invalid recipients_json in resend_email_event', { + cause: err, + }) + } + }) + } + + getResendDeliveryMetrics(): ResendDeliveryMetrics { + const counts = this.db + .prepare( + `SELECT + COUNT(DISTINCT CASE WHEN event_type = 'email.sent' THEN email_id END) as sentEmails, + COUNT(DISTINCT CASE WHEN event_type = 'email.delivered' THEN email_id END) as deliveredEmails, + COALESCE(SUM(CASE WHEN event_type = 'email.delivery_delayed' THEN 1 ELSE 0 END), 0) as deliveryDelayedEvents, + COUNT(DISTINCT CASE WHEN event_type = 'email.bounced' THEN email_id END) as bouncedEmails, + COUNT(DISTINCT CASE WHEN event_type = 'email.failed' THEN email_id END) as failedEmails + FROM resend_email_event`, + ) + .get() as Pick< + ResendDeliveryMetrics, + | 'sentEmails' + | 'deliveredEmails' + | 'deliveryDelayedEvents' + | 'bouncedEmails' + | 'failedEmails' + > + + const latency = this.db + .prepare( + `WITH event_times AS ( + SELECT email_id, + MIN(CASE WHEN event_type = 'email.sent' THEN event_created_at END) AS sent_at, + MIN(CASE WHEN event_type = 'email.delivered' THEN event_created_at END) AS delivered_at + FROM resend_email_event + GROUP BY email_id + ) + SELECT + COUNT(*) as matchedDeliveries, + COALESCE(AVG(delivered_at - sent_at), 0) as averageDeliveryLatencyMs, + COALESCE(MAX(delivered_at - sent_at), 0) as maxDeliveryLatencyMs, + COALESCE(SUM(CASE WHEN delivered_at - sent_at > ? THEN 1 ELSE 0 END), 0) as deliveriesOverOtpLifetime + FROM event_times + WHERE sent_at IS NOT NULL AND delivered_at IS NOT NULL AND delivered_at >= sent_at`, + ) + .get(OTP_LIFETIME_MS) as Pick< + ResendDeliveryMetrics, + | 'matchedDeliveries' + | 'averageDeliveryLatencyMs' + | 'maxDeliveryLatencyMs' + | 'deliveriesOverOtpLifetime' + > + + return { + ...counts, + ...latency, + deliveryOverOtpLifetimeFraction: + latency.matchedDeliveries === 0 + ? 0 + : latency.deliveriesOverOtpLifetime / latency.matchedDeliveries, + } + } + // ── Metrics ── getMetrics(): { pendingTokens: number backupEmails: number rateLimitEntries: number + resendDelivery: ResendDeliveryMetrics } { const now = Date.now() const pendingTokens = ( @@ -490,7 +656,12 @@ export class EpdsDb { c: number } ).c - return { pendingTokens, backupEmails, rateLimitEntries } + return { + pendingTokens, + backupEmails, + rateLimitEntries, + resendDelivery: this.getResendDeliveryMetrics(), + } } close(): void { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index f3a0e973..5166678c 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -4,6 +4,9 @@ export type { BackupEmailRow, EmailRateLimitRow, AuthFlowRow, + ResendEmailEventType, + ResendEmailEvent, + ResendDeliveryMetrics, } from './db.js' export { generateVerificationToken, @@ -22,7 +25,7 @@ export type { AuthConfig, RateLimitConfig, } from './types.js' -export { DEFAULT_RATE_LIMITS } from './types.js' +export { DEFAULT_RATE_LIMITS, OTP_LIFETIME_SECONDS } from './types.js' export { createLogger } from './logger.js' export { escapeHtml, diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index a1b502bb..1999692a 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -32,6 +32,9 @@ export interface RateLimitConfig { globalPerMinute: number } +/** Better Auth email-code lifetime and delivery-latency threshold. */ +export const OTP_LIFETIME_SECONDS = 10 * 60 + export const DEFAULT_RATE_LIMITS: RateLimitConfig = { emailPer15Min: 3, emailPerHour: 5, diff --git a/vitest.config.ts b/vitest.config.ts index 1fcd4eac..9e0bd0f7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -23,9 +23,9 @@ export default defineConfig({ // Ratchet thresholds — update these whenever coverage increases. // See AGENTS.md for the ratcheting policy. thresholds: { - statements: 57, - branches: 56, - functions: 70, + statements: 58, + branches: 57, + functions: 71, lines: 56, }, }, From 1a9c922d6dfbc3e149c208f7ac539d363435e81a Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 15 Jul 2026 10:11:05 +0100 Subject: [PATCH 07/24] feat(auth): receive signed Resend webhooks Add a raw-body webhook endpoint that verifies Svix signatures before validating and persisting supported Resend email events. Retries are acknowledged idempotently and delayed-delivery events are surfaced in logs. Refs #185 Co-authored-by: OpenAI Codex --- packages/auth-service/package.json | 3 +- .../src/__tests__/resend-webhook.test.ts | 159 ++++++++++++++++ packages/auth-service/src/context.ts | 2 + packages/auth-service/src/index.ts | 9 + .../auth-service/src/routes/resend-webhook.ts | 177 ++++++++++++++++++ pnpm-lock.yaml | 28 +++ 6 files changed, 377 insertions(+), 1 deletion(-) create mode 100644 packages/auth-service/src/__tests__/resend-webhook.test.ts create mode 100644 packages/auth-service/src/routes/resend-webhook.ts diff --git a/packages/auth-service/package.json b/packages/auth-service/package.json index 35bcf641..dd0e07da 100644 --- a/packages/auth-service/package.json +++ b/packages/auth-service/package.json @@ -16,7 +16,8 @@ "cookie-parser": "^1.4.6", "dotenv": "^16.3.1", "express": "^4.18.2", - "nodemailer": "^6.9.8" + "nodemailer": "^6.9.8", + "svix": "^1.96.1" }, "devDependencies": { "@types/better-sqlite3": "^7.6.8", diff --git a/packages/auth-service/src/__tests__/resend-webhook.test.ts b/packages/auth-service/src/__tests__/resend-webhook.test.ts new file mode 100644 index 00000000..ad794242 --- /dev/null +++ b/packages/auth-service/src/__tests__/resend-webhook.test.ts @@ -0,0 +1,159 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { EpdsDb } from '@certified-app/shared' +import express from 'express' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' +import { randomUUID } from 'node:crypto' +import { Webhook } from 'svix' +import { createResendWebhookRouter } from '../routes/resend-webhook.js' + +const WEBHOOK_SECRET = `whsec_${Buffer.from('test-webhook-secret').toString( + 'base64', +)}` + +let db: EpdsDb +let dbPath: string + +beforeEach(() => { + dbPath = path.join(os.tmpdir(), `resend-webhook-${randomUUID()}.sqlite`) + db = new EpdsDb(dbPath) +}) + +afterEach(() => { + db.close() + for (const suffix of ['', '-wal', '-shm']) { + try { + fs.unlinkSync(dbPath + suffix) + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err + } + } +}) + +function makeEvent(type = 'email.sent'): Record { + return { + type, + created_at: '2026-07-14T10:00:00.000Z', + data: { + email_id: 'resend-email-123', + to: ['person@example.com'], + from: 'ePDS ', + subject: 'Your sign-in code', + }, + } +} + +async function postWebhook( + event: Record, + options: { + svixId?: string + validSignature?: boolean + includeHeaders?: boolean + } = {}, +): Promise<{ status: number; json: Record }> { + const app = express() + app.use(createResendWebhookRouter(db, WEBHOOK_SECRET)) + const server = app.listen(0) + + try { + server.unref() + const port = await new Promise((resolve, reject) => { + server.once('error', reject) + server.once('listening', () => { + const address = server.address() + if (typeof address === 'object' && address) resolve(address.port) + else reject(new Error('Failed to resolve ephemeral port')) + }) + }) + + const payload = JSON.stringify(event) + const svixId = options.svixId ?? 'msg_test_123' + const timestamp = new Date() + const signature = + options.validSignature === false + ? 'v1,invalid' + : new Webhook(WEBHOOK_SECRET).sign(svixId, timestamp, payload) + + const headers: Record = { + 'Content-Type': 'application/json', + } + if (options.includeHeaders !== false) { + headers['svix-id'] = svixId + headers['svix-timestamp'] = String(Math.floor(timestamp.getTime() / 1000)) + headers['svix-signature'] = signature + } + + const response = await fetch(`http://127.0.0.1:${port}/webhooks/resend`, { + method: 'POST', + headers, + body: payload, + }) + return { + status: response.status, + json: (await response.json()) as Record, + } + } finally { + await new Promise((resolve) => { + server.close(() => { + resolve() + }) + }) + } +} + +describe('Resend webhook receiver', () => { + it('verifies, persists, and acknowledges a delivery event', async () => { + const result = await postWebhook(makeEvent('email.delivered')) + + expect(result).toEqual({ + status: 200, + json: { received: true, duplicate: false }, + }) + expect(db.getResendEmailEvents('resend-email-123')).toMatchObject([ + { + svixId: 'msg_test_123', + eventType: 'email.delivered', + recipients: ['person@example.com'], + }, + ]) + }) + + it('acknowledges a Svix retry without inserting it twice', async () => { + const first = await postWebhook(makeEvent(), { svixId: 'msg_retry' }) + const retry = await postWebhook(makeEvent(), { svixId: 'msg_retry' }) + + expect(first.json.duplicate).toBe(false) + expect(retry).toEqual({ + status: 200, + json: { received: true, duplicate: true }, + }) + expect(db.getResendEmailEvents('resend-email-123')).toHaveLength(1) + }) + + it('rejects an invalid signature before persisting the payload', async () => { + const result = await postWebhook(makeEvent(), { validSignature: false }) + + expect(result.status).toBe(400) + expect(result.json).toEqual({ error: 'Invalid webhook signature' }) + expect(db.getResendEmailEvents('resend-email-123')).toHaveLength(0) + }) + + it('rejects a request without the required Svix headers', async () => { + const result = await postWebhook(makeEvent(), { includeHeaders: false }) + + expect(result).toEqual({ + status: 400, + json: { error: 'Invalid webhook request' }, + }) + expect(db.getResendEmailEvents('resend-email-123')).toHaveLength(0) + }) + + it('rejects signed event types that are not used for delivery metrics', async () => { + const result = await postWebhook(makeEvent('email.opened')) + + expect(result.status).toBe(400) + expect(result.json).toEqual({ error: 'Invalid webhook payload' }) + expect(db.getResendEmailEvents('resend-email-123')).toHaveLength(0) + }) +}) diff --git a/packages/auth-service/src/context.ts b/packages/auth-service/src/context.ts index 65f6d787..a31014fa 100644 --- a/packages/auth-service/src/context.ts +++ b/packages/auth-service/src/context.ts @@ -22,6 +22,8 @@ export interface AuthServiceConfig { dbLocation: string otpLength: number otpCharset: 'numeric' | 'alphanumeric' + /** Resend webhook signing secret. When unset, the receiver is disabled. */ + resendWebhookSecret?: string /** * OAuth client_id URLs trusted for branding injection. Used to gate * CSS branding injection AND client-supplied email templates diff --git a/packages/auth-service/src/index.ts b/packages/auth-service/src/index.ts index fc59cc2c..c0da188b 100644 --- a/packages/auth-service/src/index.ts +++ b/packages/auth-service/src/index.ts @@ -18,6 +18,7 @@ import { createChooseHandleRouter } from './routes/choose-handle.js' import { createHeartbeatRouter } from './routes/heartbeat.js' import { createPreviewRouter } from './routes/preview.js' import { createPreviewEmailsRouter } from './routes/preview-emails.js' +import { createResendWebhookRouter } from './routes/resend-webhook.js' import { createRootRouter } from './routes/root.js' import { createTestHooksRouter } from './routes/test-hooks.js' import { resolveAuthPort } from './lib/resolve-port.js' @@ -47,6 +48,13 @@ export function createAuthService(config: AuthServiceConfig): { ) app.all('/api/auth/*', toNodeHandler(betterAuthInstance)) + // Webhook verification requires the exact request bytes, so mount this + // before any urlencoded or JSON body parser. It deliberately sits outside + // browser CSRF protection; authenticity comes from the Svix signature. + if (config.resendWebhookSecret) { + app.use(createResendWebhookRouter(ctx.db, config.resendWebhookSecret)) + } + // Middleware app.set('trust proxy', 1) app.use(express.urlencoded({ extended: true })) @@ -158,6 +166,7 @@ async function main() { otpCharset: (process.env.OTP_CHARSET || 'numeric') as | 'numeric' | 'alphanumeric', + resendWebhookSecret: process.env.RESEND_WEBHOOK_SECRET || undefined, trustedClients: (process.env.PDS_OAUTH_TRUSTED_CLIENTS || '') .split(',') .map((s) => s.trim()) diff --git a/packages/auth-service/src/routes/resend-webhook.ts b/packages/auth-service/src/routes/resend-webhook.ts new file mode 100644 index 00000000..69b89bba --- /dev/null +++ b/packages/auth-service/src/routes/resend-webhook.ts @@ -0,0 +1,177 @@ +/** + * Resend delivery webhook receiver. + * + * The route consumes the untouched request body, verifies Resend's Svix + * signature before inspecting the payload, accepts only delivery events used + * by ePDS, and persists them idempotently by `svix-id`. Resend may retry the + * same event and may deliver an email's events out of order. + */ +import { createLogger, type EpdsDb } from '@certified-app/shared' +import express, { Router } from 'express' +import { Webhook } from 'svix' + +const logger = createLogger('auth:resend-webhook') + +const RESEND_EVENT_TYPES = [ + 'email.sent', + 'email.delivered', + 'email.delivery_delayed', + 'email.bounced', + 'email.failed', +] as const + +type ResendEventType = (typeof RESEND_EVENT_TYPES)[number] + +interface ResendEvent { + type: ResendEventType + created_at: string + data: { + email_id: string + to: string[] + from: string + subject: string + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function isResendEvent(value: unknown): value is ResendEvent { + if (!isRecord(value) || !isRecord(value.data)) return false + + const data = value.data + return ( + typeof value.type === 'string' && + RESEND_EVENT_TYPES.some((eventType) => eventType === value.type) && + typeof value.created_at === 'string' && + Number.isFinite(Date.parse(value.created_at)) && + typeof data.email_id === 'string' && + Array.isArray(data.to) && + data.to.every((recipient) => typeof recipient === 'string') && + typeof data.from === 'string' && + typeof data.subject === 'string' + ) +} + +interface SvixHeaders { + 'svix-id': string + 'svix-timestamp': string + 'svix-signature': string +} + +type VerificationResult = + | { ok: true; headers: SvixHeaders; event: ResendEvent } + | { + ok: false + error: + | 'Invalid webhook request' + | 'Invalid webhook signature' + | 'Invalid webhook payload' + } + +function getSvixHeaders(req: express.Request): SvixHeaders | null { + const id = req.get('svix-id') + const timestamp = req.get('svix-timestamp') + const signature = req.get('svix-signature') + if (!id || !timestamp || !signature) return null + return { + 'svix-id': id, + 'svix-timestamp': timestamp, + 'svix-signature': signature, + } +} + +function verifyResendWebhook( + req: express.Request, + verifier: Webhook, +): VerificationResult { + const headers = getSvixHeaders(req) + if (!headers || !Buffer.isBuffer(req.body)) { + return { ok: false, error: 'Invalid webhook request' } + } + + let payload: unknown + try { + payload = verifier.verify(req.body, headers) + } catch (err) { + logger.warn( + { err, svixId: headers['svix-id'] }, + 'Rejected Resend webhook with invalid signature', + ) + return { ok: false, error: 'Invalid webhook signature' } + } + + if (!isResendEvent(payload)) { + logger.warn( + { svixId: headers['svix-id'] }, + 'Rejected invalid Resend webhook payload', + ) + return { ok: false, error: 'Invalid webhook payload' } + } + return { ok: true, headers, event: payload } +} + +function recordResendEvent( + db: EpdsDb, + svixId: string, + event: ResendEvent, +): boolean { + return db.recordResendEmailEvent({ + svixId, + emailId: event.data.email_id, + eventType: event.type, + eventCreatedAt: Date.parse(event.created_at), + recipients: event.data.to, + sender: event.data.from, + subject: event.data.subject, + }) +} + +function logProcessedEvent(event: ResendEvent, inserted: boolean): void { + if (inserted && event.type === 'email.delivery_delayed') { + logger.warn( + { emailId: event.data.email_id }, + 'Resend reported delayed email delivery', + ) + return + } + logger.debug( + { + emailId: event.data.email_id, + eventType: event.type, + duplicate: !inserted, + }, + 'Processed Resend email event', + ) +} + +export function createResendWebhookRouter( + db: EpdsDb, + webhookSecret: string, +): Router { + const router = Router() + const verifier = new Webhook(webhookSecret) + + router.post( + '/webhooks/resend', + express.raw({ type: 'application/json', limit: '64kb' }), + (req, res) => { + const result = verifyResendWebhook(req, verifier) + if (!result.ok) { + res.status(400).json({ error: result.error }) + return + } + + const inserted = recordResendEvent( + db, + result.headers['svix-id'], + result.event, + ) + logProcessedEvent(result.event, inserted) + res.status(200).json({ received: true, duplicate: !inserted }) + }, + ) + + return router +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b8edc4c..a0d50948 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,6 +84,9 @@ importers: nodemailer: specifier: ^6.9.8 version: 6.10.1 + svix: + specifier: ^1.96.1 + version: 1.96.1 devDependencies: '@types/better-sqlite3': specifier: ^7.6.8 @@ -1551,6 +1554,9 @@ packages: resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==} engines: {node: '>=18.0.0'} + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -2629,6 +2635,9 @@ packages: resolution: {integrity: sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==} engines: {node: '>=6'} + 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 @@ -3944,6 +3953,9 @@ packages: standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} @@ -4033,6 +4045,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + svix@1.96.1: + resolution: {integrity: sha512-l+GyPS6gjL0okiXplLazEY2GbBsv1jZ4UIwtjp553YkDDv635xMKbM5sABpIdiWy1BYxgyV8M6tHeTwY0gyXlQ==} + tagged-tag@1.0.0: resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} engines: {node: '>=20'} @@ -6455,6 +6470,8 @@ snapshots: dependencies: tslib: 2.8.1 + '@stablelib/base64@1.0.1': {} + '@standard-schema/spec@1.1.0': {} '@swc/helpers@0.5.15': @@ -7655,6 +7672,8 @@ snapshots: fast-redact@3.5.0: {} + fast-sha256@1.3.0: {} + fast-xml-parser@5.3.6: dependencies: strnum: 2.1.2 @@ -9052,6 +9071,11 @@ snapshots: standard-as-callback@2.1.0: {} + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + statuses@2.0.2: {} std-env@4.0.0: {} @@ -9141,6 +9165,10 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + svix@1.96.1: + dependencies: + standardwebhooks: 1.0.0 + tagged-tag@1.0.0: {} tar-fs@2.1.4: From 2bf9af420281c769ec88f19c9badd89f00acc03c Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 15 Jul 2026 10:11:11 +0100 Subject: [PATCH 08/24] docs(auth): document Resend webhook setup Document the webhook URL, event subscriptions, signing secret, metrics output, and the required live SMTP verification. Propagate RESEND_WEBHOOK_SECRET into generated auth-service environments and add release notes. Refs #185 Co-authored-by: OpenAI Codex --- .changeset/resend-delivery-webhooks.md | 9 +++++++++ .env.example | 4 ++++ docs/configuration.md | 21 +++++++++++++++++++++ docs/design/testing-gaps.md | 14 ++++++++++---- packages/auth-service/.env.example | 6 ++++++ scripts/setup.sh | 1 + 6 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 .changeset/resend-delivery-webhooks.md diff --git a/.changeset/resend-delivery-webhooks.md b/.changeset/resend-delivery-webhooks.md new file mode 100644 index 00000000..f231c921 --- /dev/null +++ b/.changeset/resend-delivery-webhooks.md @@ -0,0 +1,9 @@ +--- +'ePDS': minor +--- + +Resend delivery events can now be measured against the sign-in code lifetime. + +**Affects:** Operators + +**Operators:** Register `https:///webhooks/resend` for the `email.sent`, `email.delivered`, `email.delivery_delayed`, `email.bounced`, and `email.failed` events, then set `RESEND_WEBHOOK_SECRET` to the endpoint's `whsec_...` signing secret. Delivery counts and latency statistics are exposed under `resendDelivery` in the authenticated `/metrics` response; the receiver remains disabled when the secret is unset. diff --git a/.env.example b/.env.example index e03a37f0..60782fa5 100644 --- a/.env.example +++ b/.env.example @@ -214,6 +214,10 @@ SMTP_PASS= SMTP_FROM=noreply@pds.example SMTP_FROM_NAME=ePDS +# Optional Resend delivery webhook signing secret. Configure the webhook at +# https://auth.pds.example/webhooks/resend and copy its whsec_... secret here. +RESEND_WEBHOOK_SECRET= + DB_LOCATION=/data/epds.sqlite # ============================================================================ diff --git a/docs/configuration.md b/docs/configuration.md index 70d5948a..ace8334b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -197,6 +197,27 @@ buttons appear on the login page. | `AWS_SES_SMTP_PASS` | AWS SES SMTP password | | `POSTMARK_SERVER_TOKEN` | Postmark server token | | `EMAIL_TEMPLATE_ALLOWED_DOMAINS` | Optional comma-separated list of HTTPS hostnames from which `email_template_uri` can be fetched. If unset, any HTTPS URL is allowed. If set, templates hosted on unlisted domains are logged as a warning and ignored. | +| `RESEND_WEBHOOK_SECRET` | Optional Resend webhook signing secret (`whsec_...`). When set, enables `POST /webhooks/resend`; when unset, the receiver is not mounted. | + +#### Resend delivery metrics + +To capture delivery latency for email sent through Resend: + +1. In the Resend dashboard, register `https:///webhooks/resend`. +2. Subscribe it to `email.sent`, `email.delivered`, `email.delivery_delayed`, `email.bounced`, and `email.failed`. +3. Set `RESEND_WEBHOOK_SECRET` to that endpoint's signing secret and redeploy the auth service. +4. Send a test sign-in code through the production SMTP configuration and + confirm that both `email.sent` and `email.delivered` reach the receiver. + Resend's documentation does not explicitly guarantee that SMTP-submitted + email emits webhooks, so verify this before relying on the metrics. + +The receiver verifies the Svix signature against the raw request body, +deduplicates retries by `svix-id`, and stores events in the auth SQLite +database. The authenticated `/metrics` response includes `resendDelivery` +counts, average and maximum send-to-delivery latency, delayed-delivery events, +and the fraction of matched deliveries exceeding the 10-minute code lifetime. +A `deliveryOverOtpLifetimeFraction` of `0` with `matchedDeliveries: 0` means +there is not yet a matched `email.sent`/`email.delivered` pair. ### Database diff --git a/docs/design/testing-gaps.md b/docs/design/testing-gaps.md index 35e0fd26..bed4990b 100644 --- a/docs/design/testing-gaps.md +++ b/docs/design/testing-gaps.md @@ -15,7 +15,7 @@ pds-core callback, full OAuth flow). | `auth-service/lib/` | ~77% | `auto-provision.ts` at 0% (needs live PDS) | | `auth-service/middleware/` | ~91% | `rate-limit.ts` timer cleanup at 82% | | `auth-service/email/` | ~71% | Template conditional branches partially covered | -| `auth-service/routes/` | 0% | All seven route files — see below | +| `auth-service/routes/` | ~32% | Resend webhook is HTTP-tested; browser/OAuth routes remain gaps | | `auth-service/better-auth.ts` | 0% | better-auth wiring — see below | | `auth-service/context.ts` | 0% | Minimal glue class | | `auth-service/index.ts` | 0% | Express app assembly + `main()` | @@ -57,10 +57,13 @@ pds-core callback, full OAuth flow). concerns that should be covered by end-to-end tests (see `docs/design/e2e-testing.md`). -### 2. `auth-service/src/routes/` (0% — 7 route files, ~2300 lines total) +### 2. `auth-service/src/routes/` (low coverage) -**Files:** `login-page.ts`, `consent.ts`, `recovery.ts`, `account-login.ts`, -`account-settings.ts`, `choose-handle.ts`, `complete.ts` +The signed `resend-webhook.ts` receiver has HTTP-level integration coverage +for valid signatures, rejected signatures, supported event filtering, +persistence, and idempotent retries. The browser/OAuth route files remain the +main gap: `login-page.ts`, `consent.ts`, `recovery.ts`, `account-login.ts`, +`account-settings.ts`, `choose-handle.ts`, and `complete.ts`. **Why they're hard:** @@ -78,6 +81,9 @@ pds-core callback, full OAuth flow). **What can be tested:** +- Provider webhook routes can be tested through an ephemeral Express server + with locally signed payloads, as demonstrated by + `resend-webhook.test.ts`. - The DB-level logic used by these routes is already well-covered via `consent.test.ts` (auth_flow operations, client login tracking, signCallback round-trip). diff --git a/packages/auth-service/.env.example b/packages/auth-service/.env.example index dfb1cf6a..79e87128 100644 --- a/packages/auth-service/.env.example +++ b/packages/auth-service/.env.example @@ -138,6 +138,12 @@ SMTP_PASS= SMTP_FROM=noreply@pds.example SMTP_FROM_NAME=ePDS +# Optional Resend delivery webhook receiver. In the Resend dashboard, register +# https:///webhooks/resend for email.sent, email.delivered, +# email.delivery_delayed, email.bounced, and email.failed, then copy the +# endpoint's whsec_... signing secret here. +RESEND_WEBHOOK_SECRET= + # Database path (separate from PDS account.sqlite) DB_LOCATION=/data/epds.sqlite diff --git a/scripts/setup.sh b/scripts/setup.sh index 6518c79b..db11b285 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -63,6 +63,7 @@ inject_shared_vars() { PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX \ EPDS_INVITE_CODE PDS_INTERNAL_URL \ SMTP_HOST SMTP_PORT SMTP_USER SMTP_PASS SMTP_FROM SMTP_FROM_NAME PDS_EMAIL_FROM_ADDRESS \ + RESEND_WEBHOOK_SECRET \ PDS_TERMS_OF_SERVICE_URL PDS_PRIVACY_POLICY_URL PDS_LEGAL_ENTITY_NAME; do # Skip if the var isn't in the target AND isn't in the package's .env.example. # This avoids injecting vars a package doesn't use, while still handling From f54524a775a5acdf8d8645d1db0a188da09fada4 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 15 Jul 2026 11:06:40 +0100 Subject: [PATCH 09/24] fix(auth): keep Resend monitoring explicitly optional Clarify that ePDS supports any SMTP provider and expose the Resend route and metrics only after an operator opts in with a webhook secret. Bound the optional integration with dedicated rate limiting, 30-day retention, and reduced persisted payload data. Refs #185 Co-authored-by: OpenAI Codex --- .changeset/resend-delivery-webhooks.md | 4 +-- .env.example | 7 +++-- docs/configuration.md | 30 ++++++++++++------ packages/auth-service/.env.example | 8 ++--- .../src/__tests__/rate-limit.test.ts | 23 ++++++++++++++ packages/auth-service/src/context.ts | 4 +++ packages/auth-service/src/index.ts | 11 +++++++ .../auth-service/src/middleware/rate-limit.ts | 6 ++-- .../auth-service/src/routes/resend-webhook.ts | 4 +-- .../shared/src/__tests__/db-extended.test.ts | 31 ++++++++++--------- packages/shared/src/db.ts | 27 +++++++--------- scripts/setup.sh | 12 +++---- 12 files changed, 107 insertions(+), 60 deletions(-) diff --git a/.changeset/resend-delivery-webhooks.md b/.changeset/resend-delivery-webhooks.md index f231c921..cbf95a0a 100644 --- a/.changeset/resend-delivery-webhooks.md +++ b/.changeset/resend-delivery-webhooks.md @@ -2,8 +2,8 @@ 'ePDS': minor --- -Resend delivery events can now be measured against the sign-in code lifetime. +Optional delivery metrics for operators who send email through Resend. **Affects:** Operators -**Operators:** Register `https:///webhooks/resend` for the `email.sent`, `email.delivered`, `email.delivery_delayed`, `email.bounced`, and `email.failed` events, then set `RESEND_WEBHOOK_SECRET` to the endpoint's `whsec_...` signing secret. Delivery counts and latency statistics are exposed under `resendDelivery` in the authenticated `/metrics` response; the receiver remains disabled when the secret is unset. +**Operators:** ePDS remains compatible with any SMTP provider and does not require Resend. Operators who already use Resend can opt in by registering `https:///webhooks/resend` for the documented email events and setting `RESEND_WEBHOOK_SECRET`; only then are the route and `resendDelivery` metrics enabled. diff --git a/.env.example b/.env.example index 60782fa5..dcb5b5e6 100644 --- a/.env.example +++ b/.env.example @@ -214,9 +214,10 @@ SMTP_PASS= SMTP_FROM=noreply@pds.example SMTP_FROM_NAME=ePDS -# Optional Resend delivery webhook signing secret. Configure the webhook at -# https://auth.pds.example/webhooks/resend and copy its whsec_... secret here. -RESEND_WEBHOOK_SECRET= +# Resend users only (optional): delivery webhook signing secret. ePDS supports +# any SMTP provider and does not require Resend. If you opt into Resend delivery +# metrics, configure https://auth.pds.example/webhooks/resend and set: +# RESEND_WEBHOOK_SECRET=whsec_... DB_LOCATION=/data/epds.sqlite diff --git a/docs/configuration.md b/docs/configuration.md index ace8334b..021a7a2a 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -197,11 +197,20 @@ buttons appear on the login page. | `AWS_SES_SMTP_PASS` | AWS SES SMTP password | | `POSTMARK_SERVER_TOKEN` | Postmark server token | | `EMAIL_TEMPLATE_ALLOWED_DOMAINS` | Optional comma-separated list of HTTPS hostnames from which `email_template_uri` can be fetched. If unset, any HTTPS URL is allowed. If set, templates hosted on unlisted domains are logged as a warning and ignored. | -| `RESEND_WEBHOOK_SECRET` | Optional Resend webhook signing secret (`whsec_...`). When set, enables `POST /webhooks/resend`; when unset, the receiver is not mounted. | -#### Resend delivery metrics +#### Optional Resend delivery metrics -To capture delivery latency for email sent through Resend: +ePDS does **not** require Resend: operators can use any SMTP provider. This +provider-specific integration is only for operators who already send through +Resend and choose to collect its webhook delivery metrics. Without +`RESEND_WEBHOOK_SECRET`, the webhook route is not mounted and the +`resendDelivery` metrics block is omitted. + +| Variable | Description | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `RESEND_WEBHOOK_SECRET` | Resend users only. Optional webhook signing secret (`whsec_...`) that enables `POST /webhooks/resend` and Resend-specific metrics when explicitly provided. | + +To opt into delivery-latency monitoring for email sent through Resend: 1. In the Resend dashboard, register `https:///webhooks/resend`. 2. Subscribe it to `email.sent`, `email.delivered`, `email.delivery_delayed`, `email.bounced`, and `email.failed`. @@ -212,12 +221,15 @@ To capture delivery latency for email sent through Resend: email emits webhooks, so verify this before relying on the metrics. The receiver verifies the Svix signature against the raw request body, -deduplicates retries by `svix-id`, and stores events in the auth SQLite -database. The authenticated `/metrics` response includes `resendDelivery` -counts, average and maximum send-to-delivery latency, delayed-delivery events, -and the fraction of matched deliveries exceeding the 10-minute code lifetime. -A `deliveryOverOtpLifetimeFraction` of `0` with `matchedDeliveries: 0` means -there is not yet a matched `email.sent`/`email.delivered` pair. +deduplicates retries by `svix-id`, and stores the Resend email ID, recipient, +event type, and timestamps in the auth SQLite database for 30 days. The +provider-specific rate limit permits 300 webhook requests per minute per source +IP. The authenticated `/metrics` response includes `resendDelivery` counts, +average and maximum send-to-delivery latency, delayed-delivery events, and the +fraction of matched deliveries exceeding the 10-minute code lifetime over the +retained window. A `deliveryOverOtpLifetimeFraction` of `0` with +`matchedDeliveries: 0` means there is not yet a matched +`email.sent`/`email.delivered` pair. ### Database diff --git a/packages/auth-service/.env.example b/packages/auth-service/.env.example index 79e87128..12382910 100644 --- a/packages/auth-service/.env.example +++ b/packages/auth-service/.env.example @@ -138,11 +138,11 @@ SMTP_PASS= SMTP_FROM=noreply@pds.example SMTP_FROM_NAME=ePDS -# Optional Resend delivery webhook receiver. In the Resend dashboard, register +# Resend users only (optional). ePDS supports any SMTP provider and does not +# require Resend. To opt into Resend delivery metrics, register # https:///webhooks/resend for email.sent, email.delivered, -# email.delivery_delayed, email.bounced, and email.failed, then copy the -# endpoint's whsec_... signing secret here. -RESEND_WEBHOOK_SECRET= +# email.delivery_delayed, email.bounced, and email.failed, then set: +# RESEND_WEBHOOK_SECRET=whsec_... # Database path (separate from PDS account.sqlite) DB_LOCATION=/data/epds.sqlite diff --git a/packages/auth-service/src/__tests__/rate-limit.test.ts b/packages/auth-service/src/__tests__/rate-limit.test.ts index 23533805..3ad4d64b 100644 --- a/packages/auth-service/src/__tests__/rate-limit.test.ts +++ b/packages/auth-service/src/__tests__/rate-limit.test.ts @@ -98,6 +98,29 @@ describe('requestRateLimit', () => { expect(next2).toBe(true) }) + it('keeps independently configured limiter namespaces separate', () => { + const ip = `namespace-${Date.now()}-${randomUUID()}` + const globalLimiter = requestRateLimit({ + windowMs: 60_000, + maxRequests: 1, + }) + const webhookLimiter = requestRateLimit({ + windowMs: 60_000, + maxRequests: 1, + keyPrefix: 'resend-webhook', + }) + + for (const limiter of [globalLimiter, webhookLimiter]) { + const req = makeReq(ip) + const res = makeRes() + let nextCalled = false + limiter(req as never, res as never, () => { + nextCalled = true + }) + expect(nextCalled).toBe(true) + } + }) + it('uses req.ip when available', () => { const limiter = requestRateLimit({ windowMs: 60000, maxRequests: 10 }) const req = { diff --git a/packages/auth-service/src/context.ts b/packages/auth-service/src/context.ts index a31014fa..563614df 100644 --- a/packages/auth-service/src/context.ts +++ b/packages/auth-service/src/context.ts @@ -48,6 +48,7 @@ export interface AuthServiceConfig { } const logger = createLogger('auth-service') +const RESEND_EMAIL_EVENT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000 export class AuthServiceContext { public readonly db: EpdsDb @@ -72,6 +73,9 @@ export class AuthServiceContext { } this.db.cleanupOldRateLimitEntries() this.db.cleanupOldOtpFailures() + this.db.cleanupResendEmailEventsBefore( + Date.now() - RESEND_EMAIL_EVENT_RETENTION_MS, + ) }, 5 * 60 * 1000, ) diff --git a/packages/auth-service/src/index.ts b/packages/auth-service/src/index.ts index c0da188b..97909546 100644 --- a/packages/auth-service/src/index.ts +++ b/packages/auth-service/src/index.ts @@ -52,6 +52,14 @@ export function createAuthService(config: AuthServiceConfig): { // before any urlencoded or JSON body parser. It deliberately sits outside // browser CSRF protection; authenticity comes from the Svix signature. if (config.resendWebhookSecret) { + app.use( + '/webhooks/resend', + requestRateLimit({ + windowMs: 60_000, + maxRequests: 300, + keyPrefix: 'resend-webhook', + }), + ) app.use(createResendWebhookRouter(ctx.db, config.resendWebhookSecret)) } @@ -124,6 +132,9 @@ export function createAuthService(config: AuthServiceConfig): { const metrics = ctx.db.getMetrics() res.json({ ...metrics, + ...(config.resendWebhookSecret + ? { resendDelivery: ctx.db.getResendDeliveryMetrics() } + : {}), uptime: process.uptime(), memoryUsage: process.memoryUsage().rss, timestamp: Date.now(), diff --git a/packages/auth-service/src/middleware/rate-limit.ts b/packages/auth-service/src/middleware/rate-limit.ts index 438d7cdc..f8ed2d99 100644 --- a/packages/auth-service/src/middleware/rate-limit.ts +++ b/packages/auth-service/src/middleware/rate-limit.ts @@ -26,6 +26,7 @@ setInterval( export function requestRateLimit(opts: { windowMs: number maxRequests: number + keyPrefix?: string }) { const disabled = process.env.EPDS_DISABLE_RATE_LIMIT === 'true' return (req: Request, res: Response, next: NextFunction): void => { @@ -34,12 +35,13 @@ export function requestRateLimit(opts: { return } const ip = req.ip || req.socket.remoteAddress || 'unknown' + const key = `${opts.keyPrefix ?? 'global'}:${ip}` const now = Date.now() - let entry = requestCounts.get(ip) + let entry = requestCounts.get(key) if (!entry || entry.resetAt < now) { entry = { count: 0, resetAt: now + opts.windowMs } - requestCounts.set(ip, entry) + requestCounts.set(key, entry) } entry.count++ diff --git a/packages/auth-service/src/routes/resend-webhook.ts b/packages/auth-service/src/routes/resend-webhook.ts index 69b89bba..4ab2103d 100644 --- a/packages/auth-service/src/routes/resend-webhook.ts +++ b/packages/auth-service/src/routes/resend-webhook.ts @@ -43,7 +43,7 @@ function isResendEvent(value: unknown): value is ResendEvent { const data = value.data return ( typeof value.type === 'string' && - RESEND_EVENT_TYPES.some((eventType) => eventType === value.type) && + (RESEND_EVENT_TYPES as readonly string[]).includes(value.type) && typeof value.created_at === 'string' && Number.isFinite(Date.parse(value.created_at)) && typeof data.email_id === 'string' && @@ -123,8 +123,6 @@ function recordResendEvent( eventType: event.type, eventCreatedAt: Date.parse(event.created_at), recipients: event.data.to, - sender: event.data.from, - subject: event.data.subject, }) } diff --git a/packages/shared/src/__tests__/db-extended.test.ts b/packages/shared/src/__tests__/db-extended.test.ts index a65c216d..4bd83953 100644 --- a/packages/shared/src/__tests__/db-extended.test.ts +++ b/packages/shared/src/__tests__/db-extended.test.ts @@ -4,7 +4,7 @@ * and edge cases for existing operations. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { EpdsDb } from '../db.js' +import { EpdsDb, type ResendEmailEventType } from '../db.js' import * as fs from 'node:fs' import * as path from 'node:path' import * as os from 'node:os' @@ -38,10 +38,11 @@ afterEach(() => { describe('getMetrics', () => { it('returns zero counts for an empty database', () => { - const metrics = db.getMetrics() - expect(metrics.pendingTokens).toBe(0) - expect(metrics.backupEmails).toBe(0) - expect(metrics.rateLimitEntries).toBe(0) + expect(db.getMetrics()).toEqual({ + pendingTokens: 0, + backupEmails: 0, + rateLimitEntries: 0, + }) }) it('counts pending (unused, non-expired) tokens', () => { @@ -283,12 +284,7 @@ describe('Resend email delivery events', () => { function record( svixId: string, emailId: string, - eventType: - | 'email.sent' - | 'email.delivered' - | 'email.delivery_delayed' - | 'email.bounced' - | 'email.failed', + eventType: ResendEmailEventType, eventCreatedAt: number, ): boolean { return db.recordResendEmailEvent({ @@ -297,8 +293,6 @@ describe('Resend email delivery events', () => { eventType, eventCreatedAt, recipients: ['person@example.com'], - sender: 'ePDS ', - subject: 'Your sign-in code', }) } @@ -352,8 +346,8 @@ describe('Resend email delivery events', () => { }) }) - it('includes empty delivery metrics in the service metrics', () => { - expect(db.getMetrics().resendDelivery).toEqual({ + it('returns empty delivery metrics before receiving events', () => { + expect(db.getResendDeliveryMetrics()).toEqual({ sentEmails: 0, deliveredEmails: 0, deliveryDelayedEvents: 0, @@ -366,4 +360,11 @@ describe('Resend email delivery events', () => { deliveryOverOtpLifetimeFraction: 0, }) }) + + it('purges events received before the retention cutoff', () => { + record('old-event', 'old-email', 'email.sent', baseTime) + + expect(db.cleanupResendEmailEventsBefore(Date.now() + 1)).toBe(1) + expect(db.getResendEmailEvents('old-email')).toHaveLength(0) + }) }) diff --git a/packages/shared/src/db.ts b/packages/shared/src/db.ts index 13ef46f4..e48abe77 100644 --- a/packages/shared/src/db.ts +++ b/packages/shared/src/db.ts @@ -56,8 +56,6 @@ export interface ResendEmailEvent { eventType: ResendEmailEventType eventCreatedAt: number recipients: string[] - sender: string - subject: string receivedAt: number } @@ -231,8 +229,6 @@ export class EpdsDb { event_type TEXT NOT NULL, event_created_at INTEGER NOT NULL, recipients_json TEXT NOT NULL, - sender TEXT NOT NULL, - subject TEXT NOT NULL, received_at INTEGER NOT NULL ); CREATE INDEX IF NOT EXISTS idx_ree_email_time @@ -526,8 +522,8 @@ export class EpdsDb { const result = this.db .prepare( `INSERT OR IGNORE INTO resend_email_event - (svix_id, email_id, event_type, event_created_at, recipients_json, sender, subject, received_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + (svix_id, email_id, event_type, event_created_at, recipients_json, received_at) + VALUES (?, ?, ?, ?, ?, ?)`, ) .run( event.svixId, @@ -535,8 +531,6 @@ export class EpdsDb { event.eventType, event.eventCreatedAt, JSON.stringify(event.recipients), - event.sender, - event.subject, Date.now(), ) return result.changes > 0 @@ -548,7 +542,7 @@ export class EpdsDb { .prepare( `SELECT svix_id as svixId, email_id as emailId, event_type as eventType, event_created_at as eventCreatedAt, recipients_json as recipientsJson, - sender, subject, received_at as receivedAt + received_at as receivedAt FROM resend_email_event WHERE email_id = ? ORDER BY event_created_at ASC, svix_id ASC`, @@ -575,6 +569,13 @@ export class EpdsDb { }) } + cleanupResendEmailEventsBefore(cutoffMs: number): number { + const result = this.db + .prepare('DELETE FROM resend_email_event WHERE received_at < ?') + .run(cutoffMs) + return result.changes + } + getResendDeliveryMetrics(): ResendDeliveryMetrics { const counts = this.db .prepare( @@ -636,7 +637,6 @@ export class EpdsDb { pendingTokens: number backupEmails: number rateLimitEntries: number - resendDelivery: ResendDeliveryMetrics } { const now = Date.now() const pendingTokens = ( @@ -656,12 +656,7 @@ export class EpdsDb { c: number } ).c - return { - pendingTokens, - backupEmails, - rateLimitEntries, - resendDelivery: this.getResendDeliveryMetrics(), - } + return { pendingTokens, backupEmails, rateLimitEntries } } close(): void { diff --git a/scripts/setup.sh b/scripts/setup.sh index db11b285..3addbb32 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -53,7 +53,7 @@ generate_secrets_in_file() { } # Copy shared vars from the top-level .env into a per-package .env. -# Only sets vars that already have an uncommented line in the target file, +# Only sets vars already present in the target or documented in its example, # so packages don't end up with vars they don't use. inject_shared_vars() { local target="$1" @@ -65,11 +65,11 @@ inject_shared_vars() { SMTP_HOST SMTP_PORT SMTP_USER SMTP_PASS SMTP_FROM SMTP_FROM_NAME PDS_EMAIL_FROM_ADDRESS \ RESEND_WEBHOOK_SECRET \ PDS_TERMS_OF_SERVICE_URL PDS_PRIVACY_POLICY_URL PDS_LEGAL_ENTITY_NAME; do - # Skip if the var isn't in the target AND isn't in the package's .env.example. - # This avoids injecting vars a package doesn't use, while still handling - # .env files created from an older .env.example that lacked the var. - if ! grep -qE "^${var}=" "$target" 2>/dev/null \ - && ! grep -qE "^${var}=" "$example" 2>/dev/null; then + # Optional variables may be commented out in examples. Recognising those + # comments lets an explicitly configured top-level value propagate while + # leaving the option absent for everyone else. + if ! grep -qE "^(# ?)?${var}=" "$target" 2>/dev/null \ + && ! grep -qE "^(# ?)?${var}=" "$example" 2>/dev/null; then continue fi local val From 8048eb07447475d2b97561a16d5ec4a353fe5d9c Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 15 Jul 2026 11:14:28 +0100 Subject: [PATCH 10/24] fix(auth): log Resend events without persistence Remove the webhook database schema and in-process delivery metrics. The optional signed receiver now emits structured event logs with Svix IDs so external log analysis can deduplicate retries, order events, and calculate latency without ePDS retaining webhook data. Refs #185 Co-authored-by: OpenAI Codex --- .changeset/resend-delivery-webhooks.md | 4 +- .env.example | 4 +- docs/configuration.md | 35 ++-- docs/design/testing-gaps.md | 2 +- packages/auth-service/.env.example | 2 +- .../src/__tests__/resend-webhook.test.ts | 89 +++++----- packages/auth-service/src/better-auth.ts | 6 +- packages/auth-service/src/context.ts | 4 - packages/auth-service/src/index.ts | 5 +- packages/auth-service/src/lib/auth-flow.ts | 2 +- .../auth-service/src/routes/resend-webhook.ts | 57 ++---- .../shared/src/__tests__/db-extended.test.ts | 107 +---------- packages/shared/src/db.ts | 166 ------------------ packages/shared/src/index.ts | 5 +- packages/shared/src/types.ts | 3 - vitest.config.ts | 2 +- 16 files changed, 100 insertions(+), 393 deletions(-) diff --git a/.changeset/resend-delivery-webhooks.md b/.changeset/resend-delivery-webhooks.md index cbf95a0a..3794e223 100644 --- a/.changeset/resend-delivery-webhooks.md +++ b/.changeset/resend-delivery-webhooks.md @@ -2,8 +2,8 @@ 'ePDS': minor --- -Optional delivery metrics for operators who send email through Resend. +Optional delivery event logs for operators who send email through Resend. **Affects:** Operators -**Operators:** ePDS remains compatible with any SMTP provider and does not require Resend. Operators who already use Resend can opt in by registering `https:///webhooks/resend` for the documented email events and setting `RESEND_WEBHOOK_SECRET`; only then are the route and `resendDelivery` metrics enabled. +**Operators:** ePDS remains compatible with any SMTP provider and does not require Resend. Operators who already use Resend can opt in by registering `https:///webhooks/resend` for the documented email events and setting `RESEND_WEBHOOK_SECRET`; the route then verifies each webhook and emits structured delivery logs without persisting webhook data. diff --git a/.env.example b/.env.example index dcb5b5e6..58eb9d0e 100644 --- a/.env.example +++ b/.env.example @@ -215,8 +215,8 @@ SMTP_FROM=noreply@pds.example SMTP_FROM_NAME=ePDS # Resend users only (optional): delivery webhook signing secret. ePDS supports -# any SMTP provider and does not require Resend. If you opt into Resend delivery -# metrics, configure https://auth.pds.example/webhooks/resend and set: +# any SMTP provider and does not require Resend. To opt into Resend delivery +# event logs, configure https://auth.pds.example/webhooks/resend and set: # RESEND_WEBHOOK_SECRET=whsec_... DB_LOCATION=/data/epds.sqlite diff --git a/docs/configuration.md b/docs/configuration.md index 021a7a2a..a8fb0385 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -198,19 +198,18 @@ buttons appear on the login page. | `POSTMARK_SERVER_TOKEN` | Postmark server token | | `EMAIL_TEMPLATE_ALLOWED_DOMAINS` | Optional comma-separated list of HTTPS hostnames from which `email_template_uri` can be fetched. If unset, any HTTPS URL is allowed. If set, templates hosted on unlisted domains are logged as a warning and ignored. | -#### Optional Resend delivery metrics +#### Optional Resend delivery event logs ePDS does **not** require Resend: operators can use any SMTP provider. This provider-specific integration is only for operators who already send through -Resend and choose to collect its webhook delivery metrics. Without -`RESEND_WEBHOOK_SECRET`, the webhook route is not mounted and the -`resendDelivery` metrics block is omitted. +Resend and choose to log its delivery webhooks. Without +`RESEND_WEBHOOK_SECRET`, the webhook route is not mounted. -| Variable | Description | -| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `RESEND_WEBHOOK_SECRET` | Resend users only. Optional webhook signing secret (`whsec_...`) that enables `POST /webhooks/resend` and Resend-specific metrics when explicitly provided. | +| Variable | Description | +| ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| `RESEND_WEBHOOK_SECRET` | Resend users only. Optional webhook signing secret (`whsec_...`) that enables `POST /webhooks/resend` and structured Resend event logs when provided. | -To opt into delivery-latency monitoring for email sent through Resend: +To opt into delivery-event logging for email sent through Resend: 1. In the Resend dashboard, register `https:///webhooks/resend`. 2. Subscribe it to `email.sent`, `email.delivered`, `email.delivery_delayed`, `email.bounced`, and `email.failed`. @@ -220,16 +219,16 @@ To opt into delivery-latency monitoring for email sent through Resend: Resend's documentation does not explicitly guarantee that SMTP-submitted email emits webhooks, so verify this before relying on the metrics. -The receiver verifies the Svix signature against the raw request body, -deduplicates retries by `svix-id`, and stores the Resend email ID, recipient, -event type, and timestamps in the auth SQLite database for 30 days. The -provider-specific rate limit permits 300 webhook requests per minute per source -IP. The authenticated `/metrics` response includes `resendDelivery` counts, -average and maximum send-to-delivery latency, delayed-delivery events, and the -fraction of matched deliveries exceeding the 10-minute code lifetime over the -retained window. A `deliveryOverOtpLifetimeFraction` of `0` with -`matchedDeliveries: 0` means there is not yet a matched -`email.sent`/`email.delivered` pair. +The receiver verifies the Svix signature against the raw request body and logs +`svixId`, `eventType`, `eventCreatedAt`, `emailId`, and `recipients` as +structured fields. Normal events use `info`; `email.delivery_delayed` uses +`warn`. No webhook data is persisted by ePDS. The provider-specific rate limit +permits 300 webhook requests per minute per source IP. + +Resend retries carry the same `svixId`, and events may arrive out of order. Log +analysis should deduplicate by `svixId`, order each `emailId` by +`eventCreatedAt`, and subtract `email.sent` from `email.delivered` to calculate +delivery latency. ### Database diff --git a/docs/design/testing-gaps.md b/docs/design/testing-gaps.md index bed4990b..23f250ef 100644 --- a/docs/design/testing-gaps.md +++ b/docs/design/testing-gaps.md @@ -61,7 +61,7 @@ pds-core callback, full OAuth flow). The signed `resend-webhook.ts` receiver has HTTP-level integration coverage for valid signatures, rejected signatures, supported event filtering, -persistence, and idempotent retries. The browser/OAuth route files remain the +structured logging, and retry correlation. The browser/OAuth route files remain the main gap: `login-page.ts`, `consent.ts`, `recovery.ts`, `account-login.ts`, `account-settings.ts`, `choose-handle.ts`, and `complete.ts`. diff --git a/packages/auth-service/.env.example b/packages/auth-service/.env.example index 12382910..b6b29b90 100644 --- a/packages/auth-service/.env.example +++ b/packages/auth-service/.env.example @@ -139,7 +139,7 @@ SMTP_FROM=noreply@pds.example SMTP_FROM_NAME=ePDS # Resend users only (optional). ePDS supports any SMTP provider and does not -# require Resend. To opt into Resend delivery metrics, register +# require Resend. To opt into Resend delivery event logs, register # https:///webhooks/resend for email.sent, email.delivered, # email.delivery_delayed, email.bounced, and email.failed, then set: # RESEND_WEBHOOK_SECRET=whsec_... diff --git a/packages/auth-service/src/__tests__/resend-webhook.test.ts b/packages/auth-service/src/__tests__/resend-webhook.test.ts index ad794242..c7298dda 100644 --- a/packages/auth-service/src/__tests__/resend-webhook.test.ts +++ b/packages/auth-service/src/__tests__/resend-webhook.test.ts @@ -1,34 +1,25 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { EpdsDb } from '@certified-app/shared' +import { beforeEach, describe, expect, it, vi } from 'vitest' import express from 'express' -import * as fs from 'node:fs' -import * as os from 'node:os' -import * as path from 'node:path' -import { randomUUID } from 'node:crypto' import { Webhook } from 'svix' + +const { logInfo, logWarn } = vi.hoisted(() => ({ + logInfo: vi.fn(), + logWarn: vi.fn(), +})) + +vi.mock('@certified-app/shared', () => ({ + createLogger: () => ({ info: logInfo, warn: logWarn }), +})) + import { createResendWebhookRouter } from '../routes/resend-webhook.js' const WEBHOOK_SECRET = `whsec_${Buffer.from('test-webhook-secret').toString( 'base64', )}` -let db: EpdsDb -let dbPath: string - beforeEach(() => { - dbPath = path.join(os.tmpdir(), `resend-webhook-${randomUUID()}.sqlite`) - db = new EpdsDb(dbPath) -}) - -afterEach(() => { - db.close() - for (const suffix of ['', '-wal', '-shm']) { - try { - fs.unlinkSync(dbPath + suffix) - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err - } - } + logInfo.mockClear() + logWarn.mockClear() }) function makeEvent(type = 'email.sent'): Record { @@ -53,7 +44,7 @@ async function postWebhook( } = {}, ): Promise<{ status: number; json: Record }> { const app = express() - app.use(createResendWebhookRouter(db, WEBHOOK_SECRET)) + app.use(createResendWebhookRouter(WEBHOOK_SECRET)) const server = app.listen(0) try { @@ -103,40 +94,52 @@ async function postWebhook( } describe('Resend webhook receiver', () => { - it('verifies, persists, and acknowledges a delivery event', async () => { + it('verifies, logs, and acknowledges a delivery event', async () => { const result = await postWebhook(makeEvent('email.delivered')) - expect(result).toEqual({ - status: 200, - json: { received: true, duplicate: false }, - }) - expect(db.getResendEmailEvents('resend-email-123')).toMatchObject([ + expect(result).toEqual({ status: 200, json: { received: true } }) + expect(logInfo).toHaveBeenCalledWith( { svixId: 'msg_test_123', eventType: 'email.delivered', + eventCreatedAt: '2026-07-14T10:00:00.000Z', + emailId: 'resend-email-123', recipients: ['person@example.com'], }, + 'Received Resend email delivery event', + ) + }) + + it('logs retries with the same Svix ID for downstream deduplication', async () => { + await postWebhook(makeEvent(), { svixId: 'msg_retry' }) + await postWebhook(makeEvent(), { svixId: 'msg_retry' }) + + expect(logInfo).toHaveBeenCalledTimes(2) + expect(logInfo.mock.calls.map(([fields]) => fields.svixId)).toEqual([ + 'msg_retry', + 'msg_retry', ]) }) - it('acknowledges a Svix retry without inserting it twice', async () => { - const first = await postWebhook(makeEvent(), { svixId: 'msg_retry' }) - const retry = await postWebhook(makeEvent(), { svixId: 'msg_retry' }) + it('logs delivery delays at warning level', async () => { + await postWebhook(makeEvent('email.delivery_delayed')) - expect(first.json.duplicate).toBe(false) - expect(retry).toEqual({ - status: 200, - json: { received: true, duplicate: true }, - }) - expect(db.getResendEmailEvents('resend-email-123')).toHaveLength(1) + expect(logWarn).toHaveBeenCalledWith( + expect.objectContaining({ + svixId: 'msg_test_123', + eventType: 'email.delivery_delayed', + emailId: 'resend-email-123', + }), + 'Received Resend email delivery event', + ) }) - it('rejects an invalid signature before persisting the payload', async () => { + it('rejects an invalid signature before logging the payload', async () => { const result = await postWebhook(makeEvent(), { validSignature: false }) expect(result.status).toBe(400) expect(result.json).toEqual({ error: 'Invalid webhook signature' }) - expect(db.getResendEmailEvents('resend-email-123')).toHaveLength(0) + expect(logInfo).not.toHaveBeenCalled() }) it('rejects a request without the required Svix headers', async () => { @@ -146,14 +149,14 @@ describe('Resend webhook receiver', () => { status: 400, json: { error: 'Invalid webhook request' }, }) - expect(db.getResendEmailEvents('resend-email-123')).toHaveLength(0) + expect(logInfo).not.toHaveBeenCalled() }) - it('rejects signed event types that are not used for delivery metrics', async () => { + it('rejects signed event types that are not used for delivery logs', async () => { const result = await postWebhook(makeEvent('email.opened')) expect(result.status).toBe(400) expect(result.json).toEqual({ error: 'Invalid webhook payload' }) - expect(db.getResendEmailEvents('resend-email-123')).toHaveLength(0) + expect(logInfo).not.toHaveBeenCalled() }) }) diff --git a/packages/auth-service/src/better-auth.ts b/packages/auth-service/src/better-auth.ts index eb870772..c5bd2a2b 100644 --- a/packages/auth-service/src/better-auth.ts +++ b/packages/auth-service/src/better-auth.ts @@ -11,7 +11,7 @@ * The instance is mounted at /api/auth/* alongside the existing custom routes. */ import type { EpdsDb } from '@certified-app/shared' -import { createLogger, OTP_LIFETIME_SECONDS } from '@certified-app/shared' +import { createLogger } from '@certified-app/shared' import { betterAuth } from 'better-auth' import { APIError, createAuthMiddleware } from 'better-auth/api' import { generateRandomString } from 'better-auth/crypto' @@ -204,7 +204,7 @@ export async function runBetterAuthMigrations( plugins: [ emailOTP({ otpLength, - expiresIn: OTP_LIFETIME_SECONDS, + expiresIn: 600, allowedAttempts: 5, storeOTP: 'hashed', ...(otpCharset === 'alphanumeric' @@ -305,7 +305,7 @@ export function createBetterAuth( plugins: [ emailOTP({ otpLength, - expiresIn: OTP_LIFETIME_SECONDS, + expiresIn: 600, allowedAttempts: 5, storeOTP: 'hashed', ...(otpCharset === 'alphanumeric' diff --git a/packages/auth-service/src/context.ts b/packages/auth-service/src/context.ts index 563614df..a31014fa 100644 --- a/packages/auth-service/src/context.ts +++ b/packages/auth-service/src/context.ts @@ -48,7 +48,6 @@ export interface AuthServiceConfig { } const logger = createLogger('auth-service') -const RESEND_EMAIL_EVENT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000 export class AuthServiceContext { public readonly db: EpdsDb @@ -73,9 +72,6 @@ export class AuthServiceContext { } this.db.cleanupOldRateLimitEntries() this.db.cleanupOldOtpFailures() - this.db.cleanupResendEmailEventsBefore( - Date.now() - RESEND_EMAIL_EVENT_RETENTION_MS, - ) }, 5 * 60 * 1000, ) diff --git a/packages/auth-service/src/index.ts b/packages/auth-service/src/index.ts index 97909546..77725f86 100644 --- a/packages/auth-service/src/index.ts +++ b/packages/auth-service/src/index.ts @@ -60,7 +60,7 @@ export function createAuthService(config: AuthServiceConfig): { keyPrefix: 'resend-webhook', }), ) - app.use(createResendWebhookRouter(ctx.db, config.resendWebhookSecret)) + app.use(createResendWebhookRouter(config.resendWebhookSecret)) } // Middleware @@ -132,9 +132,6 @@ export function createAuthService(config: AuthServiceConfig): { const metrics = ctx.db.getMetrics() res.json({ ...metrics, - ...(config.resendWebhookSecret - ? { resendDelivery: ctx.db.getResendDeliveryMetrics() } - : {}), uptime: process.uptime(), memoryUsage: process.memoryUsage().rss, timestamp: Date.now(), diff --git a/packages/auth-service/src/lib/auth-flow.ts b/packages/auth-service/src/lib/auth-flow.ts index acb6ff3a..c9596ec5 100644 --- a/packages/auth-service/src/lib/auth-flow.ts +++ b/packages/auth-service/src/lib/auth-flow.ts @@ -12,7 +12,7 @@ export const AUTH_FLOW_COOKIE = 'epds_auth_flow' * can use them. * * NOT the OTP code's lifetime — better-auth enforces that separately - * in the `verification` table (`OTP_LIFETIME_SECONDS` in the shared package). + * in the `verification` table (`expiresIn: 600` in better-auth.ts). * * 60 minutes lets a slow user who hits OTP expiry (10 min) and clicks * Resend still have a live auth_flow + cookie to land on /auth/complete diff --git a/packages/auth-service/src/routes/resend-webhook.ts b/packages/auth-service/src/routes/resend-webhook.ts index 4ab2103d..0a535f13 100644 --- a/packages/auth-service/src/routes/resend-webhook.ts +++ b/packages/auth-service/src/routes/resend-webhook.ts @@ -3,10 +3,11 @@ * * The route consumes the untouched request body, verifies Resend's Svix * signature before inspecting the payload, accepts only delivery events used - * by ePDS, and persists them idempotently by `svix-id`. Resend may retry the - * same event and may deliver an email's events out of order. + * by ePDS, and emits one structured log entry per delivery attempt. Resend may + * retry the same `svix-id` and may deliver an email's events out of order, so + * log consumers must deduplicate and order events when calculating latency. */ -import { createLogger, type EpdsDb } from '@certified-app/shared' +import { createLogger } from '@certified-app/shared' import express, { Router } from 'express' import { Webhook } from 'svix' @@ -112,42 +113,23 @@ function verifyResendWebhook( return { ok: true, headers, event: payload } } -function recordResendEvent( - db: EpdsDb, - svixId: string, - event: ResendEvent, -): boolean { - return db.recordResendEmailEvent({ +function logResendEvent(event: ResendEvent, svixId: string): void { + const fields = { svixId, - emailId: event.data.email_id, eventType: event.type, - eventCreatedAt: Date.parse(event.created_at), + eventCreatedAt: event.created_at, + emailId: event.data.email_id, recipients: event.data.to, - }) -} - -function logProcessedEvent(event: ResendEvent, inserted: boolean): void { - if (inserted && event.type === 'email.delivery_delayed') { - logger.warn( - { emailId: event.data.email_id }, - 'Resend reported delayed email delivery', - ) - return } - logger.debug( - { - emailId: event.data.email_id, - eventType: event.type, - duplicate: !inserted, - }, - 'Processed Resend email event', - ) + const message = 'Received Resend email delivery event' + if (event.type === 'email.delivery_delayed') { + logger.warn(fields, message) + } else { + logger.info(fields, message) + } } -export function createResendWebhookRouter( - db: EpdsDb, - webhookSecret: string, -): Router { +export function createResendWebhookRouter(webhookSecret: string): Router { const router = Router() const verifier = new Webhook(webhookSecret) @@ -161,13 +143,8 @@ export function createResendWebhookRouter( return } - const inserted = recordResendEvent( - db, - result.headers['svix-id'], - result.event, - ) - logProcessedEvent(result.event, inserted) - res.status(200).json({ received: true, duplicate: !inserted }) + logResendEvent(result.event, result.headers['svix-id']) + res.status(200).json({ received: true }) }, ) diff --git a/packages/shared/src/__tests__/db-extended.test.ts b/packages/shared/src/__tests__/db-extended.test.ts index 4bd83953..7de43e27 100644 --- a/packages/shared/src/__tests__/db-extended.test.ts +++ b/packages/shared/src/__tests__/db-extended.test.ts @@ -4,7 +4,7 @@ * and edge cases for existing operations. */ import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { EpdsDb, type ResendEmailEventType } from '../db.js' +import { EpdsDb } from '../db.js' import * as fs from 'node:fs' import * as path from 'node:path' import * as os from 'node:os' @@ -38,11 +38,10 @@ afterEach(() => { describe('getMetrics', () => { it('returns zero counts for an empty database', () => { - expect(db.getMetrics()).toEqual({ - pendingTokens: 0, - backupEmails: 0, - rateLimitEntries: 0, - }) + const metrics = db.getMetrics() + expect(metrics.pendingTokens).toBe(0) + expect(metrics.backupEmails).toBe(0) + expect(metrics.rateLimitEntries).toBe(0) }) it('counts pending (unused, non-expired) tokens', () => { @@ -262,9 +261,8 @@ describe('Database initialization', () => { fs.rmSync(path.dirname(path.dirname(nestedPath)), { recursive: true, }) - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err - } + // eslint-disable-next-line no-empty + } catch {} }) it('runs migrations idempotently on second open', () => { @@ -277,94 +275,3 @@ describe('Database initialization', () => { db = db2 }) }) - -describe('Resend email delivery events', () => { - const baseTime = Date.parse('2026-07-14T10:00:00.000Z') - - function record( - svixId: string, - emailId: string, - eventType: ResendEmailEventType, - eventCreatedAt: number, - ): boolean { - return db.recordResendEmailEvent({ - svixId, - emailId, - eventType, - eventCreatedAt, - recipients: ['person@example.com'], - }) - } - - it('deduplicates retries by Svix ID and returns events by event time', () => { - expect( - record('msg-delivered', 'email-1', 'email.delivered', baseTime + 2_000), - ).toBe(true) - expect(record('msg-sent', 'email-1', 'email.sent', baseTime)).toBe(true) - expect(record('msg-sent', 'email-1', 'email.sent', baseTime)).toBe(false) - - const events = db.getResendEmailEvents('email-1') - expect(events).toHaveLength(2) - expect(events.map((event) => event.eventType)).toEqual([ - 'email.sent', - 'email.delivered', - ]) - expect(events[0]?.recipients).toEqual(['person@example.com']) - }) - - it('computes delivery latency and outcome metrics across out-of-order events', () => { - record( - 'slow-delivered', - 'slow-email', - 'email.delivered', - baseTime + 601_000, - ) - record( - 'slow-delayed', - 'slow-email', - 'email.delivery_delayed', - baseTime + 5_000, - ) - record('slow-sent', 'slow-email', 'email.sent', baseTime) - - record('fast-sent', 'fast-email', 'email.sent', baseTime + 10_000) - record('fast-delivered', 'fast-email', 'email.delivered', baseTime + 70_000) - record('bounced', 'bounced-email', 'email.bounced', baseTime) - record('failed', 'failed-email', 'email.failed', baseTime) - - expect(db.getResendDeliveryMetrics()).toEqual({ - sentEmails: 2, - deliveredEmails: 2, - deliveryDelayedEvents: 1, - bouncedEmails: 1, - failedEmails: 1, - matchedDeliveries: 2, - averageDeliveryLatencyMs: 330_500, - maxDeliveryLatencyMs: 601_000, - deliveriesOverOtpLifetime: 1, - deliveryOverOtpLifetimeFraction: 0.5, - }) - }) - - it('returns empty delivery metrics before receiving events', () => { - expect(db.getResendDeliveryMetrics()).toEqual({ - sentEmails: 0, - deliveredEmails: 0, - deliveryDelayedEvents: 0, - bouncedEmails: 0, - failedEmails: 0, - matchedDeliveries: 0, - averageDeliveryLatencyMs: 0, - maxDeliveryLatencyMs: 0, - deliveriesOverOtpLifetime: 0, - deliveryOverOtpLifetimeFraction: 0, - }) - }) - - it('purges events received before the retention cutoff', () => { - record('old-event', 'old-email', 'email.sent', baseTime) - - expect(db.cleanupResendEmailEventsBefore(Date.now() + 1)).toBe(1) - expect(db.getResendEmailEvents('old-email')).toHaveLength(0) - }) -}) diff --git a/packages/shared/src/db.ts b/packages/shared/src/db.ts index e48abe77..c387880c 100644 --- a/packages/shared/src/db.ts +++ b/packages/shared/src/db.ts @@ -2,7 +2,6 @@ import Database from 'better-sqlite3' import * as path from 'node:path' import * as fs from 'node:fs' import type { HandleMode } from './handle.js' -import { OTP_LIFETIME_SECONDS } from './types.js' export interface VerificationTokenRow { tokenHash: string @@ -43,37 +42,6 @@ export interface AuthFlowRow { expiresAt: number } -export type ResendEmailEventType = - | 'email.sent' - | 'email.delivered' - | 'email.delivery_delayed' - | 'email.bounced' - | 'email.failed' - -export interface ResendEmailEvent { - svixId: string - emailId: string - eventType: ResendEmailEventType - eventCreatedAt: number - recipients: string[] - receivedAt: number -} - -export interface ResendDeliveryMetrics { - sentEmails: number - deliveredEmails: number - deliveryDelayedEvents: number - bouncedEmails: number - failedEmails: number - matchedDeliveries: number - averageDeliveryLatencyMs: number - maxDeliveryLatencyMs: number - deliveriesOverOtpLifetime: number - deliveryOverOtpLifetimeFraction: number -} - -const OTP_LIFETIME_MS = OTP_LIFETIME_SECONDS * 1000 - export class EpdsDb { private db: Database.Database @@ -219,24 +187,6 @@ export class EpdsDb { // changed to a no-op since the table is harmless to keep and dropping // it prevents rollback. The table is no longer used by current code. () => {}, - - // v10: Persist signed Resend delivery webhooks for latency analysis. - () => { - this.db.exec(` - CREATE TABLE IF NOT EXISTS resend_email_event ( - svix_id TEXT PRIMARY KEY, - email_id TEXT NOT NULL, - event_type TEXT NOT NULL, - event_created_at INTEGER NOT NULL, - recipients_json TEXT NOT NULL, - received_at INTEGER NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_ree_email_time - ON resend_email_event(email_id, event_created_at); - CREATE INDEX IF NOT EXISTS idx_ree_type_time - ON resend_email_event(event_type, event_created_at); - `) - }, ] for (let i = currentVersion; i < migrations.length; i++) { @@ -515,122 +465,6 @@ export class EpdsDb { return result.changes } - // ── Resend Email Delivery Events ── - - /** Persist one verified webhook. Returns false when Svix retries an event. */ - recordResendEmailEvent(event: Omit): boolean { - const result = this.db - .prepare( - `INSERT OR IGNORE INTO resend_email_event - (svix_id, email_id, event_type, event_created_at, recipients_json, received_at) - VALUES (?, ?, ?, ?, ?, ?)`, - ) - .run( - event.svixId, - event.emailId, - event.eventType, - event.eventCreatedAt, - JSON.stringify(event.recipients), - Date.now(), - ) - return result.changes > 0 - } - - /** Return an email's events in provider timestamp order, not arrival order. */ - getResendEmailEvents(emailId: string): ResendEmailEvent[] { - const rows = this.db - .prepare( - `SELECT svix_id as svixId, email_id as emailId, event_type as eventType, - event_created_at as eventCreatedAt, recipients_json as recipientsJson, - received_at as receivedAt - FROM resend_email_event - WHERE email_id = ? - ORDER BY event_created_at ASC, svix_id ASC`, - ) - .all(emailId) as Array< - Omit & { recipientsJson: string } - > - - return rows.map(({ recipientsJson, ...row }) => { - try { - const recipients: unknown = JSON.parse(recipientsJson) - if ( - !Array.isArray(recipients) || - !recipients.every((recipient) => typeof recipient === 'string') - ) { - throw new TypeError('recipients_json is not a string array') - } - return { ...row, recipients } - } catch (err) { - throw new Error('Invalid recipients_json in resend_email_event', { - cause: err, - }) - } - }) - } - - cleanupResendEmailEventsBefore(cutoffMs: number): number { - const result = this.db - .prepare('DELETE FROM resend_email_event WHERE received_at < ?') - .run(cutoffMs) - return result.changes - } - - getResendDeliveryMetrics(): ResendDeliveryMetrics { - const counts = this.db - .prepare( - `SELECT - COUNT(DISTINCT CASE WHEN event_type = 'email.sent' THEN email_id END) as sentEmails, - COUNT(DISTINCT CASE WHEN event_type = 'email.delivered' THEN email_id END) as deliveredEmails, - COALESCE(SUM(CASE WHEN event_type = 'email.delivery_delayed' THEN 1 ELSE 0 END), 0) as deliveryDelayedEvents, - COUNT(DISTINCT CASE WHEN event_type = 'email.bounced' THEN email_id END) as bouncedEmails, - COUNT(DISTINCT CASE WHEN event_type = 'email.failed' THEN email_id END) as failedEmails - FROM resend_email_event`, - ) - .get() as Pick< - ResendDeliveryMetrics, - | 'sentEmails' - | 'deliveredEmails' - | 'deliveryDelayedEvents' - | 'bouncedEmails' - | 'failedEmails' - > - - const latency = this.db - .prepare( - `WITH event_times AS ( - SELECT email_id, - MIN(CASE WHEN event_type = 'email.sent' THEN event_created_at END) AS sent_at, - MIN(CASE WHEN event_type = 'email.delivered' THEN event_created_at END) AS delivered_at - FROM resend_email_event - GROUP BY email_id - ) - SELECT - COUNT(*) as matchedDeliveries, - COALESCE(AVG(delivered_at - sent_at), 0) as averageDeliveryLatencyMs, - COALESCE(MAX(delivered_at - sent_at), 0) as maxDeliveryLatencyMs, - COALESCE(SUM(CASE WHEN delivered_at - sent_at > ? THEN 1 ELSE 0 END), 0) as deliveriesOverOtpLifetime - FROM event_times - WHERE sent_at IS NOT NULL AND delivered_at IS NOT NULL AND delivered_at >= sent_at`, - ) - .get(OTP_LIFETIME_MS) as Pick< - ResendDeliveryMetrics, - | 'matchedDeliveries' - | 'averageDeliveryLatencyMs' - | 'maxDeliveryLatencyMs' - | 'deliveriesOverOtpLifetime' - > - - return { - ...counts, - ...latency, - deliveryOverOtpLifetimeFraction: - latency.matchedDeliveries === 0 - ? 0 - : latency.deliveriesOverOtpLifetime / latency.matchedDeliveries, - } - } - // ── Metrics ── getMetrics(): { diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 5166678c..f3a0e973 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -4,9 +4,6 @@ export type { BackupEmailRow, EmailRateLimitRow, AuthFlowRow, - ResendEmailEventType, - ResendEmailEvent, - ResendDeliveryMetrics, } from './db.js' export { generateVerificationToken, @@ -25,7 +22,7 @@ export type { AuthConfig, RateLimitConfig, } from './types.js' -export { DEFAULT_RATE_LIMITS, OTP_LIFETIME_SECONDS } from './types.js' +export { DEFAULT_RATE_LIMITS } from './types.js' export { createLogger } from './logger.js' export { escapeHtml, diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 1999692a..a1b502bb 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -32,9 +32,6 @@ export interface RateLimitConfig { globalPerMinute: number } -/** Better Auth email-code lifetime and delivery-latency threshold. */ -export const OTP_LIFETIME_SECONDS = 10 * 60 - export const DEFAULT_RATE_LIMITS: RateLimitConfig = { emailPer15Min: 3, emailPerHour: 5, diff --git a/vitest.config.ts b/vitest.config.ts index 9e0bd0f7..c88ef09c 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -23,7 +23,7 @@ export default defineConfig({ // Ratchet thresholds — update these whenever coverage increases. // See AGENTS.md for the ratcheting policy. thresholds: { - statements: 58, + statements: 57, branches: 57, functions: 71, lines: 56, From c78b4e52db82ddb8849c32c5a1828a39ebff5c7f Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 15 Jul 2026 11:18:03 +0100 Subject: [PATCH 11/24] test(shared): surface database cleanup failures Ignore only missing temporary database directories during teardown instead of swallowing every filesystem error. Co-authored-by: OpenAI Codex --- packages/shared/src/__tests__/db-extended.test.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/shared/src/__tests__/db-extended.test.ts b/packages/shared/src/__tests__/db-extended.test.ts index 7de43e27..4084444f 100644 --- a/packages/shared/src/__tests__/db-extended.test.ts +++ b/packages/shared/src/__tests__/db-extended.test.ts @@ -261,8 +261,9 @@ describe('Database initialization', () => { fs.rmSync(path.dirname(path.dirname(nestedPath)), { recursive: true, }) - // eslint-disable-next-line no-empty - } catch {} + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err + } }) it('runs migrations idempotently on second open', () => { From def61ec28982e9661cfb17b3f61884e4f9cf0ba6 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 15 Jul 2026 11:22:36 +0100 Subject: [PATCH 12/24] fix(auth): include subjects in Resend event logs Include the verified email subject in structured delivery event logs so operators can distinguish messages during incident analysis. Refs #185 Co-authored-by: OpenAI Codex --- docs/configuration.md | 4 ++-- packages/auth-service/src/__tests__/resend-webhook.test.ts | 1 + packages/auth-service/src/routes/resend-webhook.ts | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index a8fb0385..8288a3a7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -220,8 +220,8 @@ To opt into delivery-event logging for email sent through Resend: email emits webhooks, so verify this before relying on the metrics. The receiver verifies the Svix signature against the raw request body and logs -`svixId`, `eventType`, `eventCreatedAt`, `emailId`, and `recipients` as -structured fields. Normal events use `info`; `email.delivery_delayed` uses +`svixId`, `eventType`, `eventCreatedAt`, `emailId`, `recipients`, and `subject` +as structured fields. Normal events use `info`; `email.delivery_delayed` uses `warn`. No webhook data is persisted by ePDS. The provider-specific rate limit permits 300 webhook requests per minute per source IP. diff --git a/packages/auth-service/src/__tests__/resend-webhook.test.ts b/packages/auth-service/src/__tests__/resend-webhook.test.ts index c7298dda..7056d674 100644 --- a/packages/auth-service/src/__tests__/resend-webhook.test.ts +++ b/packages/auth-service/src/__tests__/resend-webhook.test.ts @@ -105,6 +105,7 @@ describe('Resend webhook receiver', () => { eventCreatedAt: '2026-07-14T10:00:00.000Z', emailId: 'resend-email-123', recipients: ['person@example.com'], + subject: 'Your sign-in code', }, 'Received Resend email delivery event', ) diff --git a/packages/auth-service/src/routes/resend-webhook.ts b/packages/auth-service/src/routes/resend-webhook.ts index 0a535f13..9d65c26d 100644 --- a/packages/auth-service/src/routes/resend-webhook.ts +++ b/packages/auth-service/src/routes/resend-webhook.ts @@ -120,6 +120,7 @@ function logResendEvent(event: ResendEvent, svixId: string): void { eventCreatedAt: event.created_at, emailId: event.data.email_id, recipients: event.data.to, + subject: event.data.subject, } const message = 'Received Resend email delivery event' if (event.type === 'email.delivery_delayed') { From fb49dae8a37c97ca63ed90de47c5c7ff2d415072 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 15 Jul 2026 11:24:57 +0100 Subject: [PATCH 13/24] fix(auth): configure trusted proxies before middleware Set Express trust-proxy configuration immediately after app creation so webhook rate-limit request IP handling is unambiguous. Co-authored-by: OpenAI Codex --- packages/auth-service/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/auth-service/src/index.ts b/packages/auth-service/src/index.ts index 77725f86..2dc9294d 100644 --- a/packages/auth-service/src/index.ts +++ b/packages/auth-service/src/index.ts @@ -37,6 +37,7 @@ export function createAuthService(config: AuthServiceConfig): { } { const ctx = new AuthServiceContext(config) const app = express() + app.set('trust proxy', 1) // Mount better-auth BEFORE express.json() so it can parse its own request bodies. // All better-auth endpoints live under /api/auth/*. @@ -64,7 +65,6 @@ export function createAuthService(config: AuthServiceConfig): { } // Middleware - app.set('trust proxy', 1) app.use(express.urlencoded({ extended: true })) app.use(express.json()) app.use(cookieParser()) From c2055a8872c885ff962060e1676f722a1028d67d Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 15 Jul 2026 11:27:03 +0100 Subject: [PATCH 14/24] docs(auth): clarify Resend is an optional provider Introduce Resend as a third-party email service in the release summary and state that ePDS continues to support other email providers. Co-authored-by: OpenAI Codex --- .changeset/resend-delivery-webhooks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/resend-delivery-webhooks.md b/.changeset/resend-delivery-webhooks.md index 3794e223..0dd4fded 100644 --- a/.changeset/resend-delivery-webhooks.md +++ b/.changeset/resend-delivery-webhooks.md @@ -2,7 +2,7 @@ 'ePDS': minor --- -Optional delivery event logs for operators who send email through Resend. +Optional delivery-event logging for operators who choose the third-party Resend service to send email; ePDS continues to support other email providers. **Affects:** Operators From 54f2d0b329d744c09e7a22628f16889311933354 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 15 Jul 2026 11:28:05 +0100 Subject: [PATCH 15/24] style(auth): order Resend webhook imports Follow the auth-service import convention by placing external dependencies before the internal shared package. Co-authored-by: OpenAI Codex --- packages/auth-service/src/routes/resend-webhook.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/auth-service/src/routes/resend-webhook.ts b/packages/auth-service/src/routes/resend-webhook.ts index 9d65c26d..129d83f5 100644 --- a/packages/auth-service/src/routes/resend-webhook.ts +++ b/packages/auth-service/src/routes/resend-webhook.ts @@ -7,9 +7,9 @@ * retry the same `svix-id` and may deliver an email's events out of order, so * log consumers must deduplicate and order events when calculating latency. */ -import { createLogger } from '@certified-app/shared' import express, { Router } from 'express' import { Webhook } from 'svix' +import { createLogger } from '@certified-app/shared' const logger = createLogger('auth:resend-webhook') From 58a1e1234cc82611adc148331d543848d8eafa51 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Wed, 15 Jul 2026 11:34:07 +0100 Subject: [PATCH 16/24] refactor(auth): normalize email delivery logs Map Resend webhook payloads into provider-neutral delivery fields so future provider adapters can emit the same log schema. Centralize the Resend webhook path while leaving provider-specific verification in place. Refs #185 Co-authored-by: OpenAI Codex --- docs/configuration.md | 26 ++++++----- .../src/__tests__/resend-webhook.test.ts | 22 ++++----- packages/auth-service/src/index.ts | 7 ++- .../auth-service/src/routes/resend-webhook.ts | 45 +++++++++++++------ 4 files changed, 63 insertions(+), 37 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 8288a3a7..d18b9b57 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -217,18 +217,20 @@ To opt into delivery-event logging for email sent through Resend: 4. Send a test sign-in code through the production SMTP configuration and confirm that both `email.sent` and `email.delivered` reach the receiver. Resend's documentation does not explicitly guarantee that SMTP-submitted - email emits webhooks, so verify this before relying on the metrics. - -The receiver verifies the Svix signature against the raw request body and logs -`svixId`, `eventType`, `eventCreatedAt`, `emailId`, `recipients`, and `subject` -as structured fields. Normal events use `info`; `email.delivery_delayed` uses -`warn`. No webhook data is persisted by ePDS. The provider-specific rate limit -permits 300 webhook requests per minute per source IP. - -Resend retries carry the same `svixId`, and events may arrive out of order. Log -analysis should deduplicate by `svixId`, order each `emailId` by -`eventCreatedAt`, and subtract `email.sent` from `email.delivered` to calculate -delivery latency. + email emits webhooks, so verify this before relying on the logs. + +The receiver verifies the Svix signature against the raw request body and emits +a provider-neutral log schema: `provider`, `eventId`, `eventType`, `occurredAt`, +`messageId`, `recipients`, and `subject`. Resend event types are normalized to +`sent`, `delivered`, `delayed`, `bounced`, or `failed`. Normal events use `info`; +`delayed` uses `warn`. No webhook data is persisted by ePDS. The +provider-specific rate limit permits 300 webhook requests per minute per source +IP. + +Resend retries carry the same source event ID, logged as `eventId`, and events +may arrive out of order. Log analysis should deduplicate by `eventId`, order +each `messageId` by `occurredAt`, and subtract `sent` from `delivered` to +calculate delivery latency. ### Database diff --git a/packages/auth-service/src/__tests__/resend-webhook.test.ts b/packages/auth-service/src/__tests__/resend-webhook.test.ts index 7056d674..3153f067 100644 --- a/packages/auth-service/src/__tests__/resend-webhook.test.ts +++ b/packages/auth-service/src/__tests__/resend-webhook.test.ts @@ -100,14 +100,15 @@ describe('Resend webhook receiver', () => { expect(result).toEqual({ status: 200, json: { received: true } }) expect(logInfo).toHaveBeenCalledWith( { - svixId: 'msg_test_123', - eventType: 'email.delivered', - eventCreatedAt: '2026-07-14T10:00:00.000Z', - emailId: 'resend-email-123', + provider: 'resend', + eventId: 'msg_test_123', + eventType: 'delivered', + occurredAt: '2026-07-14T10:00:00.000Z', + messageId: 'resend-email-123', recipients: ['person@example.com'], subject: 'Your sign-in code', }, - 'Received Resend email delivery event', + 'Received email delivery event', ) }) @@ -116,7 +117,7 @@ describe('Resend webhook receiver', () => { await postWebhook(makeEvent(), { svixId: 'msg_retry' }) expect(logInfo).toHaveBeenCalledTimes(2) - expect(logInfo.mock.calls.map(([fields]) => fields.svixId)).toEqual([ + expect(logInfo.mock.calls.map(([fields]) => fields.eventId)).toEqual([ 'msg_retry', 'msg_retry', ]) @@ -127,11 +128,12 @@ describe('Resend webhook receiver', () => { expect(logWarn).toHaveBeenCalledWith( expect.objectContaining({ - svixId: 'msg_test_123', - eventType: 'email.delivery_delayed', - emailId: 'resend-email-123', + provider: 'resend', + eventId: 'msg_test_123', + eventType: 'delayed', + messageId: 'resend-email-123', }), - 'Received Resend email delivery event', + 'Received email delivery event', ) }) diff --git a/packages/auth-service/src/index.ts b/packages/auth-service/src/index.ts index 2dc9294d..60f66002 100644 --- a/packages/auth-service/src/index.ts +++ b/packages/auth-service/src/index.ts @@ -18,7 +18,10 @@ import { createChooseHandleRouter } from './routes/choose-handle.js' import { createHeartbeatRouter } from './routes/heartbeat.js' import { createPreviewRouter } from './routes/preview.js' import { createPreviewEmailsRouter } from './routes/preview-emails.js' -import { createResendWebhookRouter } from './routes/resend-webhook.js' +import { + createResendWebhookRouter, + RESEND_WEBHOOK_PATH, +} from './routes/resend-webhook.js' import { createRootRouter } from './routes/root.js' import { createTestHooksRouter } from './routes/test-hooks.js' import { resolveAuthPort } from './lib/resolve-port.js' @@ -54,7 +57,7 @@ export function createAuthService(config: AuthServiceConfig): { // browser CSRF protection; authenticity comes from the Svix signature. if (config.resendWebhookSecret) { app.use( - '/webhooks/resend', + RESEND_WEBHOOK_PATH, requestRateLimit({ windowMs: 60_000, maxRequests: 300, diff --git a/packages/auth-service/src/routes/resend-webhook.ts b/packages/auth-service/src/routes/resend-webhook.ts index 129d83f5..ff8397fe 100644 --- a/packages/auth-service/src/routes/resend-webhook.ts +++ b/packages/auth-service/src/routes/resend-webhook.ts @@ -4,14 +4,17 @@ * The route consumes the untouched request body, verifies Resend's Svix * signature before inspecting the payload, accepts only delivery events used * by ePDS, and emits one structured log entry per delivery attempt. Resend may - * retry the same `svix-id` and may deliver an email's events out of order, so - * log consumers must deduplicate and order events when calculating latency. + * retry the same source ID (logged as `eventId`) and may deliver an email's + * events out of order, so log consumers must deduplicate and order events when + * calculating latency. */ import express, { Router } from 'express' import { Webhook } from 'svix' import { createLogger } from '@certified-app/shared' -const logger = createLogger('auth:resend-webhook') +const logger = createLogger('auth:email-webhook') + +export const RESEND_WEBHOOK_PATH = '/webhooks/resend' const RESEND_EVENT_TYPES = [ 'email.sent', @@ -22,6 +25,21 @@ const RESEND_EVENT_TYPES = [ ] as const type ResendEventType = (typeof RESEND_EVENT_TYPES)[number] +type EmailDeliveryEventType = + | 'sent' + | 'delivered' + | 'delayed' + | 'bounced' + | 'failed' + +const NORMALIZED_EVENT_TYPES: Record = + { + 'email.sent': 'sent', + 'email.delivered': 'delivered', + 'email.delivery_delayed': 'delayed', + 'email.bounced': 'bounced', + 'email.failed': 'failed', + } interface ResendEvent { type: ResendEventType @@ -97,16 +115,16 @@ function verifyResendWebhook( payload = verifier.verify(req.body, headers) } catch (err) { logger.warn( - { err, svixId: headers['svix-id'] }, - 'Rejected Resend webhook with invalid signature', + { err, provider: 'resend', eventId: headers['svix-id'] }, + 'Rejected email webhook with invalid signature', ) return { ok: false, error: 'Invalid webhook signature' } } if (!isResendEvent(payload)) { logger.warn( - { svixId: headers['svix-id'] }, - 'Rejected invalid Resend webhook payload', + { provider: 'resend', eventId: headers['svix-id'] }, + 'Rejected invalid email webhook payload', ) return { ok: false, error: 'Invalid webhook payload' } } @@ -115,14 +133,15 @@ function verifyResendWebhook( function logResendEvent(event: ResendEvent, svixId: string): void { const fields = { - svixId, - eventType: event.type, - eventCreatedAt: event.created_at, - emailId: event.data.email_id, + provider: 'resend', + eventId: svixId, + eventType: NORMALIZED_EVENT_TYPES[event.type], + occurredAt: event.created_at, + messageId: event.data.email_id, recipients: event.data.to, subject: event.data.subject, } - const message = 'Received Resend email delivery event' + const message = 'Received email delivery event' if (event.type === 'email.delivery_delayed') { logger.warn(fields, message) } else { @@ -135,7 +154,7 @@ export function createResendWebhookRouter(webhookSecret: string): Router { const verifier = new Webhook(webhookSecret) router.post( - '/webhooks/resend', + RESEND_WEBHOOK_PATH, express.raw({ type: 'application/json', limit: '64kb' }), (req, res) => { const result = verifyResendWebhook(req, verifier) From fc369fec2988e3beba16b29ab344647ddf536df8 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Thu, 16 Jul 2026 20:42:24 +0100 Subject: [PATCH 17/24] fix(auth): filter account-wide Resend webhooks Parse each webhook sender and log only events whose address exactly matches the instance SMTP_FROM. Unrelated account-wide events are acknowledged without exposing their payload, allowing multiple ePDS domains to share one Resend account when they use distinct sender addresses. Refs #185 Co-authored-by: OpenAI Codex --- .changeset/resend-delivery-webhooks.md | 2 +- .env.example | 1 + docs/configuration.md | 7 ++++ packages/auth-service/.env.example | 1 + .../src/__tests__/resend-webhook.test.ts | 26 ++++++++++++-- packages/auth-service/src/index.ts | 4 ++- .../auth-service/src/routes/resend-webhook.ts | 35 +++++++++++++++++-- vitest.config.ts | 2 +- 8 files changed, 70 insertions(+), 8 deletions(-) diff --git a/.changeset/resend-delivery-webhooks.md b/.changeset/resend-delivery-webhooks.md index 0dd4fded..b7a6c9e4 100644 --- a/.changeset/resend-delivery-webhooks.md +++ b/.changeset/resend-delivery-webhooks.md @@ -6,4 +6,4 @@ Optional delivery-event logging for operators who choose the third-party Resend **Affects:** Operators -**Operators:** ePDS remains compatible with any SMTP provider and does not require Resend. Operators who already use Resend can opt in by registering `https:///webhooks/resend` for the documented email events and setting `RESEND_WEBHOOK_SECRET`; the route then verifies each webhook and emits structured delivery logs without persisting webhook data. +**Operators:** ePDS remains compatible with any SMTP provider and does not require Resend. Operators who already use Resend can opt in by registering `https:///webhooks/resend` for the documented email events and setting `RESEND_WEBHOOK_SECRET`; the route verifies each webhook, logs only events whose sender exactly matches `SMTP_FROM`, and does not persist webhook data. diff --git a/.env.example b/.env.example index 58eb9d0e..133705f0 100644 --- a/.env.example +++ b/.env.example @@ -218,6 +218,7 @@ SMTP_FROM_NAME=ePDS # any SMTP provider and does not require Resend. To opt into Resend delivery # event logs, configure https://auth.pds.example/webhooks/resend and set: # RESEND_WEBHOOK_SECRET=whsec_... +# Account-wide Resend events are filtered to the exact SMTP_FROM address. DB_LOCATION=/data/epds.sqlite diff --git a/docs/configuration.md b/docs/configuration.md index d18b9b57..0112f775 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -219,6 +219,13 @@ To opt into delivery-event logging for email sent through Resend: Resend's documentation does not explicitly guarantee that SMTP-submitted email emits webhooks, so verify this before relying on the logs. +Resend webhooks cover the whole Resend account rather than one sending domain. +The receiver parses each event's `data.from` address and logs it only when it +exactly matches this ePDS instance's `SMTP_FROM`; events for other senders are +acknowledged without logging their payload. ePDS instances sharing a Resend +account must therefore use distinct `SMTP_FROM` addresses. Events cannot be +separated when multiple instances use the same sender address. + The receiver verifies the Svix signature against the raw request body and emits a provider-neutral log schema: `provider`, `eventId`, `eventType`, `occurredAt`, `messageId`, `recipients`, and `subject`. Resend event types are normalized to diff --git a/packages/auth-service/.env.example b/packages/auth-service/.env.example index b6b29b90..13735507 100644 --- a/packages/auth-service/.env.example +++ b/packages/auth-service/.env.example @@ -143,6 +143,7 @@ SMTP_FROM_NAME=ePDS # https:///webhooks/resend for email.sent, email.delivered, # email.delivery_delayed, email.bounced, and email.failed, then set: # RESEND_WEBHOOK_SECRET=whsec_... +# Account-wide Resend events are filtered to the exact SMTP_FROM address. # Database path (separate from PDS account.sqlite) DB_LOCATION=/data/epds.sqlite diff --git a/packages/auth-service/src/__tests__/resend-webhook.test.ts b/packages/auth-service/src/__tests__/resend-webhook.test.ts index 3153f067..6cd1caff 100644 --- a/packages/auth-service/src/__tests__/resend-webhook.test.ts +++ b/packages/auth-service/src/__tests__/resend-webhook.test.ts @@ -2,13 +2,14 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import express from 'express' import { Webhook } from 'svix' -const { logInfo, logWarn } = vi.hoisted(() => ({ +const { logDebug, logInfo, logWarn } = vi.hoisted(() => ({ + logDebug: vi.fn(), logInfo: vi.fn(), logWarn: vi.fn(), })) vi.mock('@certified-app/shared', () => ({ - createLogger: () => ({ info: logInfo, warn: logWarn }), + createLogger: () => ({ debug: logDebug, info: logInfo, warn: logWarn }), })) import { createResendWebhookRouter } from '../routes/resend-webhook.js' @@ -18,6 +19,7 @@ const WEBHOOK_SECRET = `whsec_${Buffer.from('test-webhook-secret').toString( )}` beforeEach(() => { + logDebug.mockClear() logInfo.mockClear() logWarn.mockClear() }) @@ -44,7 +46,7 @@ async function postWebhook( } = {}, ): Promise<{ status: number; json: Record }> { const app = express() - app.use(createResendWebhookRouter(WEBHOOK_SECRET)) + app.use(createResendWebhookRouter(WEBHOOK_SECRET, 'login@example.org')) const server = app.listen(0) try { @@ -123,6 +125,24 @@ describe('Resend webhook receiver', () => { ]) }) + it('acknowledges account-wide events for other senders without logging them', async () => { + const event = makeEvent() + const data = event.data as Record + data.from = 'Another service ' + + const result = await postWebhook(event, { svixId: 'msg_other_sender' }) + + expect(result).toEqual({ + status: 200, + json: { received: true, ignored: true }, + }) + expect(logInfo).not.toHaveBeenCalled() + expect(logDebug).toHaveBeenCalledWith( + { provider: 'resend', eventId: 'msg_other_sender' }, + 'Ignored email webhook for another sender', + ) + }) + it('logs delivery delays at warning level', async () => { await postWebhook(makeEvent('email.delivery_delayed')) diff --git a/packages/auth-service/src/index.ts b/packages/auth-service/src/index.ts index 60f66002..7262ecf4 100644 --- a/packages/auth-service/src/index.ts +++ b/packages/auth-service/src/index.ts @@ -64,7 +64,9 @@ export function createAuthService(config: AuthServiceConfig): { keyPrefix: 'resend-webhook', }), ) - app.use(createResendWebhookRouter(config.resendWebhookSecret)) + app.use( + createResendWebhookRouter(config.resendWebhookSecret, config.email.from), + ) } // Middleware diff --git a/packages/auth-service/src/routes/resend-webhook.ts b/packages/auth-service/src/routes/resend-webhook.ts index ff8397fe..46f717ff 100644 --- a/packages/auth-service/src/routes/resend-webhook.ts +++ b/packages/auth-service/src/routes/resend-webhook.ts @@ -9,6 +9,7 @@ * calculating latency. */ import express, { Router } from 'express' +import addressparser from 'nodemailer/lib/addressparser/index.js' import { Webhook } from 'svix' import { createLogger } from '@certified-app/shared' @@ -56,6 +57,18 @@ function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } +function parseSingleEmailAddress(value: string): string | null { + try { + const addresses = addressparser(value, { flatten: true }) + if (addresses.length !== 1) return null + const address = addresses[0]?.address.trim().toLowerCase() + return address || null + } catch (err) { + logger.warn({ err }, 'Failed to parse email sender address') + return null + } +} + function isResendEvent(value: unknown): value is ResendEvent { if (!isRecord(value) || !isRecord(value.data)) return false @@ -149,9 +162,16 @@ function logResendEvent(event: ResendEvent, svixId: string): void { } } -export function createResendWebhookRouter(webhookSecret: string): Router { +export function createResendWebhookRouter( + webhookSecret: string, + expectedFrom: string, +): Router { const router = Router() const verifier = new Webhook(webhookSecret) + const expectedFromAddress = parseSingleEmailAddress(expectedFrom) + if (!expectedFromAddress) { + throw new Error('RESEND_WEBHOOK_SECRET requires a valid SMTP_FROM address') + } router.post( RESEND_WEBHOOK_PATH, @@ -163,7 +183,18 @@ export function createResendWebhookRouter(webhookSecret: string): Router { return } - logResendEvent(result.event, result.headers['svix-id']) + const eventId = result.headers['svix-id'] + const eventFromAddress = parseSingleEmailAddress(result.event.data.from) + if (eventFromAddress !== expectedFromAddress) { + logger.debug( + { provider: 'resend', eventId }, + 'Ignored email webhook for another sender', + ) + res.status(200).json({ received: true, ignored: true }) + return + } + + logResendEvent(result.event, eventId) res.status(200).json({ received: true }) }, ) diff --git a/vitest.config.ts b/vitest.config.ts index c88ef09c..9e0bd0f7 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -23,7 +23,7 @@ export default defineConfig({ // Ratchet thresholds — update these whenever coverage increases. // See AGENTS.md for the ratcheting policy. thresholds: { - statements: 57, + statements: 58, branches: 57, functions: 71, lines: 56, From 6bfa3db594be66994ac94f7ff7c0108c4f13d84f Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Mon, 20 Jul 2026 13:51:26 +0100 Subject: [PATCH 18/24] fix(auth): redact OTPs from delivery logs Resend models recipients as an array, which made Railway display the single ePDS recipient as recipients[0]. Webhook subjects also include the sign-in code and would expose it in logs.\n\nNormalize the recipient to the existing email field and redact only OTP tokens matching the configured length and charset while preserving the rest of the subject.\n\nFollow-up to #185.\n\nCo-authored-by: OpenAI --- docs/configuration.md | 6 +- .../src/__tests__/resend-webhook.test.ts | 67 +++++++++++++++++-- packages/auth-service/src/index.ts | 7 +- .../auth-service/src/routes/resend-webhook.ts | 61 +++++++++++++++-- 4 files changed, 129 insertions(+), 12 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 0112f775..9b1a891c 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -228,8 +228,10 @@ separated when multiple instances use the same sender address. The receiver verifies the Svix signature against the raw request body and emits a provider-neutral log schema: `provider`, `eventId`, `eventType`, `occurredAt`, -`messageId`, `recipients`, and `subject`. Resend event types are normalized to -`sent`, `delivered`, `delayed`, `bounced`, or `failed`. Normal events use `info`; +`messageId`, `email`, and `subject`. Any sign-in code in `subject` is replaced +with `[REDACTED]` to prevent it from reaching logs while preserving the rest of +the subject. Resend event types are normalized to `sent`, `delivered`, `delayed`, +`bounced`, or `failed`. Normal events use `info`; `delayed` uses `warn`. No webhook data is persisted by ePDS. The provider-specific rate limit permits 300 webhook requests per minute per source IP. diff --git a/packages/auth-service/src/__tests__/resend-webhook.test.ts b/packages/auth-service/src/__tests__/resend-webhook.test.ts index 6cd1caff..0e5caf03 100644 --- a/packages/auth-service/src/__tests__/resend-webhook.test.ts +++ b/packages/auth-service/src/__tests__/resend-webhook.test.ts @@ -32,7 +32,7 @@ function makeEvent(type = 'email.sent'): Record { email_id: 'resend-email-123', to: ['person@example.com'], from: 'ePDS ', - subject: 'Your sign-in code', + subject: '123456 — Your sign-in code', }, } } @@ -43,10 +43,19 @@ async function postWebhook( svixId?: string validSignature?: boolean includeHeaders?: boolean + otpLength?: number + otpCharset?: 'numeric' | 'alphanumeric' } = {}, ): Promise<{ status: number; json: Record }> { const app = express() - app.use(createResendWebhookRouter(WEBHOOK_SECRET, 'login@example.org')) + app.use( + createResendWebhookRouter( + WEBHOOK_SECRET, + 'login@example.org', + options.otpLength ?? 6, + options.otpCharset ?? 'numeric', + ), + ) const server = app.listen(0) try { @@ -107,13 +116,51 @@ describe('Resend webhook receiver', () => { eventType: 'delivered', occurredAt: '2026-07-14T10:00:00.000Z', messageId: 'resend-email-123', - recipients: ['person@example.com'], - subject: 'Your sign-in code', + email: 'person@example.com', + subject: '[REDACTED] — Your sign-in code', }, 'Received email delivery event', ) }) + it.each([ + { + label: 'a grouped numeric code', + subject: '1234 5678 — Your sign-in code', + otpLength: 8, + otpCharset: 'numeric' as const, + expected: '[REDACTED] — Your sign-in code', + }, + { + label: 'an alphanumeric code', + subject: 'AB12CD — Your sign-in code', + otpLength: 6, + otpCharset: 'alphanumeric' as const, + expected: '[REDACTED] — Your sign-in code', + }, + { + label: 'a subject without a code', + subject: 'Verify your backup email', + otpLength: 6, + otpCharset: 'numeric' as const, + expected: 'Verify your backup email', + }, + ])( + 'sanitizes $label without hiding the rest of the subject', + async (test) => { + const event = makeEvent() + const data = event.data as Record + data.subject = test.subject + + await postWebhook(event, { + otpLength: test.otpLength, + otpCharset: test.otpCharset, + }) + + expect(logInfo.mock.calls[0]?.[0].subject).toBe(test.expected) + }, + ) + it('logs retries with the same Svix ID for downstream deduplication', async () => { await postWebhook(makeEvent(), { svixId: 'msg_retry' }) await postWebhook(makeEvent(), { svixId: 'msg_retry' }) @@ -182,4 +229,16 @@ describe('Resend webhook receiver', () => { expect(result.json).toEqual({ error: 'Invalid webhook payload' }) expect(logInfo).not.toHaveBeenCalled() }) + + it('rejects a delivery event without a recipient email', async () => { + const event = makeEvent() + const data = event.data as Record + data.to = [] + + const result = await postWebhook(event) + + expect(result.status).toBe(400) + expect(result.json).toEqual({ error: 'Invalid webhook payload' }) + expect(logInfo).not.toHaveBeenCalled() + }) }) diff --git a/packages/auth-service/src/index.ts b/packages/auth-service/src/index.ts index 7262ecf4..12a3c6cf 100644 --- a/packages/auth-service/src/index.ts +++ b/packages/auth-service/src/index.ts @@ -65,7 +65,12 @@ export function createAuthService(config: AuthServiceConfig): { }), ) app.use( - createResendWebhookRouter(config.resendWebhookSecret, config.email.from), + createResendWebhookRouter( + config.resendWebhookSecret, + config.email.from, + config.otpLength, + config.otpCharset, + ), ) } diff --git a/packages/auth-service/src/routes/resend-webhook.ts b/packages/auth-service/src/routes/resend-webhook.ts index 46f717ff..789b5d4f 100644 --- a/packages/auth-service/src/routes/resend-webhook.ts +++ b/packages/auth-service/src/routes/resend-webhook.ts @@ -32,6 +32,7 @@ type EmailDeliveryEventType = | 'delayed' | 'bounced' | 'failed' +type OtpCharset = 'numeric' | 'alphanumeric' const NORMALIZED_EVENT_TYPES: Record = { @@ -47,7 +48,7 @@ interface ResendEvent { created_at: string data: { email_id: string - to: string[] + to: [string, ...string[]] from: string subject: string } @@ -80,6 +81,7 @@ function isResendEvent(value: unknown): value is ResendEvent { Number.isFinite(Date.parse(value.created_at)) && typeof data.email_id === 'string' && Array.isArray(data.to) && + data.to.length > 0 && data.to.every((recipient) => typeof recipient === 'string') && typeof data.from === 'string' && typeof data.subject === 'string' @@ -144,15 +146,62 @@ function verifyResendWebhook( return { ok: true, headers, event: payload } } -function logResendEvent(event: ResendEvent, svixId: string): void { +const NUMERIC_OTP_CANDIDATE = + /(^|[^0-9])((?:[0-9]{3,4}(?: [0-9]{3,4}){1,3}|[0-9]{4,12}))(?![0-9])/g +const ALPHANUMERIC_OTP_CANDIDATE = + /(^|[^A-Za-z0-9])((?:[A-Z0-9]{3,4}(?: [A-Z0-9]{3,4}){1,3}|[A-Z0-9]{4,12}))(?![A-Za-z0-9])/g + +function otpGroupSize(length: number): number | null { + if (length < 8) return null + if (length % 4 === 0) return 4 + if (length % 3 === 0) return 3 + return null +} + +function isOtpCandidate(candidate: string, otpLength: number): boolean { + const raw = candidate.replaceAll(' ', '') + if (raw.length !== otpLength) return false + if (!candidate.includes(' ')) return true + + const groupSize = otpGroupSize(otpLength) + if (!groupSize) return false + const groups = candidate.split(' ') + return ( + groups.length === otpLength / groupSize && + groups.every((group) => group.length === groupSize) + ) +} + +function redactOtpFromSubject( + subject: string, + otpLength: number, + otpCharset: OtpCharset, +): string { + const pattern = + otpCharset === 'numeric' + ? NUMERIC_OTP_CANDIDATE + : ALPHANUMERIC_OTP_CANDIDATE + return subject.replace( + pattern, + (match: string, prefix: string, candidate: string) => + isOtpCandidate(candidate, otpLength) ? `${prefix}[REDACTED]` : match, + ) +} + +function logResendEvent( + event: ResendEvent, + svixId: string, + otpLength: number, + otpCharset: OtpCharset, +): void { const fields = { provider: 'resend', eventId: svixId, eventType: NORMALIZED_EVENT_TYPES[event.type], occurredAt: event.created_at, messageId: event.data.email_id, - recipients: event.data.to, - subject: event.data.subject, + email: event.data.to[0], + subject: redactOtpFromSubject(event.data.subject, otpLength, otpCharset), } const message = 'Received email delivery event' if (event.type === 'email.delivery_delayed') { @@ -165,6 +214,8 @@ function logResendEvent(event: ResendEvent, svixId: string): void { export function createResendWebhookRouter( webhookSecret: string, expectedFrom: string, + otpLength: number, + otpCharset: OtpCharset, ): Router { const router = Router() const verifier = new Webhook(webhookSecret) @@ -194,7 +245,7 @@ export function createResendWebhookRouter( return } - logResendEvent(result.event, eventId) + logResendEvent(result.event, eventId, otpLength, otpCharset) res.status(200).json({ received: true }) }, ) From fd50cf062b5113a3c24c8a1ef72c5e53aebd5c82 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Mon, 20 Jul 2026 13:58:01 +0100 Subject: [PATCH 19/24] style(auth): simplify OTP redaction regex Use concise digit character classes so SonarCloud accepts the OTP subject sanitizer added for #185.\n\nCo-authored-by: OpenAI --- packages/auth-service/src/routes/resend-webhook.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/auth-service/src/routes/resend-webhook.ts b/packages/auth-service/src/routes/resend-webhook.ts index 789b5d4f..a574527d 100644 --- a/packages/auth-service/src/routes/resend-webhook.ts +++ b/packages/auth-service/src/routes/resend-webhook.ts @@ -147,7 +147,7 @@ function verifyResendWebhook( } const NUMERIC_OTP_CANDIDATE = - /(^|[^0-9])((?:[0-9]{3,4}(?: [0-9]{3,4}){1,3}|[0-9]{4,12}))(?![0-9])/g + /(^|\D)((?:\d{3,4}(?: \d{3,4}){1,3}|\d{4,12}))(?!\d)/g const ALPHANUMERIC_OTP_CANDIDATE = /(^|[^A-Za-z0-9])((?:[A-Z0-9]{3,4}(?: [A-Z0-9]{3,4}){1,3}|[A-Z0-9]{4,12}))(?![A-Za-z0-9])/g From fa9e74026fbb18451b584c920e8b4d92bf57c77c Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Mon, 20 Jul 2026 13:58:01 +0100 Subject: [PATCH 20/24] chore(ci): document safe internal HTTP URLs SonarCloud reports intentional localhost, Docker, and Railway-private HTTP URLs as clear-text protocol vulnerabilities. Mark those exact lines with justified NOSONAR annotations because the traffic never crosses the public network.\n\nCo-authored-by: OpenAI --- scripts/setup.sh | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/scripts/setup.sh b/scripts/setup.sh index 3addbb32..e794f0e9 100755 --- a/scripts/setup.sh +++ b/scripts/setup.sh @@ -167,20 +167,20 @@ prompt_hostname() { set_env_var PDS_HOSTNAME "$pds_hostname" .env set_env_var PDS_PUBLIC_URL "$pds_public_url" .env set_env_var AUTH_HOSTNAME "$auth_hostname" .env - set_env_var EPDS_LINK_BASE_URL "${proto}://${auth_hostname}/auth/verify" .env + set_env_var EPDS_LINK_BASE_URL "${proto}://${auth_hostname}/auth/verify" .env # NOSONAR — HTTP is selected only for localhost development. # Set PDS_INTERNAL_URL for multi-service deployments (auth-service → pds-core). # Docker: http://core:3000; Railway: http://.railway.internal:3000 # Not needed for localhost (both services on same host). if [ "$pds_hostname" != "localhost" ] && [[ "$pds_hostname" != *.localhost ]]; then - set_env_var PDS_INTERNAL_URL "http://core:3000" .env - echo " Set PDS_INTERNAL_URL=http://core:3000" + set_env_var PDS_INTERNAL_URL "http://core:3000" .env # NOSONAR — Docker's private network does not need TLS. + echo " Set PDS_INTERNAL_URL=http://core:3000" # NOSONAR — Docker's private network does not need TLS. fi echo " Set PDS_HOSTNAME=${pds_hostname}" echo " Set PDS_PUBLIC_URL=${pds_public_url}" echo " Set AUTH_HOSTNAME=${auth_hostname}" - echo " Set EPDS_LINK_BASE_URL=${proto}://${auth_hostname}/auth/verify" + echo " Set EPDS_LINK_BASE_URL=${proto}://${auth_hostname}/auth/verify" # NOSONAR — HTTP is selected only for localhost development. } # Ask for SMTP credentials. Sets discrete vars in .env (for auth-service) and @@ -485,8 +485,8 @@ print_next_steps() { echo " grep -v '^\s*#' packages/demo/.env | grep -v '^\s*$'" echo "" echo " IMPORTANT: For Railway, change PDS_INTERNAL_URL in auth-service from" - echo " the Docker value (http://core:3000) to the Railway internal URL:" - echo " http://.railway.internal:3000" + echo " the Docker value (http://core:3000) to the Railway internal URL:" # NOSONAR — Docker's private network does not need TLS. + echo " http://.railway.internal:3000" # NOSONAR — Railway's private network does not need TLS. echo " The auth service will fail to start without a correct PDS_INTERNAL_URL." echo "" echo " IMPORTANT: EPDS_CLIENT_PRIVATE_JWK must be DIFFERENT per demo service." From 2193515970c56035fd026c3df2b3f44e403eb801 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Thu, 16 Jul 2026 21:21:24 +0100 Subject: [PATCH 21/24] feat(auth-service): log OTP send duration, messageId and SMTP response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #183. The per-send completion line previously carried only the recipient, so a slow-but-successful SMTP handoff was indistinguishable from a fast one and app logs could not be joined to Resend's per-message delivery events. Wrap every sendMail() in a timedSendMail() helper that measures the handoff, captures the result, and folds elapsedMs, messageId and smtpResponse into the existing success line — one enriched line per send across all four paths (client-branded, sign-in, welcome, backup). On failure it stamps elapsedMs onto the thrown error so the existing "failed to send OTP email" line carries the duration without adding a second failure line. The explicit-SMTP-timeouts part of #183 is intentionally deferred: it would change send behaviour (a slow-but-successful handshake could start failing) and the right values need to be chosen from real latency data, which the elapsedMs logging added here will itself provide. Tracked separately. Co-Authored-By: Claude Opus 4.8 --- .changeset/otp-send-timing-logging.md | 9 ++ .../src/__tests__/email-template.test.ts | 99 +++++++++++++++ packages/auth-service/src/better-auth.ts | 10 +- packages/auth-service/src/email/sender.ts | 120 +++++++++++++----- 4 files changed, 202 insertions(+), 36 deletions(-) create mode 100644 .changeset/otp-send-timing-logging.md diff --git a/.changeset/otp-send-timing-logging.md b/.changeset/otp-send-timing-logging.md new file mode 100644 index 00000000..fbac3ac2 --- /dev/null +++ b/.changeset/otp-send-timing-logging.md @@ -0,0 +1,9 @@ +--- +'ePDS': minor +--- + +OTP send logs now record how long the email handoff took, the provider's message ID, and the SMTP server response. + +**Affects:** Operators + +**Operators:** every OTP email completion line (all four paths — client-branded, sign-in, welcome, backup-email verification) now carries `elapsedMs`, `messageId`, and `smtpResponse` alongside `email`. `messageId` lets these logs be joined to Resend delivery events; `elapsedMs` isolates a slow SMTP handoff from other causes of late-arriving codes. Failed sends log `elapsedMs` too, on the existing `better-auth: failed to send OTP email` error line. diff --git a/packages/auth-service/src/__tests__/email-template.test.ts b/packages/auth-service/src/__tests__/email-template.test.ts index b2894841..1b9139ca 100644 --- a/packages/auth-service/src/__tests__/email-template.test.ts +++ b/packages/auth-service/src/__tests__/email-template.test.ts @@ -13,6 +13,7 @@ import { EmailSender, _seedTemplateCacheForTest, _clearTemplateCacheForTest, + _loggerForTest, } from '../email/sender.js' import { formatOtpHtmlGrouped, @@ -267,4 +268,102 @@ describe('EmailSender', () => { ).resolves.toBeUndefined() }) }) + + // Regression cover for #183: the per-send completion line must carry + // the SMTP handoff duration and the provider's messageId / server + // response so app logs can be timed and joined to Resend delivery + // events. jsonTransport supplies a real messageId + response. + describe('per-send diagnostic logging', () => { + it('folds elapsedMs, messageId and smtpResponse into the success line', async () => { + const sender = makeSender() + const info = vi + .spyOn(_loggerForTest, 'info') + .mockImplementation(() => _loggerForTest) + + await sender.sendOtpCode({ + to: 'user@test.com', + code: '12345678', + clientAppName: 'Test App', + pdsName: 'Test PDS', + pdsDomain: 'pds.example', + }) + + expect(info).toHaveBeenCalledOnce() + const [fields, message] = info.mock.calls[0] as [ + Record, + string, + ] + expect(message).toBe('Sent sign-in OTP email') + expect(fields.email).toBe('user@test.com') + expect(fields.elapsedMs).toEqual(expect.any(Number)) + expect(fields.elapsedMs as number).toBeGreaterThanOrEqual(0) + expect(fields.messageId).toEqual(expect.any(String)) + // jsonTransport reports its serialized envelope as the response. + expect(fields).toHaveProperty('smtpResponse') + + info.mockRestore() + }) + + it('carries clientId alongside the diagnostic fields on the branded line', async () => { + const TRUSTED_ID = 'https://branded.app/client-metadata.json' + _seedClientMetadataCacheForTest(TRUSTED_ID, { + client_name: 'Branded App', + email_template_uri: 'https://branded.app/email-template.html', + logo_uri: 'https://branded.app/logo.png', + }) + _seedTemplateCacheForTest( + 'https://branded.app/email-template.html', + 'Your code is {{code}}', + ) + const sender = makeSender([TRUSTED_ID]) + const info = vi + .spyOn(_loggerForTest, 'info') + .mockImplementation(() => _loggerForTest) + + await sender.sendOtpCode({ + to: 'branded@test.com', + code: '99999999', + clientAppName: 'Fallback Name', + clientId: TRUSTED_ID, + pdsName: 'Test PDS', + pdsDomain: 'pds.example', + }) + + const [fields, message] = info.mock.calls[0] as [ + Record, + string, + ] + expect(message).toBe('Sent client-branded OTP email') + expect(fields.clientId).toBe(TRUSTED_ID) + expect(fields.elapsedMs).toEqual(expect.any(Number)) + expect(fields.messageId).toEqual(expect.any(String)) + + info.mockRestore() + }) + + it('stamps elapsedMs onto a failed send for the caller error log', async () => { + const sender = makeSender() + vi.spyOn(sender['transporter'], 'sendMail').mockRejectedValue( + new Error('SMTP handshake timed out'), + ) + + let caught: (Error & { elapsedMs?: number }) | undefined + try { + await sender.sendOtpCode({ + to: 'user@test.com', + code: '12345678', + clientAppName: 'Test App', + pdsName: 'Test PDS', + pdsDomain: 'pds.example', + }) + } catch (e) { + caught = e as Error & { elapsedMs?: number } + } + + expect(caught).toBeInstanceOf(Error) + expect(caught?.message).toBe('SMTP handshake timed out') + expect(caught?.elapsedMs).toEqual(expect.any(Number)) + expect(caught?.elapsedMs as number).toBeGreaterThanOrEqual(0) + }) + }) }) diff --git a/packages/auth-service/src/better-auth.ts b/packages/auth-service/src/better-auth.ts index c5bd2a2b..81796123 100644 --- a/packages/auth-service/src/better-auth.ts +++ b/packages/auth-service/src/better-auth.ts @@ -362,9 +362,15 @@ export function createBetterAuth( isNewUser, }) .catch((err: unknown) => { - // Log and swallow — caller does not await this + // Log and swallow — caller does not await this. + // `EmailSender.timedSendMail` stamps the SMTP handoff + // duration onto the error so this single line carries it. + const elapsedMs = + err instanceof Error + ? (err as Error & { elapsedMs?: number }).elapsedMs + : undefined logger.error( - { err, email, isNewUser }, + { err, email, isNewUser, elapsedMs }, 'better-auth: failed to send OTP email', ) }) diff --git a/packages/auth-service/src/email/sender.ts b/packages/auth-service/src/email/sender.ts index a6fc4bbd..8ddfe82e 100644 --- a/packages/auth-service/src/email/sender.ts +++ b/packages/auth-service/src/email/sender.ts @@ -1,6 +1,6 @@ import * as nodemailer from 'nodemailer' import { createLogger } from '@certified-app/shared' -import type { Transporter } from 'nodemailer' +import type { SendMailOptions, SentMessageInfo, Transporter } from 'nodemailer' import type { EmailConfig } from '@certified-app/shared' import { buildSignInCodeEmail, @@ -11,6 +11,11 @@ import { buildClientBrandedEmail } from './client-template.js' const logger = createLogger('auth:email') +// Exposed for tests that assert the structured fields on the per-send +// completion line (elapsedMs / messageId / smtpResponse). Production +// code must not import this. +export const _loggerForTest: ReturnType = logger + // Re-exports so existing tests that reach for the template cache still // resolve through sender.js. New code should import directly from // ./client-template.js. @@ -95,6 +100,47 @@ export class EmailSender { } } + /** + * Send one message, measuring how long the SMTP handoff takes and + * folding the elapsed time plus the provider's `messageId` / server + * `response` into a single structured completion line. `messageId` + * lets these app logs be joined to Resend's per-message delivery + * events (see #183 / #198) so provider-side latency can be told apart + * from users submitting stale codes. + * + * On failure the error is re-thrown after stamping it with + * `elapsedMs`, so the caller's existing error log can carry the + * duration without adding a second line per failure. + */ + private async timedSendMail( + mail: SendMailOptions, + message: string, + extra: Record = {}, + ): Promise { + const startedAt = performance.now() + try { + const info: SentMessageInfo = await this.transporter.sendMail(mail) + const elapsedMs = Math.round(performance.now() - startedAt) + logger.info( + { + email: mail.to, + ...extra, + elapsedMs, + messageId: info.messageId, + smtpResponse: info.response, + }, + message, + ) + } catch (err) { + const elapsedMs = Math.round(performance.now() - startedAt) + if (err instanceof Error) { + // Stamp the duration so the caller's error log carries it. + ;(err as Error & { elapsedMs?: number }).elapsedMs = elapsedMs + } + throw err + } + } + async sendOtpCode(opts: { to: string code: string @@ -125,16 +171,16 @@ export class EmailSender { trustedClients: this.trustedClients, }) if (branded) { - await this.transporter.sendMail({ - from: `"${branded.fromName}" <${this.config.from}>`, - to, - subject: branded.subject, - text: branded.text, - html: branded.html, - }) - logger.info( - { email: to, clientId: opts.clientId }, + await this.timedSendMail( + { + from: `"${branded.fromName}" <${this.config.from}>`, + to, + subject: branded.subject, + text: branded.text, + html: branded.html, + }, 'Sent client-branded OTP email', + { clientId: opts.clientId }, ) return } @@ -158,14 +204,16 @@ export class EmailSender { const { to, ...rest } = opts const { subject, text, html } = buildSignInCodeEmail(rest) - await this.transporter.sendMail({ - from: `"${this.config.fromName}" <${this.config.from}>`, - to, - subject, - text, - html, - }) - logger.info({ email: to }, 'Sent sign-in OTP email') + await this.timedSendMail( + { + from: `"${this.config.fromName}" <${this.config.from}>`, + to, + subject, + text, + html, + }, + 'Sent sign-in OTP email', + ) } private async sendWelcomeCode(opts: { @@ -177,14 +225,16 @@ export class EmailSender { const { to, ...rest } = opts const { subject, text, html } = buildWelcomeCodeEmail(rest) - await this.transporter.sendMail({ - from: `"${this.config.fromName}" <${this.config.from}>`, - to, - subject, - text, - html, - }) - logger.info({ email: to }, 'Sent welcome OTP email') + await this.timedSendMail( + { + from: `"${this.config.fromName}" <${this.config.from}>`, + to, + subject, + text, + html, + }, + 'Sent welcome OTP email', + ) } async sendBackupEmailVerification(opts: { @@ -196,13 +246,15 @@ export class EmailSender { const { to, ...rest } = opts const { subject, text, html } = buildBackupEmailVerificationEmail(rest) - await this.transporter.sendMail({ - from: `"${this.config.fromName}" <${this.config.from}>`, - to, - subject, - text, - html, - }) - logger.info({ email: to }, 'Sent backup email verification') + await this.timedSendMail( + { + from: `"${this.config.fromName}" <${this.config.from}>`, + to, + subject, + text, + html, + }, + 'Sent backup email verification', + ) } } From d275b333b86a23e0defea7d5a5d2bbd3c89ffe04 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Mon, 20 Jul 2026 14:51:23 +0100 Subject: [PATCH 22/24] feat(auth): log Resend email open events Operators using Resend open tracking could see delivery outcomes but not whether the message's tracking pixel was fetched.\n\nAccept email.opened webhooks, normalize them to opened, and document the required tracking opt-in and reliability caveats. The event uses the existing sender filter and sanitized provider-neutral log fields.\n\nFollow-up to #185.\n\nCo-authored-by: OpenAI --- .changeset/resend-delivery-webhooks.md | 4 +-- .env.example | 6 ++-- docs/configuration.md | 26 +++++++++----- packages/auth-service/.env.example | 4 +-- .../src/__tests__/resend-webhook.test.ts | 25 +++++++++++--- .../auth-service/src/routes/resend-webhook.ts | 34 ++++++++++--------- 6 files changed, 62 insertions(+), 37 deletions(-) diff --git a/.changeset/resend-delivery-webhooks.md b/.changeset/resend-delivery-webhooks.md index b7a6c9e4..fb3feb13 100644 --- a/.changeset/resend-delivery-webhooks.md +++ b/.changeset/resend-delivery-webhooks.md @@ -2,8 +2,8 @@ 'ePDS': minor --- -Optional delivery-event logging for operators who choose the third-party Resend service to send email; ePDS continues to support other email providers. +Optional email-event logging for operators who choose the third-party Resend service to send email; ePDS continues to support other email providers. **Affects:** Operators -**Operators:** ePDS remains compatible with any SMTP provider and does not require Resend. Operators who already use Resend can opt in by registering `https:///webhooks/resend` for the documented email events and setting `RESEND_WEBHOOK_SECRET`; the route verifies each webhook, logs only events whose sender exactly matches `SMTP_FROM`, and does not persist webhook data. +**Operators:** ePDS remains compatible with any SMTP provider and does not require Resend. Operators who already use Resend can opt in by registering `https:///webhooks/resend` for the documented email events and setting `RESEND_WEBHOOK_SECRET`; optional Resend open tracking adds `opened` logs. The route verifies each webhook, logs only events whose sender exactly matches `SMTP_FROM`, and does not persist webhook data. diff --git a/.env.example b/.env.example index 133705f0..b25b42a7 100644 --- a/.env.example +++ b/.env.example @@ -214,9 +214,9 @@ SMTP_PASS= SMTP_FROM=noreply@pds.example SMTP_FROM_NAME=ePDS -# Resend users only (optional): delivery webhook signing secret. ePDS supports -# any SMTP provider and does not require Resend. To opt into Resend delivery -# event logs, configure https://auth.pds.example/webhooks/resend and set: +# Resend users only (optional): email webhook signing secret. ePDS supports +# any SMTP provider and does not require Resend. To opt into Resend email event +# logs, configure https://auth.pds.example/webhooks/resend and set: # RESEND_WEBHOOK_SECRET=whsec_... # Account-wide Resend events are filtered to the exact SMTP_FROM address. diff --git a/docs/configuration.md b/docs/configuration.md index 9b1a891c..2568917b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -198,24 +198,30 @@ buttons appear on the login page. | `POSTMARK_SERVER_TOKEN` | Postmark server token | | `EMAIL_TEMPLATE_ALLOWED_DOMAINS` | Optional comma-separated list of HTTPS hostnames from which `email_template_uri` can be fetched. If unset, any HTTPS URL is allowed. If set, templates hosted on unlisted domains are logged as a warning and ignored. | -#### Optional Resend delivery event logs +#### Optional Resend email event logs ePDS does **not** require Resend: operators can use any SMTP provider. This provider-specific integration is only for operators who already send through -Resend and choose to log its delivery webhooks. Without +Resend and choose to log its email webhooks. Without `RESEND_WEBHOOK_SECRET`, the webhook route is not mounted. | Variable | Description | | ----------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | | `RESEND_WEBHOOK_SECRET` | Resend users only. Optional webhook signing secret (`whsec_...`) that enables `POST /webhooks/resend` and structured Resend event logs when provided. | -To opt into delivery-event logging for email sent through Resend: +To opt into email-event logging for email sent through Resend: 1. In the Resend dashboard, register `https:///webhooks/resend`. -2. Subscribe it to `email.sent`, `email.delivered`, `email.delivery_delayed`, `email.bounced`, and `email.failed`. -3. Set `RESEND_WEBHOOK_SECRET` to that endpoint's signing secret and redeploy the auth service. -4. Send a test sign-in code through the production SMTP configuration and - confirm that both `email.sent` and `email.delivered` reach the receiver. +2. Subscribe it to `email.sent`, `email.delivered`, `email.delivery_delayed`, + `email.opened`, `email.bounced`, and `email.failed`. +3. To receive `email.opened`, enable Resend open tracking for the sending domain + and configure its tracking CNAME. Resend inserts the tracking pixel; ePDS + does not alter email HTML. Without tracking, the other events still work. +4. Set `RESEND_WEBHOOK_SECRET` to that endpoint's signing secret and redeploy + the auth service. +5. Send a test sign-in code through the production SMTP configuration and + confirm that `email.sent` and `email.delivered` reach the receiver. If open + tracking is enabled, open the message and confirm `email.opened` also arrives. Resend's documentation does not explicitly guarantee that SMTP-submitted email emits webhooks, so verify this before relying on the logs. @@ -231,8 +237,10 @@ a provider-neutral log schema: `provider`, `eventId`, `eventType`, `occurredAt`, `messageId`, `email`, and `subject`. Any sign-in code in `subject` is replaced with `[REDACTED]` to prevent it from reaching logs while preserving the rest of the subject. Resend event types are normalized to `sent`, `delivered`, `delayed`, -`bounced`, or `failed`. Normal events use `info`; -`delayed` uses `warn`. No webhook data is persisted by ePDS. The +`opened`, `bounced`, or `failed`. Normal events use `info`; `delayed` uses +`warn`. An `opened` event means the tracking pixel was fetched, which can be +caused or suppressed by mail-client privacy features and is not proof that the +recipient read the message. No webhook data is persisted by ePDS. The provider-specific rate limit permits 300 webhook requests per minute per source IP. diff --git a/packages/auth-service/.env.example b/packages/auth-service/.env.example index 13735507..d4ef2b2f 100644 --- a/packages/auth-service/.env.example +++ b/packages/auth-service/.env.example @@ -139,9 +139,9 @@ SMTP_FROM=noreply@pds.example SMTP_FROM_NAME=ePDS # Resend users only (optional). ePDS supports any SMTP provider and does not -# require Resend. To opt into Resend delivery event logs, register +# require Resend. To opt into Resend email event logs, register # https:///webhooks/resend for email.sent, email.delivered, -# email.delivery_delayed, email.bounced, and email.failed, then set: +# email.delivery_delayed, email.opened, email.bounced, and email.failed, then set: # RESEND_WEBHOOK_SECRET=whsec_... # Account-wide Resend events are filtered to the exact SMTP_FROM address. diff --git a/packages/auth-service/src/__tests__/resend-webhook.test.ts b/packages/auth-service/src/__tests__/resend-webhook.test.ts index 0e5caf03..88e37850 100644 --- a/packages/auth-service/src/__tests__/resend-webhook.test.ts +++ b/packages/auth-service/src/__tests__/resend-webhook.test.ts @@ -119,7 +119,22 @@ describe('Resend webhook receiver', () => { email: 'person@example.com', subject: '[REDACTED] — Your sign-in code', }, - 'Received email delivery event', + 'Received email event', + ) + }) + + it('normalizes open-tracking events', async () => { + const result = await postWebhook(makeEvent('email.opened')) + + expect(result).toEqual({ status: 200, json: { received: true } }) + expect(logInfo).toHaveBeenCalledWith( + expect.objectContaining({ + provider: 'resend', + eventType: 'opened', + messageId: 'resend-email-123', + email: 'person@example.com', + }), + 'Received email event', ) }) @@ -200,7 +215,7 @@ describe('Resend webhook receiver', () => { eventType: 'delayed', messageId: 'resend-email-123', }), - 'Received email delivery event', + 'Received email event', ) }) @@ -222,15 +237,15 @@ describe('Resend webhook receiver', () => { expect(logInfo).not.toHaveBeenCalled() }) - it('rejects signed event types that are not used for delivery logs', async () => { - const result = await postWebhook(makeEvent('email.opened')) + it('rejects signed event types that are not used for email logs', async () => { + const result = await postWebhook(makeEvent('email.received')) expect(result.status).toBe(400) expect(result.json).toEqual({ error: 'Invalid webhook payload' }) expect(logInfo).not.toHaveBeenCalled() }) - it('rejects a delivery event without a recipient email', async () => { + it('rejects an email event without a recipient email', async () => { const event = makeEvent() const data = event.data as Record data.to = [] diff --git a/packages/auth-service/src/routes/resend-webhook.ts b/packages/auth-service/src/routes/resend-webhook.ts index a574527d..4999f516 100644 --- a/packages/auth-service/src/routes/resend-webhook.ts +++ b/packages/auth-service/src/routes/resend-webhook.ts @@ -1,12 +1,12 @@ /** - * Resend delivery webhook receiver. + * Resend email webhook receiver. * * The route consumes the untouched request body, verifies Resend's Svix - * signature before inspecting the payload, accepts only delivery events used - * by ePDS, and emits one structured log entry per delivery attempt. Resend may - * retry the same source ID (logged as `eventId`) and may deliver an email's - * events out of order, so log consumers must deduplicate and order events when - * calculating latency. + * signature before inspecting the payload, accepts only email events used by + * ePDS, and emits one structured log entry per event. Resend may retry the same + * source ID (logged as `eventId`) and may deliver an email's events out of + * order, so log consumers must deduplicate and order events when calculating + * latency. */ import express, { Router } from 'express' import addressparser from 'nodemailer/lib/addressparser/index.js' @@ -21,27 +21,29 @@ const RESEND_EVENT_TYPES = [ 'email.sent', 'email.delivered', 'email.delivery_delayed', + 'email.opened', 'email.bounced', 'email.failed', ] as const type ResendEventType = (typeof RESEND_EVENT_TYPES)[number] -type EmailDeliveryEventType = +type EmailEventType = | 'sent' | 'delivered' | 'delayed' + | 'opened' | 'bounced' | 'failed' type OtpCharset = 'numeric' | 'alphanumeric' -const NORMALIZED_EVENT_TYPES: Record = - { - 'email.sent': 'sent', - 'email.delivered': 'delivered', - 'email.delivery_delayed': 'delayed', - 'email.bounced': 'bounced', - 'email.failed': 'failed', - } +const NORMALIZED_EVENT_TYPES: Record = { + 'email.sent': 'sent', + 'email.delivered': 'delivered', + 'email.delivery_delayed': 'delayed', + 'email.opened': 'opened', + 'email.bounced': 'bounced', + 'email.failed': 'failed', +} interface ResendEvent { type: ResendEventType @@ -203,7 +205,7 @@ function logResendEvent( email: event.data.to[0], subject: redactOtpFromSubject(event.data.subject, otpLength, otpCharset), } - const message = 'Received email delivery event' + const message = 'Received email event' if (event.type === 'email.delivery_delayed') { logger.warn(fields, message) } else { From c06031983a9b8f413dc166de9b7e86fb77e4e1f4 Mon Sep 17 00:00:00 2001 From: Adam Spiers Date: Mon, 20 Jul 2026 15:21:52 +0100 Subject: [PATCH 23/24] feat(auth): expand Resend email event handling Resend complaint, suppression, and scheduling events provide useful operational context but were rejected by the original delivery-focused allowlist. Known click and inbound events also caused pointless retries when subscribed accidentally.\n\nNormalize and log complained, suppressed, and scheduled events; acknowledge clicked and received events without logging their payloads. Document the expanded subscriptions and ratchet line coverage.\n\nCo-authored-by: OpenAI --- .changeset/resend-delivery-webhooks.md | 2 +- docs/configuration.md | 15 ++++-- docs/design/testing-gaps.md | 8 +-- packages/auth-service/.env.example | 3 +- .../src/__tests__/resend-webhook.test.ts | 45 ++++++++++++++--- .../auth-service/src/routes/resend-webhook.ts | 49 +++++++++++++++++-- vitest.config.ts | 2 +- 7 files changed, 100 insertions(+), 24 deletions(-) diff --git a/.changeset/resend-delivery-webhooks.md b/.changeset/resend-delivery-webhooks.md index fb3feb13..4f820c39 100644 --- a/.changeset/resend-delivery-webhooks.md +++ b/.changeset/resend-delivery-webhooks.md @@ -6,4 +6,4 @@ Optional email-event logging for operators who choose the third-party Resend ser **Affects:** Operators -**Operators:** ePDS remains compatible with any SMTP provider and does not require Resend. Operators who already use Resend can opt in by registering `https:///webhooks/resend` for the documented email events and setting `RESEND_WEBHOOK_SECRET`; optional Resend open tracking adds `opened` logs. The route verifies each webhook, logs only events whose sender exactly matches `SMTP_FROM`, and does not persist webhook data. +**Operators:** ePDS remains compatible with any SMTP provider and does not require Resend. Operators who already use Resend can opt in by registering `https:///webhooks/resend` for the documented email events and setting `RESEND_WEBHOOK_SECRET`; logs include delivery, open, complaint, suppression, and scheduling events. The route verifies each webhook, logs only events whose sender exactly matches `SMTP_FROM`, acknowledges unsupported click and inbound events without logging their payloads, and does not persist webhook data. diff --git a/docs/configuration.md b/docs/configuration.md index 2568917b..642ec3ac 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -213,7 +213,8 @@ To opt into email-event logging for email sent through Resend: 1. In the Resend dashboard, register `https:///webhooks/resend`. 2. Subscribe it to `email.sent`, `email.delivered`, `email.delivery_delayed`, - `email.opened`, `email.bounced`, and `email.failed`. + `email.opened`, `email.bounced`, `email.failed`, `email.complained`, + `email.suppressed`, and `email.scheduled`. 3. To receive `email.opened`, enable Resend open tracking for the sending domain and configure its tracking CNAME. Resend inserts the tracking pixel; ePDS does not alter email HTML. Without tracking, the other events still work. @@ -237,10 +238,14 @@ a provider-neutral log schema: `provider`, `eventId`, `eventType`, `occurredAt`, `messageId`, `email`, and `subject`. Any sign-in code in `subject` is replaced with `[REDACTED]` to prevent it from reaching logs while preserving the rest of the subject. Resend event types are normalized to `sent`, `delivered`, `delayed`, -`opened`, `bounced`, or `failed`. Normal events use `info`; `delayed` uses -`warn`. An `opened` event means the tracking pixel was fetched, which can be -caused or suppressed by mail-client privacy features and is not proof that the -recipient read the message. No webhook data is persisted by ePDS. The +`opened`, `bounced`, `failed`, `complained`, `suppressed`, or `scheduled`. +`Delayed`, `complained`, and `suppressed` events use `warn`; the rest use `info`. +An `opened` event means the tracking pixel was fetched, which can be caused or +suppressed by mail-client privacy features and is not proof that the recipient +read the message. Signed `email.clicked` and inbound `email.received` events are +acknowledged without logging their payloads, preventing retries if the Resend +endpoint is accidentally subscribed to them. No webhook data is persisted by +ePDS. The provider-specific rate limit permits 300 webhook requests per minute per source IP. diff --git a/docs/design/testing-gaps.md b/docs/design/testing-gaps.md index 23f250ef..b460f195 100644 --- a/docs/design/testing-gaps.md +++ b/docs/design/testing-gaps.md @@ -15,7 +15,7 @@ pds-core callback, full OAuth flow). | `auth-service/lib/` | ~77% | `auto-provision.ts` at 0% (needs live PDS) | | `auth-service/middleware/` | ~91% | `rate-limit.ts` timer cleanup at 82% | | `auth-service/email/` | ~71% | Template conditional branches partially covered | -| `auth-service/routes/` | ~32% | Resend webhook is HTTP-tested; browser/OAuth routes remain gaps | +| `auth-service/routes/` | ~35% | Resend webhook is HTTP-tested; browser/OAuth routes remain gaps | | `auth-service/better-auth.ts` | 0% | better-auth wiring — see below | | `auth-service/context.ts` | 0% | Minimal glue class | | `auth-service/index.ts` | 0% | Express app assembly + `main()` | @@ -60,9 +60,9 @@ pds-core callback, full OAuth flow). ### 2. `auth-service/src/routes/` (low coverage) The signed `resend-webhook.ts` receiver has HTTP-level integration coverage -for valid signatures, rejected signatures, supported event filtering, -structured logging, and retry correlation. The browser/OAuth route files remain the -main gap: `login-page.ts`, `consent.ts`, `recovery.ts`, `account-login.ts`, +for valid signatures, rejected signatures, logged and ignored event filtering, +structured logging, and retry correlation. The browser/OAuth route files remain +this area's main gap: `login-page.ts`, `consent.ts`, `recovery.ts`, `account-login.ts`, `account-settings.ts`, `choose-handle.ts`, and `complete.ts`. **Why they're hard:** diff --git a/packages/auth-service/.env.example b/packages/auth-service/.env.example index d4ef2b2f..caa0d728 100644 --- a/packages/auth-service/.env.example +++ b/packages/auth-service/.env.example @@ -141,7 +141,8 @@ SMTP_FROM_NAME=ePDS # Resend users only (optional). ePDS supports any SMTP provider and does not # require Resend. To opt into Resend email event logs, register # https:///webhooks/resend for email.sent, email.delivered, -# email.delivery_delayed, email.opened, email.bounced, and email.failed, then set: +# email.delivery_delayed, email.opened, email.bounced, email.failed, +# email.complained, email.suppressed, and email.scheduled, then set: # RESEND_WEBHOOK_SECRET=whsec_... # Account-wide Resend events are filtered to the exact SMTP_FROM address. diff --git a/packages/auth-service/src/__tests__/resend-webhook.test.ts b/packages/auth-service/src/__tests__/resend-webhook.test.ts index 88e37850..d66b118d 100644 --- a/packages/auth-service/src/__tests__/resend-webhook.test.ts +++ b/packages/auth-service/src/__tests__/resend-webhook.test.ts @@ -123,14 +123,17 @@ describe('Resend webhook receiver', () => { ) }) - it('normalizes open-tracking events', async () => { - const result = await postWebhook(makeEvent('email.opened')) + it.each([ + ['email.opened', 'opened'], + ['email.scheduled', 'scheduled'], + ])('normalizes %s events as %s', async (resendType, normalizedType) => { + const result = await postWebhook(makeEvent(resendType)) expect(result).toEqual({ status: 200, json: { received: true } }) expect(logInfo).toHaveBeenCalledWith( expect.objectContaining({ provider: 'resend', - eventType: 'opened', + eventType: normalizedType, messageId: 'resend-email-123', email: 'person@example.com', }), @@ -205,14 +208,18 @@ describe('Resend webhook receiver', () => { ) }) - it('logs delivery delays at warning level', async () => { - await postWebhook(makeEvent('email.delivery_delayed')) + it.each([ + ['email.delivery_delayed', 'delayed'], + ['email.complained', 'complained'], + ['email.suppressed', 'suppressed'], + ])('logs %s as %s at warning level', async (resendType, normalizedType) => { + await postWebhook(makeEvent(resendType)) expect(logWarn).toHaveBeenCalledWith( expect.objectContaining({ provider: 'resend', eventId: 'msg_test_123', - eventType: 'delayed', + eventType: normalizedType, messageId: 'resend-email-123', }), 'Received email event', @@ -237,8 +244,30 @@ describe('Resend webhook receiver', () => { expect(logInfo).not.toHaveBeenCalled() }) - it('rejects signed event types that are not used for email logs', async () => { - const result = await postWebhook(makeEvent('email.received')) + it.each(['email.clicked', 'email.received'])( + 'acknowledges ignored %s events without logging their payload', + async (eventType) => { + const result = await postWebhook(makeEvent(eventType)) + + expect(result).toEqual({ + status: 200, + json: { received: true, ignored: true }, + }) + expect(logInfo).not.toHaveBeenCalled() + expect(logWarn).not.toHaveBeenCalled() + expect(logDebug).toHaveBeenCalledWith( + { + provider: 'resend', + eventId: 'msg_test_123', + eventType: eventType.slice('email.'.length), + }, + 'Ignored unsupported email webhook event', + ) + }, + ) + + it('rejects unknown signed event types', async () => { + const result = await postWebhook(makeEvent('email.unknown')) expect(result.status).toBe(400) expect(result.json).toEqual({ error: 'Invalid webhook payload' }) diff --git a/packages/auth-service/src/routes/resend-webhook.ts b/packages/auth-service/src/routes/resend-webhook.ts index 4999f516..dffc42e3 100644 --- a/packages/auth-service/src/routes/resend-webhook.ts +++ b/packages/auth-service/src/routes/resend-webhook.ts @@ -17,15 +17,31 @@ const logger = createLogger('auth:email-webhook') export const RESEND_WEBHOOK_PATH = '/webhooks/resend' -const RESEND_EVENT_TYPES = [ +const LOGGED_RESEND_EVENT_TYPES = [ 'email.sent', 'email.delivered', 'email.delivery_delayed', 'email.opened', 'email.bounced', 'email.failed', + 'email.complained', + 'email.suppressed', + 'email.scheduled', ] as const +const IGNORED_RESEND_EVENT_TYPES = ['email.clicked', 'email.received'] as const +const RESEND_EVENT_TYPES = [ + ...LOGGED_RESEND_EVENT_TYPES, + ...IGNORED_RESEND_EVENT_TYPES, +] as const + +const WARNING_RESEND_EVENT_TYPES = [ + 'email.delivery_delayed', + 'email.complained', + 'email.suppressed', +] as const + +type LoggedResendEventType = (typeof LOGGED_RESEND_EVENT_TYPES)[number] type ResendEventType = (typeof RESEND_EVENT_TYPES)[number] type EmailEventType = | 'sent' @@ -34,15 +50,21 @@ type EmailEventType = | 'opened' | 'bounced' | 'failed' + | 'complained' + | 'suppressed' + | 'scheduled' type OtpCharset = 'numeric' | 'alphanumeric' -const NORMALIZED_EVENT_TYPES: Record = { +const NORMALIZED_EVENT_TYPES: Record = { 'email.sent': 'sent', 'email.delivered': 'delivered', 'email.delivery_delayed': 'delayed', 'email.opened': 'opened', 'email.bounced': 'bounced', 'email.failed': 'failed', + 'email.complained': 'complained', + 'email.suppressed': 'suppressed', + 'email.scheduled': 'scheduled', } interface ResendEvent { @@ -190,8 +212,14 @@ function redactOtpFromSubject( ) } -function logResendEvent( +function isLoggedResendEvent( event: ResendEvent, +): event is ResendEvent & { type: LoggedResendEventType } { + return (LOGGED_RESEND_EVENT_TYPES as readonly string[]).includes(event.type) +} + +function logResendEvent( + event: ResendEvent & { type: LoggedResendEventType }, svixId: string, otpLength: number, otpCharset: OtpCharset, @@ -206,7 +234,7 @@ function logResendEvent( subject: redactOtpFromSubject(event.data.subject, otpLength, otpCharset), } const message = 'Received email event' - if (event.type === 'email.delivery_delayed') { + if ((WARNING_RESEND_EVENT_TYPES as readonly string[]).includes(event.type)) { logger.warn(fields, message) } else { logger.info(fields, message) @@ -237,6 +265,19 @@ export function createResendWebhookRouter( } const eventId = result.headers['svix-id'] + if (!isLoggedResendEvent(result.event)) { + logger.debug( + { + provider: 'resend', + eventId, + eventType: result.event.type.slice('email.'.length), + }, + 'Ignored unsupported email webhook event', + ) + res.status(200).json({ received: true, ignored: true }) + return + } + const eventFromAddress = parseSingleEmailAddress(result.event.data.from) if (eventFromAddress !== expectedFromAddress) { logger.debug( diff --git a/vitest.config.ts b/vitest.config.ts index 9e0bd0f7..954c8956 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -26,7 +26,7 @@ export default defineConfig({ statements: 58, branches: 57, functions: 71, - lines: 56, + lines: 57, }, }, }, From 0d05513f1ae1ef3c708d69fbb936bacfc1b298ea Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 15:00:27 +0000 Subject: [PATCH 24/24] chore: release --- .changeset/email-log-recipient-field.md | 9 -------- .changeset/otp-send-timing-logging.md | 9 -------- .changeset/resend-delivery-webhooks.md | 9 -------- CHANGELOG.md | 29 +++++++++++++++++++++++++ package.json | 2 +- 5 files changed, 30 insertions(+), 28 deletions(-) delete mode 100644 .changeset/email-log-recipient-field.md delete mode 100644 .changeset/otp-send-timing-logging.md delete mode 100644 .changeset/resend-delivery-webhooks.md diff --git a/.changeset/email-log-recipient-field.md b/.changeset/email-log-recipient-field.md deleted file mode 100644 index 454ed246..00000000 --- a/.changeset/email-log-recipient-field.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'ePDS': minor ---- - -Email delivery logs use a consistent recipient field. - -**Affects:** Operators - -**Operators:** All four email delivery paths now log the recipient as `email`: client-branded OTP, standard sign-in OTP, welcome OTP, and backup-email verification. Update structured-log queries for `Sent client-branded OTP email` to read `email` instead of `to`. diff --git a/.changeset/otp-send-timing-logging.md b/.changeset/otp-send-timing-logging.md deleted file mode 100644 index fbac3ac2..00000000 --- a/.changeset/otp-send-timing-logging.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'ePDS': minor ---- - -OTP send logs now record how long the email handoff took, the provider's message ID, and the SMTP server response. - -**Affects:** Operators - -**Operators:** every OTP email completion line (all four paths — client-branded, sign-in, welcome, backup-email verification) now carries `elapsedMs`, `messageId`, and `smtpResponse` alongside `email`. `messageId` lets these logs be joined to Resend delivery events; `elapsedMs` isolates a slow SMTP handoff from other causes of late-arriving codes. Failed sends log `elapsedMs` too, on the existing `better-auth: failed to send OTP email` error line. diff --git a/.changeset/resend-delivery-webhooks.md b/.changeset/resend-delivery-webhooks.md deleted file mode 100644 index 4f820c39..00000000 --- a/.changeset/resend-delivery-webhooks.md +++ /dev/null @@ -1,9 +0,0 @@ ---- -'ePDS': minor ---- - -Optional email-event logging for operators who choose the third-party Resend service to send email; ePDS continues to support other email providers. - -**Affects:** Operators - -**Operators:** ePDS remains compatible with any SMTP provider and does not require Resend. Operators who already use Resend can opt in by registering `https:///webhooks/resend` for the documented email events and setting `RESEND_WEBHOOK_SECRET`; logs include delivery, open, complaint, suppression, and scheduling events. The route verifies each webhook, logs only events whose sender exactly matches `SMTP_FROM`, acknowledges unsupported click and inbound events without logging their payloads, and does not persist webhook data. diff --git a/CHANGELOG.md b/CHANGELOG.md index 3fc6c939..34fe1351 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,34 @@ # ePDS +## 0.7.0 + +### Who should read this release + +- **Operators:** + - [Email delivery logs use a consistent recipient field.](#v0.7.0-email-delivery-logs-use-a-consistent-recipient-field) + - [OTP send logs now record how long the email handoff took, the provider's message ID, and the SMTP server response.](#v0.7.0-otp-send-logs-now-record-how-long-the-email-handoff-took) + - [Optional email-event logging for operators who choose the third-party Resend service to send email; ePDS continues to support other email providers.](#v0.7.0-optional-email-event-logging-for-operators-who-choose-the) + +### Minor Changes + +- [#197](https://github.com/hypercerts-org/ePDS/pull/197) [`ce25beb`](https://github.com/hypercerts-org/ePDS/commit/ce25beba5e231e8300a875f1fc1d2ad37ae75527) Thanks [@aspiers](https://github.com/aspiers)! - Email delivery logs use a consistent recipient field. + + **Affects:** Operators + + **Operators:** All four email delivery paths now log the recipient as `email`: client-branded OTP, standard sign-in OTP, welcome OTP, and backup-email verification. Update structured-log queries for `Sent client-branded OTP email` to read `email` instead of `to`. + +- [#203](https://github.com/hypercerts-org/ePDS/pull/203) [`2193515`](https://github.com/hypercerts-org/ePDS/commit/2193515970c56035fd026c3df2b3f44e403eb801) Thanks [@aspiers](https://github.com/aspiers)! - OTP send logs now record how long the email handoff took, the provider's message ID, and the SMTP server response. + + **Affects:** Operators + + **Operators:** every OTP email completion line (all four paths — client-branded, sign-in, welcome, backup-email verification) now carries `elapsedMs`, `messageId`, and `smtpResponse` alongside `email`. `messageId` lets these logs be joined to Resend delivery events; `elapsedMs` isolates a slow SMTP handoff from other causes of late-arriving codes. Failed sends log `elapsedMs` too, on the existing `better-auth: failed to send OTP email` error line. + +- [#198](https://github.com/hypercerts-org/ePDS/pull/198) [`2bf9af4`](https://github.com/hypercerts-org/ePDS/commit/2bf9af420281c769ec88f19c9badd89f00acc03c) Thanks [@aspiers](https://github.com/aspiers)! - Optional email-event logging for operators who choose the third-party Resend service to send email; ePDS continues to support other email providers. + + **Affects:** Operators + + **Operators:** ePDS remains compatible with any SMTP provider and does not require Resend. Operators who already use Resend can opt in by registering `https:///webhooks/resend` for the documented email events and setting `RESEND_WEBHOOK_SECRET`; logs include delivery, open, complaint, suppression, and scheduling events. The route verifies each webhook, logs only events whose sender exactly matches `SMTP_FROM`, acknowledges unsupported click and inbound events without logging their payloads, and does not persist webhook data. + ## 0.6.5 ### Who should read this release diff --git a/package.json b/package.json index 6220f103..5d117057 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ePDS", - "version": "0.6.5", + "version": "0.7.0", "private": true, "description": "ePDS — extended Personal Data Server for AT Protocol with passwordless OTP authentication", "license": "MIT",