Skip to content

[HYPER-219] record emailConfirmedAt after OTP-verified sign-up - #234

Open
aspiers wants to merge 11 commits into
mainfrom
adam/hyper-219-set-emailconfirmedat-on-epds-account-after-otp-verified
Open

[HYPER-219] record emailConfirmedAt after OTP-verified sign-up#234
aspiers wants to merge 11 commits into
mainfrom
adam/hyper-219-set-emailconfirmedat-on-epds-account-after-otp-verified

Conversation

@aspiers

@aspiers aspiers commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Problem

Every ePDS account reported its email address as unverified.

Accounts are created after the user verifies an emailed one-time code, so the address genuinely is verified. But upstream only records confirmation via confirmEmail()'s token flow, which the OTP path never triggered, so emailConfirmedAt stayed null. Two consequences:

  1. email_verified was always false. Upstream's oauth-store derives it as emailConfirmedAt != null, so relying parties saw a verified address reported as unverified.
  2. The email-change verification gate was bypassed. Upstream's requestEmailUpdate only demands a confirmation token when emailConfirmedAt is set.

What this does

Confirmation goes through the public AccountManager APIcreateEmailToken + confirmEmail, per AGENTS.md's "use pds.ctx.accountManager.* methods, do not directly read or modify @atproto/pds database tables". confirmEmail validates the token, deletes it and sets emailConfirmedAt in a single transaction, so no token row is left behind. No email is sent: createEmailToken only inserts the row and returns the token; upstream's XRPC handlers do the mailing separately.

Keyed on the account being unconfirmed, not new. A returning user whose earlier confirmation failed is repaired on their next sign-in; already-confirmed accounts do no writes. Failures are logged at error and never block sign-in — the user has already proven ownership of the address.

Proof of email control now travels in the signed callback. pds-core previously inferred that a valid callback meant the address had been proved, which held only because an emailed code was the sole sign-in path. A passkey flow would legitimately send a signed callback carrying email merely to locate the account, having proved nothing about that address — and pds-core would have marked it confirmed, asserting email_verified: true to relying parties on no evidence. auth-service now reads better-auth's emailVerified where it verifies the code, and signs it into the callback as email_verified; pds-core acts only on an explicit '1'.

The field is required, not sentinel-defaulted: the HMAC payload is positional, so a producer that omits it signs a different payload and is rejected. A future flow that forgets it fails loudly rather than silently claiming verification. The recovery path sets it false, because it rebinds email from the verified backup address to the account's primary.

Existing accounts are handled by an opt-in script rather than a startup fixup — a null emailConfirmedAt cannot be proven to mean "verified but unrecorded", since ePDS does not block upstream's com.atproto.server.createAccount XRPC route:

pnpm --filter @certified-app/pds-core backfill:email-confirmed --dry-run
pnpm --filter @certified-app/pds-core backfill:email-confirmed
pnpm --filter @certified-app/pds-core backfill:email-confirmed @gmail.com   # scope to a domain

Idempotent; a trailing argument scopes the run by case-insensitive address substring; failed accounts are listed by DID and the command exits non-zero.

Deploying

Ship auth-service and pds-core together. The signed handover carries a new required field, so a mixed pair rejects sign-in with an explicit 400 naming the parameter until both are updated. Sign-in recovers on its own once the rollout completes; no data is affected.

Verification

typecheck, lint, format clean; 1114 tests. Beyond unit fakes, the backfill was exercised against a real account.sqlite built by upstream's own migrator:

case result
dry run reports candidates, writes nothing
real run confirms only unconfirmed accounts with a real address
re-run 0 candidates — idempotent
already-confirmed account original timestamp preserved
account with no address skipped
@gmail.com filter matched mixed-case addresses, left others untouched
after any run 0 leftover email_token rows

That exercise also surfaced that account.email is NOT NULL in the PDS schema, so "no address" arrives as the empty string.

Notes for review

  • Enumerating accounts in the backfill script is the one direct table read: AccountManager exposes getAccounts(dids) but no "every account" query, and a backfill cannot know the DIDs in advance. Confined to the operator-invoked script; the request path never does it.
  • ADR docs: recover, re-examine, and spec migration off the auth subdomain split #201 was amended with the boundary cost this work surfaced — auth facts must be re-serialised across the two services, and inference is the dangerous default.

Refs HYPER-219.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Email-code sign-ins now record verified email status.
    • Existing accounts can be safely backfilled with dry-run and email-filter options.
    • Sign-ins automatically repair missing confirmation status when eligible.
  • Bug Fixes

    • Invalid or missing verification details are rejected during sign-in.
    • Recovery-email sign-ins no longer incorrectly mark the primary email as verified.
  • Documentation

    • Added deployment guidance for running and monitoring the email-confirmation backfill.

Copilot AI lite review requested due to automatic review settings August 4, 2026 12:03
@changeset-bot

changeset-bot Bot commented Aug 4, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 9d0d0fb

The changes in this PR will be included in the next version bump.

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Aug 4, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
epds-demo Ready Ready Preview Aug 12, 2026 8:52am

Request Review

@railway-app
railway-app Bot temporarily deployed to ePDS / ePDS-pr-234 August 4, 2026 12:03 Destroyed
@railway-app

railway-app Bot commented Aug 4, 2026

Copy link
Copy Markdown

🚅 Deployed to the ePDS-pr-234 environment in ePDS

Service Status Web Updated (UTC)
@certified-app/pds-core ✅ Success (View Logs) Web Aug 12, 2026 at 8:52 am
@certified-app/demo untrusted ✅ Success (View Logs) Web Aug 11, 2026 at 11:36 pm
@certified-app/demo ✅ Success (View Logs) Web Aug 11, 2026 at 11:35 pm
@certified-app/auth-service ✅ Success (View Logs) Web Aug 11, 2026 at 11:35 pm

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 12c2474a-004b-4e8e-856b-7ce04ab83bca

📥 Commits

Reviewing files that changed from the base of the PR and between 86068b5 and 9d0d0fb.

📒 Files selected for processing (2)
  • AGENTS.md
  • packages/pds-core/src/backfill-email-confirmed.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/pds-core/src/backfill-email-confirmed.ts

📝 Walkthrough

Walkthrough

Adds signed email-verification propagation from auth-service to PDS. PDS confirms eligible accounts during sign-in and provides an idempotent operator backfill with filtering, dry-run support, reporting, and failure handling.

Changes

Email confirmation tracking

Layer / File(s) Summary
Signed verification propagation
packages/shared/src/crypto.ts, packages/auth-service/src/routes/*, packages/auth-service/src/__tests__/*, packages/shared/src/__tests__/crypto.test.ts
Adds the required signed email_verified claim to callback contracts, HMAC payloads, auth-service redirects, recovery flows, and validation tests.
Sign-in confirmation integration
packages/pds-core/src/index.ts, packages/pds-core/src/lib/email-confirmed.ts, packages/pds-core/src/__tests__/email-confirmed.test.ts
Validates the claim and confirms new or unconfirmed accounts through the account-manager API. Confirmation failures do not block sign-in.
Operator backfill command
packages/pds-core/src/backfill-email-confirmed.ts, packages/pds-core/package.json, docs/deployment.md, AGENTS.md, .changeset/record-email-as-confirmed.md
Adds filtered and dry-run backfills, formatted reports, cleanup, nonzero failure status handling, deployment guidance, database-access guidance, and release notes.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant AuthService
  participant SharedCrypto
  participant PDS
  participant AccountManager
  AuthService->>SharedCrypto: sign email_verified claim
  SharedCrypto-->>AuthService: signed callback parameters
  AuthService->>PDS: redirect with signed callback
  PDS->>SharedCrypto: verify callback claim
  PDS->>AccountManager: confirm eligible account email
  AccountManager-->>PDS: confirmation result
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the primary change: recording emailConfirmedAt after OTP-verified sign-up.
Docstring Coverage ✅ Passed Docstring coverage is 84.62% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch adam/hyper-219-set-emailconfirmedat-on-epds-account-after-otp-verified

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coveralls-official

coveralls-official Bot commented Aug 4, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 31580298359

Coverage increased (+0.04%) to 60.234%

Details

  • Coverage increased (+0.04%) from the base build.
  • Patch coverage: 25 uncovered changes across 3 files (42 of 67 lines covered, 62.69%).
  • 1 coverage regression across 1 file.

Uncovered Changes

File Changed Covered %
packages/pds-core/src/backfill-email-confirmed.ts 16 0 0.0%
packages/pds-core/src/index.ts 8 0 0.0%
packages/auth-service/src/routes/complete.ts 7 6 85.71%
Total (5 files) 67 42 62.69%

Coverage Regressions

1 previously-covered line in 1 file lost coverage.

File Lines Losing Coverage Coverage
packages/pds-core/src/index.ts 1 0.0%

Coverage Stats

Coverage Status
Relevant Lines: 3202
Covered Lines: 1925
Line Coverage: 60.12%
Relevant Branches: 2006
Covered Branches: 1212
Branch Coverage: 60.42%
Branches in Coverage %: Yes
Coverage Strength: 9.65 hits per line

💛 - Coveralls

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR ensures ePDS accounts created via the OTP-verified sign-up flow are recorded as having a confirmed email by stamping account.emailConfirmedAt, and provides an opt-in operator backfill script for pre-existing accounts.

Changes:

  • Add a small lib/ module to (a) stamp emailConfirmedAt for newly created accounts and (b) backfill legacy rows, plus unit tests.
  • Call the best-effort stamping helper during /oauth/epds-callback for new accounts only.
  • Add an operator script (backfill:email-confirmed) and a Changeset describing the behavior change and backfill instructions.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/pds-core/src/lib/email-confirmed.ts Adds helpers to set emailConfirmedAt, backfill legacy rows, and a best-effort “hot path” wrapper.
packages/pds-core/src/index.ts Hooks email-confirm stamping into the callback flow for new accounts only.
packages/pds-core/src/backfill-email-confirmed.ts Adds an operator-run script that boots PDS context and executes the backfill.
packages/pds-core/src/tests/email-confirmed.test.ts Adds unit tests covering single-row stamping, best-effort behavior, and backfill selection/update criteria.
packages/pds-core/package.json Registers the new backfill:email-confirmed script.
.changeset/record-email-as-confirmed.md Documents the behavioral change and provides operator backfill guidance.
Suppressed comments (1)

packages/pds-core/src/tests/email-confirmed.test.ts:153

  • The backfill UPDATE mock currently resolves to undefined, but the production code should read result.numUpdatedRows from executeTakeFirst(). Return an UpdateResult-like object here so updated counts are exercised (including bigint-to-number coercion).
            return chain(updateWheres, () => {
              updateExecuted++
              return Promise.resolve(undefined)
            })

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread packages/pds-core/src/lib/email-confirmed.ts Outdated
Comment thread packages/pds-core/src/__tests__/email-confirmed.test.ts Outdated
Copilot AI review requested due to automatic review settings August 4, 2026 12:08
@railway-app
railway-app Bot temporarily deployed to ePDS / ePDS-pr-234 August 4, 2026 12:08 Destroyed
aspiers added a commit that referenced this pull request Aug 4, 2026
The reported `updated` count reused the earlier SELECT's row count,
which assumes nothing changes between the two statements. A sign-in
that stamps one of those rows in between leaves the UPDATE touching
fewer rows than were counted, and this number is the operator's only
evidence of what the script did.

Read Kysely's UpdateResult.numUpdatedRows via executeTakeFirst()
instead. The test double now mirrors the real surface -- SELECT via
execute(), UPDATE via executeTakeFirst() returning a bigint count --
and rejects an UPDATE sent to execute(), so the mock cannot drift
back into accepting an API the production code no longer uses.

Verified against a real account.sqlite built by upstream's migrator:
numUpdatedRows matches the rows actually written, and dry-run,
idempotent-rerun and skip-empty-email behaviour are unchanged.

Raised by Copilot in review on PR #234.
@railway-app
railway-app Bot temporarily deployed to ePDS / ePDS-pr-234 August 4, 2026 12:15 Destroyed

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/pds-core/src/__tests__/email-confirmed.test.ts`:
- Around line 45-46: Replace the `as any` casts in the test fake factories with
explicit `EmailConfirmedDb` and `BackfillDb` typing, using `satisfies`, return
annotations, or narrowly typed fluent builders. Keep the fakes limited to those
local interfaces and do not use unsupported casts for the database shapes.

In `@packages/pds-core/src/index.ts`:
- Around line 447-461: Update the Step 4b email-confirmation flow around
markEmailConfirmed so it runs after every successful OTP callback, regardless of
existingAccount. Remove the !existingAccount guard while preserving the existing
best-effort behavior and arguments, allowing failed writes to be retried on
later sign-ins.

In `@packages/pds-core/src/lib/email-confirmed.ts`:
- Around line 71-75: Replace the direct account-table updates in both
single-account and bulk confirmation helpers with the supported
pds.ctx.accountManager operation(s), preserving the existing emailConfirmedAt
values and retry behavior. If the manager lacks bulk confirmation support, add
or use a narrow account-manager operation rather than accessing the table
directly.
- Around line 145-153: Update the email confirmation flow around the UPDATE
query to capture its execution result and return the database-reported
affected-row count as updated instead of candidates. Preserve candidates for the
selection count, and add a test covering a mismatch between candidate count and
updated rows.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 641d7825-22a5-4d9a-876b-1b82fa93d76b

📥 Commits

Reviewing files that changed from the base of the PR and between a42d20a and f74d377.

📒 Files selected for processing (6)
  • .changeset/record-email-as-confirmed.md
  • packages/pds-core/package.json
  • packages/pds-core/src/__tests__/email-confirmed.test.ts
  • packages/pds-core/src/backfill-email-confirmed.ts
  • packages/pds-core/src/index.ts
  • packages/pds-core/src/lib/email-confirmed.ts

Comment thread packages/pds-core/src/__tests__/email-confirmed.test.ts Outdated
Comment thread packages/pds-core/src/index.ts Outdated
Comment thread packages/pds-core/src/lib/email-confirmed.ts Outdated
Comment thread packages/pds-core/src/lib/email-confirmed.ts Outdated

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

Suppressed comments (3)

packages/pds-core/src/lib/email-confirmed.ts:138

  • The backfill SELECT runs without AccountDb.executeWithRetry, so the operator script may fail on transient SQLite busy/locking errors when run against a live DB. Since the query builder already has an .execute() method, wrap it in executeWithRetry like other account-db writes.
}): Promise<BackfillResult> {
  const dryRun = opts.dryRun ?? false
  const emailConfirmedAt = opts.emailConfirmedAt ?? new Date().toISOString()

  const rows = await opts.db.db
    .selectFrom('account')
    .select('did')

packages/pds-core/src/lib/email-confirmed.ts:153

  • The backfill UPDATE isn’t wrapped in executeWithRetry, and the function reports updated: candidates regardless of how many rows were actually modified. If rows change between the SELECT and UPDATE, or if the UPDATE touches fewer rows, the report will be incorrect. Prefer executeTakeFirst() and derive updated from numUpdatedRows (as done elsewhere in pds-core) while also using executeWithRetry for the write.
  if (dryRun || candidates === 0) {
    return { candidates, updated: 0, dryRun }
  }

  // Report what the UPDATE actually touched rather than reusing the
  // SELECT's count. The two are separate statements, so a sign-in
  // that stamps one of these rows in between would make `candidates`
  // an overstatement — and this number is the operator's only
  // evidence of what the script did.

packages/pds-core/src/lib/email-confirmed.ts:90

  • BackfillDb omits executeWithRetry, but AccountManager.db provides it and it’s important for operator scripts that may run against a live SQLite DB (avoids transient SQLITE_BUSY failures). Including it in the structural slice also makes the expectation explicit and keeps fakes honest.

This issue also appears in the following locations of the same file:

  • line 132
  • line 145
export interface BackfillDb {
  db: {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- structural slice of Kysely's fluent builder; the real types come from AccountDb at the call site
    selectFrom: (table: 'account') => any
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ditto

Copilot AI review requested due to automatic review settings August 4, 2026 12:16

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

Suppressed comments (3)

packages/pds-core/src/lib/email-confirmed.ts:142

  • backfillEmailConfirmedAt() executes its SELECT/UPDATE directly. When run against a deployment where the server is still using the SQLite DB, this can fail with transient SQLITE_BUSY. Since the call site already passes AccountDb, use executeWithRetry when it’s available to make the backfill more reliable.
    .select('did')
    .where('emailConfirmedAt', 'is', null)
    .where('email', 'is not', null)
    .where('email', '!=', '')
    .execute()

packages/pds-core/src/lib/email-confirmed.ts:90

  • BackfillDb doesn’t expose the AccountDb SQLite busy-retry wrapper, so backfillEmailConfirmedAt() can’t reliably reuse executeWithRetry when it’s available. When this script is run against a live deployment, a SQLITE_BUSY could fail the one-off backfill unnecessarily.

This issue also appears on line 138 of the same file.

export interface BackfillDb {
  db: {
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- structural slice of Kysely's fluent builder; the real types come from AccountDb at the call site
    selectFrom: (table: 'account') => any
    // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ditto
    updateTable: (table: 'account') => any
  }
}

packages/pds-core/src/backfill-email-confirmed.ts:53

  • The script error output uses String(err), which can degrade to "[object Object]" and drops stack traces for Error instances. Including the stack when available will make operator debugging much easier.
main().catch((err: unknown) => {
  process.stderr.write(`Backfill failed: ${String(err)}\n`)
  process.exit(1)

aspiers added a commit that referenced this pull request Aug 4, 2026
AGENTS.md is explicit: "Do not directly read or modify @atproto/pds
database tables -- use pds.ctx.accountManager.* methods." The Kysely
UPDATE this PR introduced violated that, on my mistaken claim that no
public API could do the job.

There is one. createEmailToken() and confirmEmail() are both public
AccountManager methods, and together they are the supported route to
a confirmed address: confirmEmail validates the token, deletes it and
sets emailConfirmedAt in a single transaction. createEmailToken only
inserts the row and returns the token -- upstream's XRPC handlers do
the mailing separately -- so nothing is sent to the user, which
matters when they just proved ownership via the OTP.

Also fix the retry gap this exposed. Confirmation was keyed on the
account being new, so a failed write was never retried and the
account stayed unconfirmed until an operator ran the backfill. It is
now keyed on the account being *unconfirmed*, so a returning user
whose earlier attempt failed is repaired on their next sign-in, while
already-confirmed accounts still cost no writes.

The backfill now confirms one account at a time through the same API
rather than a single set-based UPDATE: more round-trips, but it keeps
to the account-manager boundary and lets one bad account be reported
without abandoning the run. Failures are listed by DID and the script
exits non-zero. Enumerating accounts remains a direct read -- the
manager exposes getAccounts(dids) but no "every account" query -- and
is confined to this operator-invoked script.

Dropping the Kysely fakes removes the `as any` casts from the tests;
they now type against the local interface, so a signature change
breaks them instead of passing silently.

Verified against a real account.sqlite built by upstream's migrator:
unconfirmed accounts are confirmed, already-confirmed ones untouched,
empty-email skipped, re-runs find nothing, and zero email_token rows
are left behind.

Raised by CodeRabbit in review on PR #234.
Copilot AI review requested due to automatic review settings August 4, 2026 12:58
@railway-app
railway-app Bot temporarily deployed to ePDS / ePDS-pr-234 August 4, 2026 12:58 Destroyed

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated no new comments.

Suppressed comments (2)

packages/pds-core/src/lib/email-confirmed.ts:28

  • PR description says the write path "goes through the public, typed AccountManager.db" (direct UPDATE) to avoid deep imports, but the implementation here uses a mint-then-redeem flow (createEmailToken + confirmEmail) instead. Please align the PR description with the actual approach (or change the implementation to match the described direct-DB write), so operators/reviewers aren’t misled about the chosen mechanism and its side effects (token creation).
 * ## Why mint-then-redeem rather than writing the column
 *
 * AGENTS.md: "Do not directly read or modify `@atproto/pds` database
 * tables — use `pds.ctx.accountManager.*` methods." `createEmailToken`
 * and `confirmEmail` are both public `AccountManager` methods, and
 * together they are exactly the supported route to a confirmed email:
 * `confirmEmail` validates the token, deletes it, and sets
 * `emailConfirmedAt` in a single transaction, so no token row is left
 * behind.

packages/pds-core/package.json:10

  • The operator backfill script is configured to run via tsx src/backfill-email-confirmed.ts, but production builds/Docker images for pds-core only ship dist/ (no src/). That makes the documented pnpm --filter @certified-app/pds-core backfill:email-confirmed command fail in deployed environments. Prefer invoking the compiled entrypoint from dist/ so it works anywhere the service runs.
    "backfill:email-confirmed": "tsx src/backfill-email-confirmed.ts"

aspiers added a commit that referenced this pull request Aug 4, 2026
…dary

The merge analysis framed the HMAC callback purely as an authenticity
check. HYPER-219 (PR #234) showed the boundary also forces every
authentication *fact* to be re-serialised: the code that knows how the
user authenticated lives in auth-service, the API that records it lives
in pds-core, and nothing but an explicit signed field can carry the fact
between them.

The natural implementation instead inferred it -- a valid callback had
only ever followed an OTP, so arrival was treated as proof of control of
the address. That is correct for today's single sign-in flow and silently
wrong for the next one: a passkey flow would send a signed callback
carrying the email merely to locate the account, and the address would be
marked confirmed with nothing proved.

Add the bullet to the merge benefits, a worked example section, and a
note to the recommendation. The phasing is unchanged -- the integration
hazards still argue for single-origin first -- but the boundary's cost is
now known to recur per authentication mechanism rather than being a
fixed one-off, which raises the standing cost of not merging.

Also captures two details worth reusing at any such boundary: make
carried auth facts required rather than sentinel-defaulted, so an
omitting caller fails loudly instead of claiming a false negative; and
reset the fact when the subject is rebound, as the recovery path does
when it swaps a verified backup address for the primary.
aspiers added a commit that referenced this pull request Aug 4, 2026
pds-core inferred that a valid signed callback meant the user had
proved control of the email it carried. That held only because an
emailed one-time code was the sole route to such a callback, and the
assumption lived in a comment rather than in the payload.

A passkey or similar flow breaks it: that flow would legitimately sign
a callback carrying `email` merely to locate the account, having
proved nothing about the address. pds-core would then mark it
confirmed -- asserting email_verified: true to relying parties on no
evidence, and arming upstream's email-change verification gate on an
address nobody proved. The failure would be silent and in the
dangerous direction.

Carry the fact instead of inferring it. auth-service reads
better-auth's emailVerified where it already reads the session and
signs it into the callback as `email_verified`; pds-core records
confirmation only on an explicit '1'.

The field is required rather than sentinel-defaulted like handle and
client_id. The payload is positional, so a producer that omits it
signs a different payload and is rejected at the trust boundary. A
future flow whose author forgets it fails loudly instead of quietly
claiming verification. It is inside the HMAC, so it cannot be flipped
from '0' to '1' by anyone holding the URL.

The recovery path sets it false: it rebinds `email` from the verified
backup address to the account's primary, so the session's flag no
longer describes the address being signed.

Also repairs two expiry tests that hand-build the payload. They were
passing on a stale signature rather than on the timestamp check, so
they would have kept passing with expiry validation removed entirely.

Operators must deploy both services together; a mixed pair rejects
sign-in with "Invalid callback signature" until the rollout completes.

Raised by Adam in review of PR #234.
Copilot AI review requested due to automatic review settings August 4, 2026 21:20
aspiers added a commit that referenced this pull request Aug 4, 2026
…as errors

Three review points from PR #234.

Add an optional trailing argument to the backfill that scopes the run
to addresses containing it, case-insensitively -- "@gmail.com" for a
domain, a full address for one account. Operators can then work
through a deployment in batches or repair a single user instead of
being forced to process the whole table in one go. No argument still
means every account: "no filter" must never be read as "match
nothing", or a mistyped invocation would silently do nothing and look
like a clean run. The report names the filter, because otherwise
"0 account(s)" cannot be distinguished between nothing-left-to-do and
your-filter-matched-nothing.

Raise the failed-confirmation log from warn to error. Swallowing the
exception keeps the user signed in, but the account is left claiming
an unverified address to every relying party until some later sign-in
happens to succeed, and the operator has no other signal it occurred.
Self-healing is not the same as harmless, so it belongs at
error-level alerting.

Add End users to the changeset's audience list. The claim is
user-visible: once #233 lands, the PDS /account route stops telling
people to verify an address they have already confirmed.

Verified against a real account.sqlite built by upstream's migrator:
the domain filter matched mixed-case addresses, left non-matching
accounts untouched, a later unfiltered run picked up the remainder,
and no email_token rows were left behind.
@aspiers
aspiers force-pushed the adam/hyper-219-set-emailconfirmedat-on-epds-account-after-otp-verified branch from ec559a9 to a4ec9c0 Compare August 4, 2026 21:20
aspiers added a commit that referenced this pull request Aug 4, 2026
The changeset had grown to a full operator runbook: six bullets of
command syntax, flag semantics, exit codes and a caveat about
provisioning accounts outside the sign-in flow. None of that is
release-note material, and the writing-changesets skill says so
plainly -- "keep it as short as the change deserves", two to four
sentences per audience, and cut anything the reader does not act on.

Move the procedure to a "Backfilling Email Confirmation" section in
docs/deployment.md, alongside the other operator runbooks, where it
can be found by someone doing the task rather than only by someone
reading the changelog for the release that introduced it.

The changeset keeps one sentence per audience and points operators at
the doc: 23 lines down to 13.

Raised by Adam in review on PR #234.
@railway-app
railway-app Bot temporarily deployed to ePDS / ePDS-pr-234 August 4, 2026 21:20 Destroyed
@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Note

GitHub couldn't provide a complete incremental comparison for this pull request, so CodeRabbit is performing a full review instead. This review may take a little longer.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/pds-core/src/backfill-email-confirmed.ts`:
- Around line 75-77: Update the main().catch fatal-error handler to use the
project’s pino logger and log the original error object as structured metadata
via logger.error({ err }, ...), replacing the process.stderr.write(String(err))
call; preserve process.exit(1) after logging.
- Around line 53-56: Update the backfill flow around the account enumeration to
avoid accessing accountManager.db.db directly. Add and use a narrow enumeration
method on the account-manager API that returns the required did, email, and
emailConfirmedAt fields for BackfillCandidate, or establish the repository
exception if that boundary cannot be extended.

In `@packages/pds-core/src/lib/email-confirmed.ts`:
- Around line 48-67: The confirmation flow must bind verification to the email
proved by the callback, not only the account DID. In
packages/pds-core/src/lib/email-confirmed.ts lines 48-67, extend
EmailConfirmingAccountManager and confirmAccountEmail to accept the expected
email and call an upstream atomic operation that compares it before recording
confirmation; at lines 88-100, propagate that email through the best-effort
wrapper. In packages/pds-core/src/index.ts lines 497-506, pass the HMAC-bound
callback email to markEmailConfirmed(). In
packages/pds-core/src/lib/email-confirmed.ts lines 214-217, pass each backfill
candidate’s email and skip or report rows whose email changed since scanning.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 92711487-21d7-46da-a19f-35b660da6dba

📥 Commits

Reviewing files that changed from the base of the PR and between d535f3a and a4ec9c0.

📒 Files selected for processing (12)
  • .changeset/record-email-as-confirmed.md
  • docs/deployment.md
  • packages/auth-service/src/__tests__/build-epds-callback-url.test.ts
  • packages/auth-service/src/routes/choose-handle.ts
  • packages/auth-service/src/routes/complete.ts
  • packages/pds-core/package.json
  • packages/pds-core/src/__tests__/email-confirmed.test.ts
  • packages/pds-core/src/backfill-email-confirmed.ts
  • packages/pds-core/src/index.ts
  • packages/pds-core/src/lib/email-confirmed.ts
  • packages/shared/src/__tests__/crypto.test.ts
  • packages/shared/src/crypto.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/pds-core/package.json

Comment thread packages/pds-core/src/backfill-email-confirmed.ts
Comment thread packages/pds-core/src/backfill-email-confirmed.ts
Comment thread packages/pds-core/src/lib/email-confirmed.ts
aspiers and others added 10 commits August 12, 2026 00:10
Without this change, every ePDS account reports its email address as
unverified. Accounts are created by /oauth/epds-callback, which only
runs once auth-service has verified a one-time code sent to that
address, but upstream populates emailConfirmedAt solely from
confirmEmail()'s token flow -- a token the OTP flow never issues. The
column therefore stays null, so the email_verified claim is always
false, and upstream's requestEmailUpdate skips its confirmation-token
gate entirely because that gate only engages when emailConfirmedAt is
set.

Stamp emailConfirmedAt on newly created accounts, at the point where
both creation branches converge and the account is known to be new.
The write is best-effort: the user has already proven ownership of the
address, so a failed write is logged at warn rather than failing their
sign-in.

Write through the public, typed AccountManager.db rather than
upstream's setEmailConfirmedAt helper, which is not re-exported from
the package root and has no exports map -- reaching it means a deep
import into dist/ that nothing guarantees across upgrades. The emitted
statement is identical to upstream's.

Accounts predating this are handled by an opt-in backfill script
rather than a startup fixup. A null emailConfirmedAt cannot be proven
to mean "OTP-verified but unrecorded": ePDS does not block upstream's
com.atproto.server.createAccount XRPC route, so a deployment that
provisioned accounts by other means must not have those addresses
silently promoted to verified. Only the operator knows which case
applies, so they choose when to run it, with --dry-run to preview.

Verified against a real account.sqlite built by upstream's migrator:
dry run writes nothing, the real run stamps only unconfirmed accounts
with a genuine address, already-confirmed timestamps are preserved,
and re-running is a no-op.

Refs HYPER-219.
Coveralls flagged a -0.06% drop: backfill-email-confirmed.ts is an
entry point, so none of its lines are exercised by the suite. The
repo counts entry points rather than excluding them (index.ts sits at
0/372), so adding an exclusion would bend the project's coverage
convention to flatter this branch.

Move the two testable pieces -- --dry-run parsing and the completion
line -- into lib/email-confirmed.ts, leaving the script as env
loading, PDS.create and teardown. Both are worth asserting on their
own terms: a near-miss flag such as --dry must not be read as
--dry-run, since writing when the operator meant to preview is the
one unrecoverable mistake here, and a zero-row run must say so rather
than print nothing.

Line coverage 57.37% -> 57.45%; 1093 -> 1098 tests.
The reported `updated` count reused the earlier SELECT's row count,
which assumes nothing changes between the two statements. A sign-in
that stamps one of those rows in between leaves the UPDATE touching
fewer rows than were counted, and this number is the operator's only
evidence of what the script did.

Read Kysely's UpdateResult.numUpdatedRows via executeTakeFirst()
instead. The test double now mirrors the real surface -- SELECT via
execute(), UPDATE via executeTakeFirst() returning a bigint count --
and rejects an UPDATE sent to execute(), so the mock cannot drift
back into accepting an API the production code no longer uses.

Verified against a real account.sqlite built by upstream's migrator:
numUpdatedRows matches the rows actually written, and dry-run,
idempotent-rerun and skip-empty-email behaviour are unchanged.

Raised by Copilot in review on PR #234.
AGENTS.md is explicit: "Do not directly read or modify @atproto/pds
database tables -- use pds.ctx.accountManager.* methods." The Kysely
UPDATE this PR introduced violated that, on my mistaken claim that no
public API could do the job.

There is one. createEmailToken() and confirmEmail() are both public
AccountManager methods, and together they are the supported route to
a confirmed address: confirmEmail validates the token, deletes it and
sets emailConfirmedAt in a single transaction. createEmailToken only
inserts the row and returns the token -- upstream's XRPC handlers do
the mailing separately -- so nothing is sent to the user, which
matters when they just proved ownership via the OTP.

Also fix the retry gap this exposed. Confirmation was keyed on the
account being new, so a failed write was never retried and the
account stayed unconfirmed until an operator ran the backfill. It is
now keyed on the account being *unconfirmed*, so a returning user
whose earlier attempt failed is repaired on their next sign-in, while
already-confirmed accounts still cost no writes.

The backfill now confirms one account at a time through the same API
rather than a single set-based UPDATE: more round-trips, but it keeps
to the account-manager boundary and lets one bad account be reported
without abandoning the run. Failures are listed by DID and the script
exits non-zero. Enumerating accounts remains a direct read -- the
manager exposes getAccounts(dids) but no "every account" query -- and
is confined to this operator-invoked script.

Dropping the Kysely fakes removes the `as any` casts from the tests;
they now type against the local interface, so a signature change
breaks them instead of passing silently.

Verified against a real account.sqlite built by upstream's migrator:
unconfirmed accounts are confirmed, already-confirmed ones untouched,
empty-email skipped, re-runs find nothing, and zero email_token rows
are left behind.

Raised by CodeRabbit in review on PR #234.
pds-core inferred that a valid signed callback meant the user had
proved control of the email it carried. That held only because an
emailed one-time code was the sole route to such a callback, and the
assumption lived in a comment rather than in the payload.

A passkey or similar flow breaks it: that flow would legitimately sign
a callback carrying `email` merely to locate the account, having
proved nothing about the address. pds-core would then mark it
confirmed -- asserting email_verified: true to relying parties on no
evidence, and arming upstream's email-change verification gate on an
address nobody proved. The failure would be silent and in the
dangerous direction.

Carry the fact instead of inferring it. auth-service reads
better-auth's emailVerified where it already reads the session and
signs it into the callback as `email_verified`; pds-core records
confirmation only on an explicit '1'.

The field is required rather than sentinel-defaulted like handle and
client_id. The payload is positional, so a producer that omits it
signs a different payload and is rejected at the trust boundary. A
future flow whose author forgets it fails loudly instead of quietly
claiming verification. It is inside the HMAC, so it cannot be flipped
from '0' to '1' by anyone holding the URL.

The recovery path sets it false: it rebinds `email` from the verified
backup address to the account's primary, so the session's flag no
longer describes the address being signed.

Also repairs two expiry tests that hand-build the payload. They were
passing on a stale signature rather than on the timestamp check, so
they would have kept passing with expiry validation removed entirely.

Operators must deploy both services together; a mixed pair rejects
sign-in with "Invalid callback signature" until the rollout completes.

Raised by Adam in review of PR #234.
…as errors

Three review points from PR #234.

Add an optional trailing argument to the backfill that scopes the run
to addresses containing it, case-insensitively -- "@gmail.com" for a
domain, a full address for one account. Operators can then work
through a deployment in batches or repair a single user instead of
being forced to process the whole table in one go. No argument still
means every account: "no filter" must never be read as "match
nothing", or a mistyped invocation would silently do nothing and look
like a clean run. The report names the filter, because otherwise
"0 account(s)" cannot be distinguished between nothing-left-to-do and
your-filter-matched-nothing.

Raise the failed-confirmation log from warn to error. Swallowing the
exception keeps the user signed in, but the account is left claiming
an unverified address to every relying party until some later sign-in
happens to succeed, and the operator has no other signal it occurred.
Self-healing is not the same as harmless, so it belongs at
error-level alerting.

Add End users to the changeset's audience list. The claim is
user-visible: once #233 lands, the PDS /account route stops telling
people to verify an address they have already confirmed.

Verified against a real account.sqlite built by upstream's migrator:
the domain filter matched mixed-case addresses, left non-matching
accounts untouched, a later unfiltered run picked up the remainder,
and no email_token rows were left behind.
Two suppressed Copilot findings, both real.

The backfill script ran via `tsx src/backfill-email-confirmed.ts`, but
the runtime image copies only `dist/` and tsx is a devDependency
(Dockerfile.pds:36). The documented command would therefore fail in
exactly the deployed environment an operator needs it in. Point the
script at the compiled entrypoint, which tsconfig already emits since
it includes all of src/, and keep the tsx form as
`backfill:email-confirmed:dev` for local use. Verified
`node dist/backfill-email-confirmed.js` loads and reaches config
validation.

A callback missing `email_verified` failed as "Invalid callback
signature", since the field is inside the HMAC. Accurate but
misleading: during a mixed-version rollout the cause is an old
auth-service, not a secret mismatch, and that error sends an operator
hunting in the wrong place. Reject a missing or non-'0'/'1' value up
front with an explicit 400 naming the parameter, mirroring the
existing expired-vs-invalid split that exists for the same
diagnosability reason.
The changeset had grown to a full operator runbook: six bullets of
command syntax, flag semantics, exit codes and a caveat about
provisioning accounts outside the sign-in flow. None of that is
release-note material, and the writing-changesets skill says so
plainly -- "keep it as short as the change deserves", two to four
sentences per audience, and cut anything the reader does not act on.

Move the procedure to a "Backfilling Email Confirmation" section in
docs/deployment.md, alongside the other operator runbooks, where it
can be found by someone doing the task rather than only by someone
reading the changelog for the release that introduced it.

The changeset keeps one sentence per audience and points operators at
the doc: 23 lines down to 13.

Raised by Adam in review on PR #234.
Applies the three review suggestions verbatim.

Note this now differs from the other pending changesets, which start
lowercase after the bold label. Worth settling one way in the skill so
the generated changelog reads consistently.
The atproto upgrade in #233 changed both halves of the confirmation
call. `confirmEmail` takes positional arguments and now also takes the
address — `confirmEmail(did, email, token)` — and `Account.sub` became
`Account.did`. `createEmailToken` takes a `DidString`.

Upstream now compares the supplied address against the account's
current one and throws `InvalidEmail` on a mismatch. That closes the
TOCTOU gap CodeRabbit raised on this PR: pass the address whose control
was actually proved and the check becomes atomic and upstream-enforced,
rather than something this code has to re-derive.

So thread the proved address through instead of only the DID:

- the callback passes the HMAC-signed `email`, not the account's stored
  address, so a change between signing and confirmation is rejected;
- the backfill passes each candidate's scanned address, so a row whose
  email moved between scan and write is reported as a failure rather
  than confirmed on stale evidence.

The test fake records the address it receives, so a future signature
change breaks these tests rather than passing silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ception

`String(err)` collapsed the error to its message, discarding the type and
stack — exactly the context an operator needs when a one-off backfill dies.
Use the repo's `createLogger` so pino serialises `err` in full. `process.exit(1)`
is unchanged, so scripted runs still see the failure.

The account enumeration this script does was defended in a code comment, but
AGENTS.md states the account-manager boundary as an unqualified rule. Record
the exception where the rule lives instead: `AccountManager` offers only
`getAccount`, `getAccounts(dids)` and `getAccountByEmail`, all requiring the
identifier up front, so there is no supported way to list every account. Noted
as something to re-check on each `@atproto/pds` upgrade.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@aspiers

aspiers commented Aug 12, 2026

Copy link
Copy Markdown
Contributor Author

(comment generated by Claude Opus 5 via Claude Code)

The red run check here is a repo-wide CI outage, not a problem with this PR — filed as #246.

playwright install-deps is hanging on apt and burning the job's full 20-minute timeout before any test executes. Confirmed on unmodified main via a workflow_dispatch run against pr-base (31583701010), which fails with a byte-identical log tail, so no branch is implicated.

Everything else on this PR is green: 16 checks passing, 0 unresolved review threads, Coveralls +0.04% to 60.234%. Once #246 is sorted, run should need only a rerun.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants