Skip to content

feat: headless email-recovery endpoints (send/verify/check)#4

Merged
daveselfsurf merged 2 commits into
mainfrom
feat/headless-recovery
May 28, 2026
Merged

feat: headless email-recovery endpoints (send/verify/check)#4
daveselfsurf merged 2 commits into
mainfrom
feat/headless-recovery

Conversation

@daveselfsurf

Copy link
Copy Markdown
Collaborator

Summary

Adds headless account-recovery endpoints so first-party apps (linkname) can offer "sign in with your backup email" without redirecting users to the ePDS-hosted recovery page. Mirrors the existing /_internal/otp/* headless pattern.

Built and verified end-to-end on self.surf earlier (added a verified backup email, recovered with it, landed in the real account; negative case — accounts without a backup email don't see the recovery option).

Endpoints (all x-api-key auth, allowedOrigins + rateLimitPerHour enforced)

  • POST /_internal/recovery/send — body { backupEmail }. Sends an OTP to the address only if it's a verified backup on some account. Anti-enumeration: always returns { success: true }.
  • POST /_internal/recovery/verify — body { backupEmail, otp }. Verifies the code against the backup email, resolves the underlying primary account (via resolveRecoveryEmail), mints AT Proto session tokens via the existing handleLogin path, and returns the same { did, handle, accessJwt, refreshJwt } shape as /_internal/otp/verify.
  • POST /_internal/recovery/check — body { email } (primary email). Returns { hasRecovery: boolean } so a client can decide whether to surface a recovery option. Boolean-only — never the backup address or DID; false is intentionally ambiguous (no account or no verified backup).

Why this is needed now

linkname's production already has the recovery UI merged + deployed (it calls these endpoints). The ePDS side was verified on the server but never merged to main, so once self.surf runs main again, linkname's live recovery feature needs these endpoints present.

Reuse

  • getDidByBackupEmail (shared/db.ts), resolveRecoveryEmail (lib/resolve-recovery-email.ts), handleLogin (headless-otp.ts), the authenticateApiKey/checkAllowedOrigin/checkApiClientRateLimit guards (lib/headless-auth.ts), getBackupEmails (shared/db.ts).

Test plan

  • pnpm build, pnpm test (incl. headless-recovery.test.ts — auth/validation/anti-enumeration/InvalidCode/no-leak), pnpm lint — all green earlier
  • Staging: recovery login end-to-end on self.surf via linkname (positive + negative cases)
  • Re-confirm after merge: server on main has /_internal/recovery/{send,verify,check} live

🤖 Generated with Claude Code

daveselfsurf and others added 2 commits May 28, 2026 10:39
Add POST /_internal/recovery/send and /_internal/recovery/verify so
first-party headless clients (e.g. linkname) can offer "sign in with
backup email" without redirecting users to the ePDS-hosted recovery
page.

Mirrors the existing /_internal/otp/* pair: per-client x-api-key auth,
allowedOrigins + rateLimitPerHour enforcement, anti-enumeration on send
(always returns {success:true}). Verify keys the OTP to the backup
email, translates to the primary account via the existing
resolveRecoveryEmail helper, then mints AT Proto session tokens via the
existing handleLogin path — returning the same {did,handle,accessJwt,
refreshJwt} shape as /_internal/otp/verify.

Routes are added to headless-otp.ts to reuse the module-private
handleLogin/getPdsUrl helpers without exporting internals.

Tests cover the auth/validation surface (401 on bad key, 400 on missing
fields, anti-enumeration send, InvalidCode on bad OTP). The full
token-mint happy path needs a live pds-core and is verified on staging.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add POST /_internal/recovery/check so a headless client can decide
whether to surface a recovery option for the account being signed into.

Body { email } (primary email) -> { hasRecovery: boolean }. Returns ONLY
the boolean, never the backup address or DID. A false result is
intentionally ambiguous (no account OR no verified backup) so it does
not confirm account non-existence. Same x-api-key auth + allowedOrigins
+ rateLimitPerHour guards as the other headless endpoints.

The has-recovery happy path needs a reachable pds-core (getDidByEmail),
so it is covered by staging tests; unit tests cover auth, validation,
the no-account branch, and the no-leak response shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@daveselfsurf daveselfsurf merged commit f107c18 into main May 28, 2026
0 of 5 checks passed
@daveselfsurf daveselfsurf deleted the feat/headless-recovery branch May 28, 2026 19:57
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.

1 participant