Files
Felis/internal/api/errors.go
T
Lemon-miaow b6ef27cd2d fix(auth): enforce the verified-email uniqueness that email login assumes
The design has claimed since migration 0010 that at most one account can hold
a PROVEN email address, with ErrEmailTaken as the 409 a second verifier sees.
Neither half ever shipped: no migration created users_verified_email_unique,
and VerifyEmailOTP had no guard at all — the sentinel was defined but never
returned, so two accounts could both verify one address. The damage is not
cosmetic: the pre-session login resolves accounts BY verified email, so the
duplicate decided which identity a mailed sign-in code belonged to.

- Migration 0020 creates the partial unique index (lower(email) WHERE
  email_verified) the comments have been citing — the database-level backstop.
- VerifyEmailOTP now refuses the take-over with ErrEmailTaken BEFORE consuming
  the code (the address, not the code, is the problem), charges no attempt,
  and maps a lost cross-user race (unique violation) to the same answer.
- The verify handler answers 409 email_taken instead of a generic 500.

Covered by the pgint suite (sequential double-verify refused with the code
still live, a direct duplicate write still loses to the index, the refused
account can still prove its own address) and a hermetic 409 case.
2026-09-23 03:19:35 +08:00

136 lines
7.1 KiB
Go

package api
import (
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
)
// Sentinel errors the repository and cluster layers return so handlers can map
// domain outcomes onto HTTP status codes without leaking driver details.
var (
// ErrNotFound means the requested server / record does not exist.
ErrNotFound = errors.New("not found")
// ErrConflict means an atomic precondition failed (e.g. claim lost the race).
ErrConflict = errors.New("conflict")
// ErrLinkCodeInvalid means an account-link code is unknown or expired (spec
// §10). It is a client error (the verify endpoint exists; the code is bad), so
// handlers map it to 400, not 404.
ErrLinkCodeInvalid = errors.New("link code invalid or expired")
// ErrConsoleUnavailable means the RCON write channel could not be reached —
// the dial timed out, was refused, or the password was rejected (spec §8).
// Because readiness IS an RCON probe (spec §141: phase=Running ⟺ RCON
// answers), a reachable failure here is a transient/racy "the server isn't
// actually up", not a server bug. Handlers map it to 503, not 500, so the
// caller is told to wake/retry rather than shown an opaque internal error.
ErrConsoleUnavailable = errors.New("server console is unavailable")
// ErrOTPInvalid means an email one-time code is unknown, expired, already
// consumed, or did not match (spec §B2 onboarding). Like ErrLinkCodeInvalid it
// is a client error — the verify endpoint exists; the code is bad — so handlers
// map it to 400, not 404. A wrong-but-not-yet-locked guess collapses to it too,
// so the response never distinguishes "no such code" from "wrong digits".
ErrOTPInvalid = errors.New("email code invalid or expired")
// ErrOTPLocked means the live email code has exhausted its attempt budget: too
// many wrong guesses (spec §B2). It is distinct from ErrOTPInvalid so handlers
// 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")
// 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
// finish endpoint exists; the ceremony state is gone (never begun, already
// consumed, or expired) — so handlers map it to 400, not 404.
ErrPasskeyChallengeInvalid = errors.New("passkey challenge invalid or expired")
// ErrPlayerBindForbidden means a public Bind-Code redemption resolved to a STAFF
// account (role=admin), which the player-console bootstrap refuses (console-tier
// access model). Operators authenticate at op.console behind Zero Trust, never via
// the account-less console.<root_domain> door, so the public bootstrap provably
// never mints a session for an admin identity. It is distinct from ErrConflict so
// the handler answers 403 (wrong door) rather than 409 (already linked).
ErrPlayerBindForbidden = errors.New("bind code belongs to a staff account")
// ErrEmailTaken means a verified email would collide with another account's
// already-verified address (spec §B email-first login foundation; the
// users_verified_email_unique index ships in migration 0020).
// VerifyEmailOTP returns it — WITHOUT consuming the code, since the address, not
// the code, is the problem — when a DIFFERENT user has already proven the same
// address case-insensitively. It is the clean, application-level counterpart of
// the users_verified_email_unique index: a sequential double-verify meets this
// guard and gets a 409 instead of a raw unique-violation 500. Distinct from
// ErrConflict so the message can name the cause (the email is spoken for).
ErrEmailTaken = errors.New("email already verified on another account")
// ErrTooManyDiscoverableChallenges means the non-user-keyed discoverable ("usernameless")
// login challenge store is at its hard cap of live rows (task #40, migration 0013).
// Unlike the user-keyed enrollment/login challenges — which self-bound via a per-user
// supersede — a from-zero begin has no principal to key a fair per-caller limit on, so the
// table is capped globally and a begin over the cap is refused. Distinct from the other
// sentinels so the handler answers 429 (a transient "too busy, retry" — the cap self-clears
// as challenges expire), never a 400 that invites an immediate retry.
ErrTooManyDiscoverableChallenges = errors.New("too many discoverable login challenges in flight")
)
// apiError is a handler-level error carrying an HTTP status and a stable,
// machine-readable code. The error envelope matches the platform convention:
//
// {"error": {"code": "...", "message": "...", "request_id": "..."}}
type apiError struct {
status int
code string
msg string
}
func (e *apiError) Error() string { return e.msg }
// newError builds an apiError with a formatted message.
func newError(status int, code, format string, a ...any) *apiError {
return &apiError{status: status, code: code, msg: fmt.Sprintf(format, a...)}
}
// Common errors reused across handlers.
var (
errUnauthorized = newError(http.StatusUnauthorized, "unauthorized", "authentication required")
// errAuthUnavailable answers when the session store itself is unreachable
// (Postgres down): an outage is not a credential verdict, so the caller gets
// 503 "retry" instead of a 401 that reads as "log in again".
errAuthUnavailable = newError(http.StatusServiceUnavailable, "auth_unavailable",
"authentication is temporarily unavailable; retry shortly")
errForbidden = newError(http.StatusForbidden, "forbidden", "not permitted")
errBadRequest = newError(http.StatusBadRequest, "bad_request", "invalid request")
)
// writeJSON writes v as an indented JSON body with the given status.
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
enc := json.NewEncoder(w)
enc.SetEscapeHTML(false)
_ = enc.Encode(v)
}
// writeError renders err as the standard error envelope. Non-apiError values
// collapse to a 500 so driver/internal details never reach the client.
//
// That collapse is deliberately lossy on the wire and deliberately NOT lossy in
// the log. Everything the client is denied — the driver message, the wrapped
// chain, the handler that produced it — is written to stderr first, keyed by the
// same request_id the caller is shown. Without that line an operator holding a
// "internal error" has nothing to grep for, and diagnosis degrades into guessing
// against a live install; it cost a full debugging session to learn that once.
func writeError(w http.ResponseWriter, r *http.Request, err error) {
var ae *apiError
if !errors.As(err, &ae) {
log.Printf("api: %s %s: unmapped error (request_id=%s): %v",
r.Method, r.URL.Path, requestIDFromContext(r.Context()), err)
ae = newError(http.StatusInternalServerError, "internal", "internal error")
}
body := map[string]any{
"error": map[string]string{
"code": ae.code,
"message": ae.msg,
"request_id": requestIDFromContext(r.Context()),
},
}
writeJSON(w, ae.status, body)
}