Unverified Commit 87279a13 authored by Minseong Choi's avatar Minseong Choi 💬
Browse files

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.
parent 93190e7a
Loading
Loading
Loading
Loading
+40 −0
Changes for docs/openapi.yaml: 40 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -3317,6 +3317,46 @@ paths:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }

  /api/v1/account/email:
    post:
      tags: [account]
      operationId: setEmail
      summary: Record the caller's email WITHOUT verifying it (setup bootstrap, spec §B2).
      description: >
        Writes the supplied address to the authenticated principal's user row and
        clears email_verified (already false for a fresh Owner). The setup bootstrap
        has no SMTP, so the Owner cannot receive an emailed code; a later Settings/SMTP
        flow proves control of the address via /account/email/verify.
      x-felis-face: [external]
      x-felis-tier: app
      security: [{ accessJWT: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [email]
              properties:
                email: { type: string, format: email }
      responses:
        '200':
          description: Email recorded (unverified).
          content:
            application/json:
              schema:
                type: object
                required: [email]
                properties:
                  email: { type: string, format: email }
        '400':
          description: A valid email is required.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '401':
          $ref: '#/components/responses/Unauthorized'

  /api/v1/account/passkey/register/begin:
    post:
      tags: [account]
+25 −10
Changes for internal/api/api.go: 25 added lines, 10 removed lines.
Original line number Diff line number Diff line
@@ -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,21 +546,32 @@ 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 {
			creds, _ := a.Repo.PasskeyCredentialsForUser(r.Context(), p.UserID)
			if len(creds) == 0 {
				writeError(w, r, newError(http.StatusForbidden, "setup_required",
				"email verification is required before this action is available"))
					"passkey enrollment is required before this action is available"))
				return
			}
		}
		h(w, r)
	}
}
+10 −0
Changes for internal/api/api_test.go: 10 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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 −0
Changes for internal/api/handlers_email_otp.go: 36 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -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
Changes for internal/api/handlers_setup.go: 10 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -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,
	})
}
Loading