fix(setup): record email unverified so onboarding works without SMTP

At bootstrap there is no SMTP, so the old /setup flow was unreachable: it
requested an emailed OTP that could never arrive. Setup now records the
Owner's email address unverified (no OTP round-trip) and requires a passkey,
deferring SMTP configuration to a later Settings page. Setup completes on
email-recorded + passkey-enrolled, and the lockdown lifts on the passkey, not
on email_verified: a passkey is the Owner's only pre-SMTP login credential
(email-OTP login refuses admin accounts).

The record-email endpoint (POST /account/email) now clears email_verified in
the same write. Only VerifyEmailOTP, which proves control of the address, may
set that flag; recording a fresh unproven address must never leave a stale
email_verified=true asserting a proof the user never gave. The change strictly
tightens the invariant, so no existing reader breaks.

Remove the dead ErrEmailTaken path and its documented 409: no migration puts a
unique index on users.email and the codebase does not enforce email
uniqueness, so the unique-violation branch was unreachable and the 409 an
impossible response.

The /setup route (Setup.tsx, setEmail helper, setup i18n copy) is rewritten to
match: record-email, mandatory passkey, no skip-for-now. The SMTP settings
page and post-setup configure-SMTP nudge are deferred.
This commit is contained in:
flyemoji committed 2026-07-17 01:41:07 +09:00
1 parent 93190e7a5b
commit 87279a1366
12 files changed
+328 -155

No files matched your search

+27 -12
View File
@@ -391,6 +391,10 @@ func (a *API) externalAPIRoutes() []apiRoute {
// email is an ordinary authenticated operation, scoped to the principal.
{Method: "POST", Pattern: "/api/v1/account/email/start", SetupAllowed: true, h: a.handleEmailOTPStart},
{Method: "POST", Pattern: "/api/v1/account/email/verify", SetupAllowed: true, h: a.handleEmailOTPVerify},
// Record-only email: the setup wizard's Step 1 stores the Owner's address
// UNVERIFIED (no SMTP at bootstrap ⇒ no code to mail). email_verified stays
// false until a later Settings/SMTP flow proves control via /email/verify above.
{Method: "POST", Pattern: "/api/v1/account/email", SetupAllowed: true, h: a.handleSetEmail},
// Passkey enrollment (spec §14 WebAuthn / Phase 6 bind), web side: /register/begin
// mints a credential-creation challenge for the caller, /register/finish verifies
// the authenticator's attestation and binds the passkey, and the credentials
@@ -522,7 +526,7 @@ func (a *API) buildFace(routes []apiRoute, guard func(http.Handler) http.Handler
// explicitly opts out. The wrapper is nil-principal safe, so it is inert on
// the internal face (service-token callers carry no Principal).
if !rt.SetupAllowed {
h = a.requireEmailVerified(h)
h = a.requireOnboarded(h)
}
auth.HandleFunc(pattern, h)
}
@@ -542,20 +546,31 @@ func (a *API) baseChain(h http.Handler) http.Handler {
return withRequestID(withRecover(h))
}
// requireEmailVerified fences an authenticated route behind the setup-lockdown:
// a session whose EmailVerified is false (a freshly-onboarded principal that has
// not yet proved control of its email) is restricted to SetupAllowed routes only.
// The wrapper is nil-principal safe, so it is inert on the internal face
// (service-token callers carry no Principal) and on the external face's admin
// Zero-Trust paths (those carry an IsAdmin/IsOwner principal that has already
// passed email verification at account creation).
func (a *API) requireEmailVerified(h http.HandlerFunc) http.HandlerFunc {
// requireOnboarded fences an authenticated route behind the setup-lockdown: a
// freshly-onboarded principal that has not finished setup is restricted to
// SetupAllowed routes only. The lockdown lifts on a durable login credential, NOT
// on email verification: the bootstrap Owner has no verified email (no SMTP exists
// at bootstrap) and a passkey is the ONLY credential that logs the Owner in
// pre-SMTP (email-OTP login refuses admin accounts; op-login needs SMTP + a second
// admin). So passkey enrollment is what completes setup — and it must, or the
// unverified Owner could never reach the Settings page to configure SMTP.
//
// Only a session principal whose email is still unverified reaches the passkey
// lookup; after setup that is just the bootstrap Owner, so the extra query is not
// on any hot path. The wrapper is nil-principal safe, so it is inert on the
// internal face (service-token callers carry no Principal) and on the external
// face's admin Zero-Trust paths (those carry an IsAdmin/IsOwner principal that has
// already passed email verification at account creation).
func (a *API) requireOnboarded(h http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
if p != nil && p.ViaSession && !p.EmailVerified {
writeError(w, r, newError(http.StatusForbidden, "setup_required",
"email verification is required before this action is available"))
return
creds, _ := a.Repo.PasskeyCredentialsForUser(r.Context(), p.UserID)
if len(creds) == 0 {
writeError(w, r, newError(http.StatusForbidden, "setup_required",
"passkey enrollment is required before this action is available"))
return
}
}
h(w, r)
}
+10
View File
@@ -359,6 +359,16 @@ func (f *fakeRepo) VerifyEmailOTP(_ context.Context, userID, purpose, codeHash s
}
return live.email, nil
}
func (f *fakeRepo) SetUserEmail(_ context.Context, userID, email string) error {
for _, u := range f.staff { // record + clear verified (proves nothing) — mirrors PGRepo
if u.ID == userID {
u.Email = email
u.EmailVerified = false
return nil
}
}
return ErrNotFound
}
// CreatePasskeyChallenge / ConsumePasskeyChallengeByUser mirror PGRepo's contract so
// the hermetic tests exercise the same semantics: a fresh begin supersedes ALL prior
+36
View File
@@ -230,6 +230,42 @@ func (a *API) deliverOTP(ctx context.Context, email, code string) error {
return a.Mailer.SendOTP(ctx, email, code)
}
// setEmailRequest is the record-email body: the address to bind to the caller's
// account WITHOUT an OTP round-trip.
type setEmailRequest struct {
Email string `json:"email"`
}
// handleSetEmail records the caller's email without verifying it (SetupAllowed). The
// setup bootstrap has no SMTP, so the Owner cannot receive an emailed code; the
// address is stored unverified and a later Settings/SMTP flow proves control of it.
// This is the setup wizard's Step-1 write. The OTP start/verify pair above is left
// intact for the Account page and for post-SMTP verification — this door deliberately
// does NOT touch email_verified.
func (a *API) handleSetEmail(w http.ResponseWriter, r *http.Request) {
p := principalFromContext(r.Context())
if err := requireJSONContentType(r); err != nil {
writeError(w, r, err)
return
}
var req setEmailRequest
if err := decodeJSON(w, r, &req); err != nil {
writeError(w, r, err)
return
}
email := strings.TrimSpace(req.Email)
if !looksLikeEmail(email) {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "a valid email is required"))
return
}
if err := a.Repo.SetUserEmail(r.Context(), p.UserID, email); err != nil {
writeError(w, r, err)
return
}
a.audit(r, auditActor(p), "account.email.set", "")
writeJSON(w, http.StatusOK, map[string]any{"email": email})
}
// auditActor picks the most identifying actor string for a principal: the audited
// Access email when present, else the stable user id. A player mid-onboarding may
// not have a verified email yet, so the id keeps the audit row attributable.
+10 -2
View File
@@ -104,7 +104,11 @@ func (a *API) handleSetupRedeem(w http.ResponseWriter, r *http.Request) {
"email": u.Email,
"email_verified": u.EmailVerified,
"has_passkey": hasPasskey,
"setup_required": !u.EmailVerified || !hasPasskey,
// Setup completes on email recorded + passkey enrolled. NOT email_verified:
// the bootstrap has no SMTP, so the Owner's address is stored unverified and a
// later Settings/SMTP flow verifies it. Passkey is the Owner's only pre-SMTP
// login credential, so it — not email verification — is the durable gate.
"setup_required": u.Email == "" || !hasPasskey,
})
}
@@ -131,6 +135,10 @@ func (a *API) handleSetupStatus(w http.ResponseWriter, r *http.Request) {
"email": u.Email,
"email_verified": u.EmailVerified,
"has_passkey": hasPasskey,
"setup_required": !u.EmailVerified || !hasPasskey,
// Setup completes on email recorded + passkey enrolled. NOT email_verified:
// the bootstrap has no SMTP, so the Owner's address is stored unverified and a
// later Settings/SMTP flow verifies it. Passkey is the Owner's only pre-SMTP
// login credential, so it — not email verification — is the durable gate.
"setup_required": u.Email == "" || !hasPasskey,
})
}
+106
View File
@@ -0,0 +1,106 @@
package api
import (
"encoding/json"
"net/http"
"testing"
)
// TestSetupNoSMTPFlow pins the no-SMTP onboarding contract: setup completes on email
// RECORDED + passkey ENROLLED, never on email verification (the bootstrap has no SMTP,
// so the Owner's address is stored unverified). The lockdown must therefore lift on a
// passkey, not on email_verified — otherwise the unverified Owner could never leave the
// wizard to reach the Settings/SMTP page.
func TestSetupNoSMTPFlow(t *testing.T) {
repo := newFakeRepo()
// Fresh Owner: admin, no email, unverified, no passkey — exactly post-CompleteOwnerSetup.
repo.staff["owner"] = &StaffUser{ID: "o1", Username: "owner", Role: "admin"}
api := newTestAPI(repo, newFakeCluster())
// A lockdown session principal: authenticated by session, email not yet verified.
api.External = staticExternal{p: &Principal{UserID: "o1", Role: "admin", ViaSession: true}}
h := api.ExternalHandler()
status := func(t *testing.T) map[string]any {
t.Helper()
w := do(h, "GET", "/api/v1/auth/setup/status", "", nil)
if w.Code != http.StatusOK {
t.Fatalf("status code = %d, want 200 (%s)", w.Code, w.Body.String())
}
var got map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("status body not JSON: %v", err)
}
return got
}
// 1. Nothing done → setup required, no email, no passkey.
if s := status(t); s["setup_required"] != true || s["email"] != "" || s["has_passkey"] != false {
t.Fatalf("fresh owner status = %v, want setup_required=true email=\"\" has_passkey=false", s)
}
// 2. Record the email — NO OTP. The row is written but email_verified stays false.
w := do(h, "POST", "/api/v1/account/email", `{"email":"[email protected]"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("set-email code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if u := repo.staff["owner"]; u.Email != "[email protected]" || u.EmailVerified {
t.Fatalf("after record: email=%q verified=%v, want the address recorded and UNVERIFIED", u.Email, u.EmailVerified)
}
// 3. Email recorded but no passkey → STILL required (email verification is not the gate).
if s := status(t); s["setup_required"] != true || s["email"] != "[email protected]" {
t.Fatalf("email-only status = %v, want setup_required=true (passkey still missing)", s)
}
// 4. The lockdown must still fence a non-SetupAllowed route: no passkey ⇒ 403 setup_required.
w = do(h, "POST", "/api/v1/me/submissions", `{}`, jsonHeader)
if w.Code != http.StatusForbidden || errCode(w.Body.Bytes()) != "setup_required" {
t.Fatalf("pre-passkey locked route: code=%d err=%q, want 403 setup_required (%s)", w.Code, errCode(w.Body.Bytes()), w.Body.String())
}
// 5. Enroll a passkey (the Owner's only pre-SMTP credential).
repo.passkeyCreds["pk1"] = PasskeyCredential{ID: "pk1", UserID: "o1", CredentialID: "cred1"}
// 6. Passkey present ⇒ setup complete AND the lockdown lifts (the route no longer 403s setup_required).
if s := status(t); s["setup_required"] != false || s["has_passkey"] != true {
t.Fatalf("post-passkey status = %v, want setup_required=false has_passkey=true", s)
}
w = do(h, "POST", "/api/v1/me/submissions", `{}`, jsonHeader)
if w.Code == http.StatusForbidden && errCode(w.Body.Bytes()) == "setup_required" {
t.Fatalf("post-passkey the lockdown did NOT lift: route still 403 setup_required")
}
}
// TestSetEmailClearsVerified pins the invariant that recording an unproven address
// drops any prior verification: /account/email is app-tier + SetupAllowed, so any
// authenticated session can reach it — an already-verified caller who changes their
// address must NOT keep email_verified=true asserting a proof they never gave. Only
// VerifyEmailOTP (which proves the address) may set that flag.
func TestSetEmailClearsVerified(t *testing.T) {
repo := newFakeRepo()
// A fully onboarded staff account: email already proven.
repo.staff["u"] = &StaffUser{ID: "u1", Username: "u", Role: "admin", Email: "[email protected]", EmailVerified: true}
api := newTestAPI(repo, newFakeCluster())
api.External = staticExternal{p: &Principal{UserID: "u1", Role: "admin", ViaSession: true, EmailVerified: true}}
h := api.ExternalHandler()
w := do(h, "POST", "/api/v1/account/email", `{"email":"[email protected]"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("set-email code = %d, want 200 (%s)", w.Code, w.Body.String())
}
if u := repo.staff["u"]; u.Email != "[email protected]" || u.EmailVerified {
t.Fatalf("after record: email=%q verified=%v, want new address recorded and verification CLEARED", u.Email, u.EmailVerified)
}
}
// errCode returns the error.code of a JSON error body, or "" if body is not one (a
// non-failing decodeErr for cases where the response may be a success).
func errCode(body []byte) string {
var raw map[string]map[string]string
if json.Unmarshal(body, &raw) != nil {
return ""
}
return raw["error"]["code"]
}
+23
View File
@@ -737,6 +737,29 @@ func (p *PGRepo) VerifyEmailOTP(ctx context.Context, userID, purpose, codeHash s
return email, nil
}
// SetUserEmail records email on the user row WITHOUT verifying it (setup bootstrap
// has no SMTP — the Owner enters an address a later Settings/SMTP flow will verify).
// It clears email_verified in the same write: only VerifyEmailOTP ever sets that
// flag, and it does so only alongside the proven address, so recording a fresh
// (unproven) address must drop any prior verification rather than leave a stale
// email_verified=true asserting an address the user never proved. For a fresh Owner
// the flag is already false, so this is a no-op there.
func (p *PGRepo) SetUserEmail(ctx context.Context, userID, email string) error {
res, err := p.db.ExecContext(ctx,
`UPDATE users SET email = $2, email_verified = false WHERE id = $1`, userID, email)
if err != nil {
return err
}
n, err := res.RowsAffected()
if err != nil {
return err
}
if n == 0 {
return ErrNotFound
}
return nil
}
// ---- player game-login: username-collision reclaim (spec §B3) ----
// ReclaimUsername bars the squatter UUID and stashes its data hold in one
+7
View File
@@ -310,6 +310,13 @@ type Repo interface {
// becomes proven, so the write is load-bearing. The pre-session LOGIN door must
// NOT use it — see ConsumeLoginEmailOTP.
VerifyEmailOTP(ctx context.Context, userID, purpose, codeHash string, now time.Time) (email string, err error)
// SetUserEmail records email on the user row WITHOUT proving control of it, and
// clears email_verified in the same write (proving nothing, it must never leave a
// stale verified flag — see the PGRepo impl). This backs the setup wizard's Step 1:
// the bootstrap has no SMTP, so the Owner cannot receive an emailed code, and the
// address is stored unverified for a later Settings/SMTP flow to verify. An unknown
// userID returns ErrNotFound.
SetUserEmail(ctx context.Context, userID, email string) error
// ConsumeLoginEmailOTP redeems the newest live code for (userID, purpose) against
// codeHash for the PRE-SESSION email LOGIN door, with the SAME code lifecycle as
// VerifyEmailOTP (FOR UPDATE, expiry+lockout before hash compare, mismatch charges