Loading docs/openapi.yaml +36 −11 Changes for docs/openapi.yaml: 36 added lines, 11 removed lines. Original line number Diff line number Diff line Loading @@ -2209,7 +2209,9 @@ paths: purpose. An address with no account returns the SAME 202 with no code minted, and the per-recipient cooldown is kept on that path too, so probing reveals nothing (existence is learnt only at the sanctioned /auth/options oracle). Gated on local_auth_enabled. An account that spent its daily wrong-code budget (10 per 24h, across every code) also gets the same 202 and no mail until the window ends. Gated on local_auth_enabled. x-felis-face: [external] x-felis-tier: public security: [] Loading Loading @@ -2268,7 +2270,10 @@ paths: under the login purpose, and on success mints a host-only felis_session. An unknown address, a wrong or expired code, and an attempt-exhausted code all return the IDENTICAL 400 invalid_code, so the door is not an existence or lockout oracle. Staff are refused (403) — but only AFTER a valid code is lockout oracle. The 10th wrong code in 24h locks the door for that account until the window ends (the right code then also reads as invalid_code); the owner is told by mail once, and the lock is audited as auth.otp.locked. Staff are refused (403) — but only AFTER a valid code is redeemed, so only the account owner can ever reach that refusal. x-felis-face: [external] x-felis-tier: public Loading Loading @@ -2324,7 +2329,8 @@ paths: staff address, opens an op_login request, and mails a one-time code under the op_login purpose, returning the request handle the browser polls. A non-staff or unknown address gets the SAME 202 with a random, non-persisted handle and no mail, so this never becomes a staff-enumeration oracle. Gated on mail, so this never becomes a staff-enumeration oracle. A staff account that spent its daily wrong-code budget gets the same neutral 202. Gated on local_auth_enabled. x-felis-face: [external] x-felis-tier: public Loading Loading @@ -2415,7 +2421,7 @@ paths: Public, pre-session final leg: mints a host-only staff session only when BOTH factors have landed — the request is approved-and-live AND the mailed code verifies. Every failure (unknown handle, not-yet-approved, wrong or locked code, lost race) collapses into one uniform 400 op_login_invalid, so a code-less an account past its daily wrong-code budget, lost race) collapses into one uniform 400 op_login_invalid, so a code-less caller learns nothing. Admin is re-asserted before the session is issued. x-felis-face: [external] x-felis-tier: public Loading Loading @@ -3768,6 +3774,14 @@ paths: schema: { $ref: '#/components/schemas/Error' } '401': $ref: '#/components/responses/Unauthorized' '429': description: >- Resend requested before the cooldown elapsed (otp_resend_cooldown); or the account spent its daily wrong-code budget (otp_account_locked, with Retry-After). content: application/json: schema: { $ref: '#/components/schemas/Error' } '502': $ref: '#/components/responses/MailUndeliverable' Loading @@ -3779,8 +3793,10 @@ paths: description: > Consumes a previously delivered code for the authenticated principal. On success the user's email is written and email_verified is set true. Too many incorrect attempts lock the code (429); an unknown, expired, consumed, or mismatched code is a 400. incorrect attempts lock the code (429 otp_locked); 10 wrong codes in 24h, counted across every code, lock the account's email-code door until the window ends (429 otp_account_locked with Retry-After). An unknown, expired, consumed, or mismatched code is a 400. x-felis-face: [external] x-felis-tier: app security: [{ accessJWT: [] }] Loading Loading @@ -3812,7 +3828,9 @@ paths: '401': $ref: '#/components/responses/Unauthorized' '429': description: Too many incorrect attempts; the code is locked. description: >- Too many incorrect attempts on this code (otp_locked), or the account's daily wrong-code budget is spent (otp_account_locked, with Retry-After). content: application/json: schema: { $ref: '#/components/schemas/Error' } Loading Loading @@ -4070,7 +4088,10 @@ paths: application/json: schema: { $ref: '#/components/schemas/Error' } '429': description: Resend requested before the cooldown elapsed. description: >- Resend requested before the cooldown elapsed (otp_resend_cooldown), or the account's daily wrong-code budget is spent (otp_account_locked, with Retry-After). content: application/json: schema: { $ref: '#/components/schemas/Error' } Loading @@ -4085,8 +4106,9 @@ paths: description: > Consumes the fresh migrate-purpose email code for the caller's initiated migration and advances it to confirmed with confirm_factor email_otp. Too many wrong attempts lock the code (429 otp_locked); an unknown, expired, consumed, or mismatched code is a 400 invalid_code. wrong attempts lock the code (429 otp_locked), and 10 wrong codes in 24h lock the account's email-code door (429 otp_account_locked with Retry-After); an unknown, expired, consumed, or mismatched code is a 400 invalid_code. x-felis-face: [external] x-felis-tier: app security: [{ accessJWT: [] }] Loading Loading @@ -4127,7 +4149,10 @@ paths: application/json: schema: { $ref: '#/components/schemas/Error' } '429': description: The code is locked after too many wrong attempts (otp_locked). description: >- The code is locked after too many wrong attempts (otp_locked), or the account's daily wrong-code budget is spent (otp_account_locked, with Retry-After). content: application/json: schema: { $ref: '#/components/schemas/Error' } Loading internal/api/api_test.go +42 −4 Changes for internal/api/api_test.go: 42 added lines, 4 removed lines. Original line number Diff line number Diff line Loading @@ -74,6 +74,8 @@ type fakeRepo struct { // player email OTPs (spec §B2). Keyed by row id; the verify path scans for the // newest live (user, purpose) just as the PG query does. otps map[string]*fakeEmailOTP // otpBudget mirrors otp_failure_windows, keyed user|purpose. otpBudget map[string]*fakeOTPBudget // op-login requests (spec §B op-login). opLogins mirrors op_login_requests keyed // by id; the in-game approve/finish paths mutate status/consumed in place, and // tests plant rows directly to drive the status/finish/pending-list paths. Loading Loading @@ -151,6 +153,37 @@ type fakeDataHold struct { expiresAt time.Time } // fakeOTPBudget mirrors an otp_failure_windows row. type fakeOTPBudget struct { windowStart time.Time failures int } // OTPLockedUntil mirrors PGRepo.OTPLockedUntil through the shared otpLockEnd rule. func (f *fakeRepo) OTPLockedUntil(_ context.Context, userID, purpose string, now time.Time) (time.Time, error) { b := f.otpBudget[userID+"|"+purpose] if b == nil { return time.Time{}, nil } return otpLockEnd(b.windowStart, b.failures, now), nil } // chargeOTP mirrors chargeOTPMismatch: one wrong guess on the code and the budget. func (f *fakeRepo) chargeOTP(live *fakeEmailOTP, now time.Time) error { live.attempts++ key := live.userID + "|" + live.purpose b := f.otpBudget[key] if b == nil || !b.windowStart.Add(otpFailureWindow).After(now) { b = &fakeOTPBudget{windowStart: now} f.otpBudget[key] = b } b.failures++ if b.failures == otpFailureBudget { return &OTPAccountLockedError{Until: b.windowStart.Add(otpFailureWindow), JustLocked: true} } return ErrOTPInvalid } // fakeEmailOTP mirrors an email_otps row: only the code hash is held (never the // digits), attempts caps brute force, consumed marks single-use, and createdAt // orders the newest-live lookup. Loading Loading @@ -227,6 +260,7 @@ func newFakeRepo() *fakeRepo { sessions: map[string]*fakeSession{}, settings: map[string][]byte{}, otps: map[string]*fakeEmailOTP{}, otpBudget: map[string]*fakeOTPBudget{}, opLogins: map[string]*fakeOpLogin{}, setupTokens: map[string]fakeSetupToken{}, blacklist: map[string]bool{}, Loading Loading @@ -373,12 +407,14 @@ func (f *fakeRepo) VerifyEmailOTP(_ context.Context, userID, purpose, codeHash s if !live.expiresAt.After(now) { return "", ErrOTPInvalid } if until, _ := f.OTPLockedUntil(context.Background(), userID, purpose, now); !until.IsZero() { return "", &OTPAccountLockedError{Until: until} } if live.attempts >= otpMaxAttempts { return "", ErrOTPLocked } if live.codeHash != codeHash { live.attempts++ // a typo costs an attempt but does not consume the code return "", ErrOTPInvalid return "", f.chargeOTP(live, now) // a typo costs an attempt but does not consume the code } // A DIFFERENT verified holder of the same address → ErrEmailTaken, code left // live — mirrors PGRepo's guard + the users_verified_email_unique index. Loading Loading @@ -1319,12 +1355,14 @@ func (f *fakeRepo) ConsumeLoginEmailOTP(_ context.Context, userID, purpose, code if live == nil || !live.expiresAt.After(now) { return ErrOTPInvalid } if until, _ := f.OTPLockedUntil(context.Background(), userID, purpose, now); !until.IsZero() { return &OTPAccountLockedError{Until: until} } if live.attempts >= otpMaxAttempts { return ErrOTPLocked } if live.codeHash != codeHash { live.attempts++ // a typo costs an attempt but does not consume the code return ErrOTPInvalid return f.chargeOTP(live, now) // a typo costs an attempt but does not consume the code } live.consumed = true return nil Loading internal/api/errors.go +20 −0 Changes for internal/api/errors.go: 20 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -6,6 +6,7 @@ import ( "fmt" "log" "net/http" "time" ) // Sentinel errors the repository and cluster layers return so handlers can map Loading Loading @@ -44,6 +45,11 @@ var ( // can answer 429 (back off / request a new code) rather than inviting another // guess against a code that will never accept one. ErrOTPLocked = errors.New("email code locked: too many attempts") // ErrOTPAccountLocked means the (user, purpose) has spent its wrong-code budget // for the current window (otpFailureBudget): every code for that door is refused, // the right one included, until the window ends. The repo returns it as an // *OTPAccountLockedError carrying the end of the lock. ErrOTPAccountLocked = errors.New("email codes locked for this account: too many wrong codes") // ErrPasskeyChallengeInvalid means a passkey enrollment ceremony cannot be // finished: there is no live (unconsumed, unexpired) challenge for the caller and // purpose (Phase 6 WebAuthn bind). Like ErrOTPInvalid it is a client error — the Loading Loading @@ -102,6 +108,20 @@ func (e *MaintenanceBusyError) Error() string { func (e *MaintenanceBusyError) Is(target error) bool { return target == ErrMaintenanceInProgress } // OTPAccountLockedError is ErrOTPAccountLocked with its detail. JustLocked is set // only on the wrong guess that spent the budget, so the handler notifies and // audits the lock exactly once. type OTPAccountLockedError struct { Until time.Time JustLocked bool } func (e *OTPAccountLockedError) Error() string { return ErrOTPAccountLocked.Error() + " until " + e.Until.UTC().Format(time.RFC3339) } func (e *OTPAccountLockedError) Is(target error) bool { return target == ErrOTPAccountLocked } // apiError is a handler-level error carrying an HTTP status and a stable, // machine-readable code. The error envelope matches the platform convention: // Loading internal/api/handlers_account_migrate.go +12 −0 Changes for internal/api/handlers_account_migrate.go: 12 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -206,6 +206,13 @@ func (a *API) handleMigrateConfirmOTPStart(w http.ResponseWriter, r *http.Reques } // Per-recipient cooldown, namespaced apart from the other OTP doors so they never // perturb each other's throttle. if until, err := a.Repo.OTPLockedUntil(r.Context(), p.UserID, otpPurposeMigrate, a.now()); err != nil { writeError(w, r, err) return } else if !until.IsZero() { writeOTPAccountLocked(w, r, until, a.now()) return } emailKey := "migrate:confirm:" + strings.ToLower(p.Email) lim := a.otpLimiter() emailAt, ok := lim.reserve(emailKey, otpResendCooldown) Loading Loading @@ -267,7 +274,12 @@ func (a *API) handleMigrateConfirmOTPVerify(w http.ResponseWriter, r *http.Reque if _, ok := a.requireInitiatedMigration(w, r, p.UserID); !ok { return } var lock *OTPAccountLockedError switch err := a.Repo.ConsumeLoginEmailOTP(r.Context(), p.UserID, otpPurposeMigrate, otpCodeHash(code), a.now()); { case errors.As(err, &lock): a.noteOTPLock(r, err, p.UserID, otpPurposeMigrate) writeOTPAccountLocked(w, r, lock.Until, a.now()) return case errors.Is(err, ErrOTPLocked): writeError(w, r, newError(http.StatusTooManyRequests, "otp_locked", "too many incorrect attempts; request a new code")) Loading internal/api/handlers_auth_email.go +16 −1 Changes for internal/api/handlers_auth_email.go: 16 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -126,6 +126,19 @@ func (a *API) handleLoginEmailStart(w http.ResponseWriter, r *http.Request) { return } // A locked door (wrong-code budget spent) gets the same neutral 202 and no // mail: the owner was told by the lock notice, and a distinct answer here // would tell a prober the address has an account. switch until, err := a.Repo.OTPLockedUntil(r.Context(), u.ID, otpPurposeLogin, a.now()); { case err != nil: writeError(w, r, err) return case !until.IsZero(): committed = true writeJSON(w, http.StatusAccepted, map[string]any{"sent": true, "expires_at": expiresAt.UTC()}) return } code, err := newEmailOTP() if err != nil { writeError(w, r, err) Loading Loading @@ -220,7 +233,9 @@ func (a *API) handleLoginEmailVerify(w http.ResponseWriter, r *http.Request) { // verified; touching the row here would let a stale OTP-snapshot address overwrite // the live one and could 500 a correct code on a spurious collision. switch err := a.Repo.ConsumeLoginEmailOTP(r.Context(), u.ID, otpPurposeLogin, otpCodeHash(code), a.now()); { case errors.Is(err, ErrOTPInvalid), errors.Is(err, ErrOTPLocked): case errors.Is(err, ErrOTPInvalid), errors.Is(err, ErrOTPLocked), errors.Is(err, ErrOTPAccountLocked): // The account lock answers the same way; its owner hears about it by mail. a.noteOTPLock(r, err, u.ID, otpPurposeLogin) // Both a wrong/expired code and an attempt-exhausted one return the SAME 400 // invalid_code, byte-identical to the unknown-account branch above. Surfacing // otp_locked as a distinct 429 (as the authenticated onboarding door does) would Loading Loading
docs/openapi.yaml +36 −11 Changes for docs/openapi.yaml: 36 added lines, 11 removed lines. Original line number Diff line number Diff line Loading @@ -2209,7 +2209,9 @@ paths: purpose. An address with no account returns the SAME 202 with no code minted, and the per-recipient cooldown is kept on that path too, so probing reveals nothing (existence is learnt only at the sanctioned /auth/options oracle). Gated on local_auth_enabled. An account that spent its daily wrong-code budget (10 per 24h, across every code) also gets the same 202 and no mail until the window ends. Gated on local_auth_enabled. x-felis-face: [external] x-felis-tier: public security: [] Loading Loading @@ -2268,7 +2270,10 @@ paths: under the login purpose, and on success mints a host-only felis_session. An unknown address, a wrong or expired code, and an attempt-exhausted code all return the IDENTICAL 400 invalid_code, so the door is not an existence or lockout oracle. Staff are refused (403) — but only AFTER a valid code is lockout oracle. The 10th wrong code in 24h locks the door for that account until the window ends (the right code then also reads as invalid_code); the owner is told by mail once, and the lock is audited as auth.otp.locked. Staff are refused (403) — but only AFTER a valid code is redeemed, so only the account owner can ever reach that refusal. x-felis-face: [external] x-felis-tier: public Loading Loading @@ -2324,7 +2329,8 @@ paths: staff address, opens an op_login request, and mails a one-time code under the op_login purpose, returning the request handle the browser polls. A non-staff or unknown address gets the SAME 202 with a random, non-persisted handle and no mail, so this never becomes a staff-enumeration oracle. Gated on mail, so this never becomes a staff-enumeration oracle. A staff account that spent its daily wrong-code budget gets the same neutral 202. Gated on local_auth_enabled. x-felis-face: [external] x-felis-tier: public Loading Loading @@ -2415,7 +2421,7 @@ paths: Public, pre-session final leg: mints a host-only staff session only when BOTH factors have landed — the request is approved-and-live AND the mailed code verifies. Every failure (unknown handle, not-yet-approved, wrong or locked code, lost race) collapses into one uniform 400 op_login_invalid, so a code-less an account past its daily wrong-code budget, lost race) collapses into one uniform 400 op_login_invalid, so a code-less caller learns nothing. Admin is re-asserted before the session is issued. x-felis-face: [external] x-felis-tier: public Loading Loading @@ -3768,6 +3774,14 @@ paths: schema: { $ref: '#/components/schemas/Error' } '401': $ref: '#/components/responses/Unauthorized' '429': description: >- Resend requested before the cooldown elapsed (otp_resend_cooldown); or the account spent its daily wrong-code budget (otp_account_locked, with Retry-After). content: application/json: schema: { $ref: '#/components/schemas/Error' } '502': $ref: '#/components/responses/MailUndeliverable' Loading @@ -3779,8 +3793,10 @@ paths: description: > Consumes a previously delivered code for the authenticated principal. On success the user's email is written and email_verified is set true. Too many incorrect attempts lock the code (429); an unknown, expired, consumed, or mismatched code is a 400. incorrect attempts lock the code (429 otp_locked); 10 wrong codes in 24h, counted across every code, lock the account's email-code door until the window ends (429 otp_account_locked with Retry-After). An unknown, expired, consumed, or mismatched code is a 400. x-felis-face: [external] x-felis-tier: app security: [{ accessJWT: [] }] Loading Loading @@ -3812,7 +3828,9 @@ paths: '401': $ref: '#/components/responses/Unauthorized' '429': description: Too many incorrect attempts; the code is locked. description: >- Too many incorrect attempts on this code (otp_locked), or the account's daily wrong-code budget is spent (otp_account_locked, with Retry-After). content: application/json: schema: { $ref: '#/components/schemas/Error' } Loading Loading @@ -4070,7 +4088,10 @@ paths: application/json: schema: { $ref: '#/components/schemas/Error' } '429': description: Resend requested before the cooldown elapsed. description: >- Resend requested before the cooldown elapsed (otp_resend_cooldown), or the account's daily wrong-code budget is spent (otp_account_locked, with Retry-After). content: application/json: schema: { $ref: '#/components/schemas/Error' } Loading @@ -4085,8 +4106,9 @@ paths: description: > Consumes the fresh migrate-purpose email code for the caller's initiated migration and advances it to confirmed with confirm_factor email_otp. Too many wrong attempts lock the code (429 otp_locked); an unknown, expired, consumed, or mismatched code is a 400 invalid_code. wrong attempts lock the code (429 otp_locked), and 10 wrong codes in 24h lock the account's email-code door (429 otp_account_locked with Retry-After); an unknown, expired, consumed, or mismatched code is a 400 invalid_code. x-felis-face: [external] x-felis-tier: app security: [{ accessJWT: [] }] Loading Loading @@ -4127,7 +4149,10 @@ paths: application/json: schema: { $ref: '#/components/schemas/Error' } '429': description: The code is locked after too many wrong attempts (otp_locked). description: >- The code is locked after too many wrong attempts (otp_locked), or the account's daily wrong-code budget is spent (otp_account_locked, with Retry-After). content: application/json: schema: { $ref: '#/components/schemas/Error' } Loading
internal/api/api_test.go +42 −4 Changes for internal/api/api_test.go: 42 added lines, 4 removed lines. Original line number Diff line number Diff line Loading @@ -74,6 +74,8 @@ type fakeRepo struct { // player email OTPs (spec §B2). Keyed by row id; the verify path scans for the // newest live (user, purpose) just as the PG query does. otps map[string]*fakeEmailOTP // otpBudget mirrors otp_failure_windows, keyed user|purpose. otpBudget map[string]*fakeOTPBudget // op-login requests (spec §B op-login). opLogins mirrors op_login_requests keyed // by id; the in-game approve/finish paths mutate status/consumed in place, and // tests plant rows directly to drive the status/finish/pending-list paths. Loading Loading @@ -151,6 +153,37 @@ type fakeDataHold struct { expiresAt time.Time } // fakeOTPBudget mirrors an otp_failure_windows row. type fakeOTPBudget struct { windowStart time.Time failures int } // OTPLockedUntil mirrors PGRepo.OTPLockedUntil through the shared otpLockEnd rule. func (f *fakeRepo) OTPLockedUntil(_ context.Context, userID, purpose string, now time.Time) (time.Time, error) { b := f.otpBudget[userID+"|"+purpose] if b == nil { return time.Time{}, nil } return otpLockEnd(b.windowStart, b.failures, now), nil } // chargeOTP mirrors chargeOTPMismatch: one wrong guess on the code and the budget. func (f *fakeRepo) chargeOTP(live *fakeEmailOTP, now time.Time) error { live.attempts++ key := live.userID + "|" + live.purpose b := f.otpBudget[key] if b == nil || !b.windowStart.Add(otpFailureWindow).After(now) { b = &fakeOTPBudget{windowStart: now} f.otpBudget[key] = b } b.failures++ if b.failures == otpFailureBudget { return &OTPAccountLockedError{Until: b.windowStart.Add(otpFailureWindow), JustLocked: true} } return ErrOTPInvalid } // fakeEmailOTP mirrors an email_otps row: only the code hash is held (never the // digits), attempts caps brute force, consumed marks single-use, and createdAt // orders the newest-live lookup. Loading Loading @@ -227,6 +260,7 @@ func newFakeRepo() *fakeRepo { sessions: map[string]*fakeSession{}, settings: map[string][]byte{}, otps: map[string]*fakeEmailOTP{}, otpBudget: map[string]*fakeOTPBudget{}, opLogins: map[string]*fakeOpLogin{}, setupTokens: map[string]fakeSetupToken{}, blacklist: map[string]bool{}, Loading Loading @@ -373,12 +407,14 @@ func (f *fakeRepo) VerifyEmailOTP(_ context.Context, userID, purpose, codeHash s if !live.expiresAt.After(now) { return "", ErrOTPInvalid } if until, _ := f.OTPLockedUntil(context.Background(), userID, purpose, now); !until.IsZero() { return "", &OTPAccountLockedError{Until: until} } if live.attempts >= otpMaxAttempts { return "", ErrOTPLocked } if live.codeHash != codeHash { live.attempts++ // a typo costs an attempt but does not consume the code return "", ErrOTPInvalid return "", f.chargeOTP(live, now) // a typo costs an attempt but does not consume the code } // A DIFFERENT verified holder of the same address → ErrEmailTaken, code left // live — mirrors PGRepo's guard + the users_verified_email_unique index. Loading Loading @@ -1319,12 +1355,14 @@ func (f *fakeRepo) ConsumeLoginEmailOTP(_ context.Context, userID, purpose, code if live == nil || !live.expiresAt.After(now) { return ErrOTPInvalid } if until, _ := f.OTPLockedUntil(context.Background(), userID, purpose, now); !until.IsZero() { return &OTPAccountLockedError{Until: until} } if live.attempts >= otpMaxAttempts { return ErrOTPLocked } if live.codeHash != codeHash { live.attempts++ // a typo costs an attempt but does not consume the code return ErrOTPInvalid return f.chargeOTP(live, now) // a typo costs an attempt but does not consume the code } live.consumed = true return nil Loading
internal/api/errors.go +20 −0 Changes for internal/api/errors.go: 20 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -6,6 +6,7 @@ import ( "fmt" "log" "net/http" "time" ) // Sentinel errors the repository and cluster layers return so handlers can map Loading Loading @@ -44,6 +45,11 @@ var ( // can answer 429 (back off / request a new code) rather than inviting another // guess against a code that will never accept one. ErrOTPLocked = errors.New("email code locked: too many attempts") // ErrOTPAccountLocked means the (user, purpose) has spent its wrong-code budget // for the current window (otpFailureBudget): every code for that door is refused, // the right one included, until the window ends. The repo returns it as an // *OTPAccountLockedError carrying the end of the lock. ErrOTPAccountLocked = errors.New("email codes locked for this account: too many wrong codes") // ErrPasskeyChallengeInvalid means a passkey enrollment ceremony cannot be // finished: there is no live (unconsumed, unexpired) challenge for the caller and // purpose (Phase 6 WebAuthn bind). Like ErrOTPInvalid it is a client error — the Loading Loading @@ -102,6 +108,20 @@ func (e *MaintenanceBusyError) Error() string { func (e *MaintenanceBusyError) Is(target error) bool { return target == ErrMaintenanceInProgress } // OTPAccountLockedError is ErrOTPAccountLocked with its detail. JustLocked is set // only on the wrong guess that spent the budget, so the handler notifies and // audits the lock exactly once. type OTPAccountLockedError struct { Until time.Time JustLocked bool } func (e *OTPAccountLockedError) Error() string { return ErrOTPAccountLocked.Error() + " until " + e.Until.UTC().Format(time.RFC3339) } func (e *OTPAccountLockedError) Is(target error) bool { return target == ErrOTPAccountLocked } // apiError is a handler-level error carrying an HTTP status and a stable, // machine-readable code. The error envelope matches the platform convention: // Loading
internal/api/handlers_account_migrate.go +12 −0 Changes for internal/api/handlers_account_migrate.go: 12 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -206,6 +206,13 @@ func (a *API) handleMigrateConfirmOTPStart(w http.ResponseWriter, r *http.Reques } // Per-recipient cooldown, namespaced apart from the other OTP doors so they never // perturb each other's throttle. if until, err := a.Repo.OTPLockedUntil(r.Context(), p.UserID, otpPurposeMigrate, a.now()); err != nil { writeError(w, r, err) return } else if !until.IsZero() { writeOTPAccountLocked(w, r, until, a.now()) return } emailKey := "migrate:confirm:" + strings.ToLower(p.Email) lim := a.otpLimiter() emailAt, ok := lim.reserve(emailKey, otpResendCooldown) Loading Loading @@ -267,7 +274,12 @@ func (a *API) handleMigrateConfirmOTPVerify(w http.ResponseWriter, r *http.Reque if _, ok := a.requireInitiatedMigration(w, r, p.UserID); !ok { return } var lock *OTPAccountLockedError switch err := a.Repo.ConsumeLoginEmailOTP(r.Context(), p.UserID, otpPurposeMigrate, otpCodeHash(code), a.now()); { case errors.As(err, &lock): a.noteOTPLock(r, err, p.UserID, otpPurposeMigrate) writeOTPAccountLocked(w, r, lock.Until, a.now()) return case errors.Is(err, ErrOTPLocked): writeError(w, r, newError(http.StatusTooManyRequests, "otp_locked", "too many incorrect attempts; request a new code")) Loading
internal/api/handlers_auth_email.go +16 −1 Changes for internal/api/handlers_auth_email.go: 16 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -126,6 +126,19 @@ func (a *API) handleLoginEmailStart(w http.ResponseWriter, r *http.Request) { return } // A locked door (wrong-code budget spent) gets the same neutral 202 and no // mail: the owner was told by the lock notice, and a distinct answer here // would tell a prober the address has an account. switch until, err := a.Repo.OTPLockedUntil(r.Context(), u.ID, otpPurposeLogin, a.now()); { case err != nil: writeError(w, r, err) return case !until.IsZero(): committed = true writeJSON(w, http.StatusAccepted, map[string]any{"sent": true, "expires_at": expiresAt.UTC()}) return } code, err := newEmailOTP() if err != nil { writeError(w, r, err) Loading Loading @@ -220,7 +233,9 @@ func (a *API) handleLoginEmailVerify(w http.ResponseWriter, r *http.Request) { // verified; touching the row here would let a stale OTP-snapshot address overwrite // the live one and could 500 a correct code on a spurious collision. switch err := a.Repo.ConsumeLoginEmailOTP(r.Context(), u.ID, otpPurposeLogin, otpCodeHash(code), a.now()); { case errors.Is(err, ErrOTPInvalid), errors.Is(err, ErrOTPLocked): case errors.Is(err, ErrOTPInvalid), errors.Is(err, ErrOTPLocked), errors.Is(err, ErrOTPAccountLocked): // The account lock answers the same way; its owner hears about it by mail. a.noteOTPLock(r, err, u.ID, otpPurposeLogin) // Both a wrong/expired code and an attempt-exhausted one return the SAME 400 // invalid_code, byte-identical to the unknown-account branch above. Surfacing // otp_locked as a distinct 429 (as the authenticated onboarding door does) would Loading