feat: headless email-recovery endpoints (send/verify/check)#4
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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-keyauth, 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 (viaresolveRecoveryEmail), mints AT Proto session tokens via the existinghandleLoginpath, 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;falseis 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 runsmainagain, linkname's live recovery feature needs these endpoints present.Reuse
getDidByBackupEmail(shared/db.ts),resolveRecoveryEmail(lib/resolve-recovery-email.ts),handleLogin(headless-otp.ts), theauthenticateApiKey/checkAllowedOrigin/checkApiClientRateLimitguards (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 earliermainhas/_internal/recovery/{send,verify,check}live🤖 Generated with Claude Code