NextMyDay/docs/specs/spec-005-setup-wizard.md
Claus Lohmar 820dc005b3 docs(spec-005): record the TOTP-step render fix
Add §11 (the bug, the write-free IssueTOTP seam, the rendered TOTP page, the
tightened tests) and bump the status revision to 2026-10-15d. Note the two
builder choices: the secret is shown as text rather than a QR image, and
`pendingGUID` now means pending AND in-window (409 on an expired window).
2026-10-15 07:07:33 +00:00

20 KiB
Raw Permalink Blame History

SPEC-005 — /setup wizard & bootstrap

Field Value
Status Implemented (2026-10-13) — see §7; build/vet/test/-race green · static binary · live-smoked · rev 2026-10-14 (D-041): TOTP-only setup · rev 2026-10-15 (D-025/D-043): per-request tunnel gate — superseded · rev 2026-10-15b (D-044): the setup code + the first-run boilerplate · rev 2026-10-15c (D-045): the gate is the setup toggle; the code is optional; the tunnel gate retired · rev 2026-10-15d: the TOTP step renders the secret + verify form (was the enroll flow's JSON) — see §9/§10/§11
Created 2026-10-13
Author OpenArchitect
Target milestone M0
Governing decisions D-021 · D-026 · D-035 · D-044 · D-045 · PROJECT_PLAN.md §5
Related SPEC-003 (door) · SPEC-004 (identity)

1. Goal

Bring a fresh instance to life with a /setup wizard — enabled by the setup config toggle (D-045) and optionally locked by a setup code (D-044) — that creates the first user = the top admin (user_id = 1, role admin), then switches the instance to productive mode.

2. Non-goals

Module setup screens (each module owns its config). Multi-admin (there is one, D-034/§7.1).

3. Setup mode & the gate (D-045)

The gate is the config toggle setup. The instance is in one of three states:

setup: false · no admin   →  LOCKED   (a "set setup: true and restart" boilerplate; not claimable)
setup: true               →  the WIZARD (or the code form when `setup_code` is set)
setup: false · admin      →  PRODUCTIVE
  • The app may bind any address — 0.0.0.0:8080 behind the proxy (D-043); no bind refusal.
  • The gate is the config toggle setup (D-045).
    • setup: false, no admin → the app is locked: GET / and GET /setup show a boilerplate — "MyDay is not set up. Set setup: true in config.yaml and restart." Every other route → 404. This state is not publicly claimable.
    • setup: true → /setup serves the wizard (the operator's deliberate switch). GET / → a friendly page pointing at /setup. Every other route → 404.
    • Completion → the app writes setup: false and creates the admin → productive.
  • setup_code is OPTIONAL (D-044 · D-045). If set in config.yaml, the wizard requires it (a second factor) and the code form comes first; if empty, the toggle alone admits. Single-use; rate-limited; redacted (A8).
  • The peer/tunnel gate (D-025) is retired — setup: true is the gate; the SetupRequestAllowed peer+X-Forwarded-For rule no longer decides setup access. The XFF/XFP handling is kept for the client IP and the Secure/__Host- cookies + HSTS (A6/A11/D-043).
  • The setup module is pinned at the door: the wizard's actions are reachable only through the reserved setup workflow (M1c/L3s · SPEC-003 §4).
  • Residual (D-045): while setup: true, whoever reaches /setup first can claim the admin — set setup_code, or complete setup immediately.

4. The wizard

# setup: false · no admin  →  the boilerplate ("enable setup")   ← locked, not claimable
# setup: true              →  the wizard
GET  /setup                         → (if `setup_code` is set) the code form, else the wizard
POST /setup?do=setup&action=unlock  → the code → unlocks          (only when `setup_code` is set)
  Step 1  domain / base_url
  Step 2  the top admin — display_name · email · **TOTP enrollment**   (Passkey deferred — D-041)
  Step 3  mail (SMTP) · locale · timezone
POST /setup?do=setup&action=…       → each step (CSRF-protected)
  Finish  → persist settings → **setup: false**
          → user_id = 1 (role admin) · identity minted + activated · session minted
  • Settings persist to config.yaml (D-035). Caveat: if the file is not writable, the wizard prints the values and instructs the operator to write them, then marks setup complete.
  • One admin only: the wizard creates user_id = 1; later attempts to create another admin are rejected (SPEC-004 AC-6).
  • TOTP only (D-041): the wizard does not enroll a Passkey — over plain-HTTP loopback a passkey would bind to 127.0.0.1 and be unusable at the public domain. The admin adds one at first login on the real origin, via the passkey nudge (SPEC-006 §8).

5. After setup

  • /setup → 404 (delete, don't redirect). Re-entering requires config.setup: true + restart.
  • config.yaml now reads setup: false; with an admin present the instance is productive. (Without an admin it would be the locked state — the toggle is off and there is nothing to serve; set setup: true and restart to resume.)
  • The instance is served on its configured bind (typically 0.0.0.0:8080 behind the reverse proxy, D-043); the request scheme is taken from X-Forwarded-Proto (trusted proxy) or TLS.
  • Audit: the bootstrap, the admin creation, and the enroll are all recorded (A11).

6. Acceptance criteria

# Criterion
AC-1 setup: true → GET /setup serves the wizard directly when setup_code is empty; GET / points at it; every other route → 404
AC-2 With setup_code set, GET /setup shows the code form first (from any peer); only a request unlocked with the code reaches the wizard; a wrong/absent code gets a generic 403. The peer/tunnel rule (D-025) is retired (D-045)
AC-3 Completing the wizard creates user_id = 1 (role admin) with TOTP enrolled and no passkey (D-041)
AC-4 After completion config.setup = false (or printed) and /setup → 404
AC-5 A second admin cannot be created
AC-6 The full bootstrap is audited
AC-7 The setup code (when set) is single-use and rate-limited; a wrong or absent code gets a generic answer that reveals nothing, and the code is redacted from the logs (A8)
AC-8 setup: false with no admin → LOCKED: GET / and GET /setup render the "enable setup" boilerplate (200 — never a bare 404), every other route → 404, and the wizard is not claimable (D-045)

7. Implementation notes (accepted rulings)

Built 2026-10-13. build · vet · test (incl. -race) green; static CGO-free binary; live-smoked (/setup→200 + CSRF cookie · /home→404 · /→404 · action without CSRF→403 · step 1→2→2b advances · real WebAuthn begin and real sealed TOTP issue). AC-1…AC-6 all pass.

What landed

  • internal/core/setup — the wizard (Actions): page + steps (domain.save · admin.create · admin.enroll.* · admin.totp.* · finish), the step state machine, and the persister.
  • internal/core/server — RegisterSetupFlow (the setup core page + the setup action module); the setup-mode gate in dispatch (§3: only /setup + static served) and serveSetupAction (pre-session CSRF, A15).
  • cmd/myday — wiring + configTarget (persists to config.yaml, never the committed example).
  • internal/core/config — Marshal (the persisted document re-loads through the strict loader).
  • internal/core/auth — Identities.ByUserID (additive; the wizard's "which step?" lookup).
  • internal/core/auth/flow — Deps.SuccessPath (additive seam; the wizard injects /setup so a successful ceremony stays on the wizard).

Step 2 reuses SPEC-006 — the wizard calls flow.Actions.EnrollBegin/Finish/TOTPBegin/Verify with the admin's GUID; the ceremony is not duplicated.

The setup-mode gate (SPEC-005 §3, AC-1) — while in setup mode every route except /setup (+ static) is 404, including /home, /access, the canonical / → /home redirect, and every GUID branch. (Delete, don't redirect — a fresh instance has no app yet.)

# Builder choice Ruling
D-1 One admin only enforced by users.Create; the wizard returns 409 on a second attempt. Accepted (AC-5).
D-2 The wizard's step is derived from the DB (admin exists? identity pending/active?) plus an in-memory draft for step 1's domain. Accepted as the smallest state model.
D-3 A not-writable config.yaml → the YAML is printed to stdout with an instruction; setup still completes in memory. Accepted (AC-4; §4 caveat).
D-4 Setup mode is LATCHED at startup (config.Setup, D-045) and cleared by an atomic Actions.Done(); config.Setup is never mutated (the live Config is an immutable startup snapshot, D-035). Accepted — avoids a data race on the shared config and a per-request DB query, and holds setup mode through the whole wizard: once the admin row exists a live "no user" check would end setup mode before enrollment/finish. (D-045): the "no user" clause moved to the LOCKED state; setup mode is now purely the toggle.

Architect rulings on the flagged items:

# Item Ruling
R-6 Secrets are preserved verbatim, never generated — a config.example.yaml copy persists CHANGE_ME placeholders, so the next boot fails closed. Generate. The wizard generates strong secrets (session · cookie · csrf · totp) when a secret is empty/placeholder, so a first boot from the example is viable. Folded into the next slice.
O-13 The WebAuthn RP is built at startup from server.base_url; step 1's domain doesn't rebuild it. Surface to the owner — see O-13 in PROJECT_PLAN.md §9. A passkey enrolled over plain-HTTP loopback binds RP ID 127.0.0.1 and is unusable at the public domain. Does not block SPEC-007/008.
F-2 The ruling "serve the otpauth URL Cache-Control: no-store" (SPEC-006 §7) is not implemented. Fix — add no-store to the enroll/TOTP (and setup) responses. Folded into the next slice.
D-5 Step-1's domain draft is in-memory only (a restart re-asks step 1). Accepted — minor; DB state for steps 2–3 survives.

8. Implementation notes — TOTP-only setup (D-041, 2026-10-14)

Built 2026-10-14. build · vet · test (incl. -race) green; static CGO-free binary.

What changed

  • Step 2 enrolls TOTP only — the wizard renders the authenticator form and the verify form; the passkey section and the admin.enroll.begin / admin.enroll.finish actions are gone. setup.Enroller now declares only TOTPBegin / TOTPVerify.
  • flow.Deps.TOTPOnlyEnroll (new, default false) lets a PENDING identity activate on a verified TOTP code alone — the wizard's dedicated flow sets it. Normal GUID enrollment (/{GUID}, SPEC-006 §4) still requires both factors (AC-2).
  • cmd/myday builds two flows over the same deps: the door's (two-factor, nudge-suppressed in setup mode) and the wizard's (TOTPOnlyEnroll, SuccessPath → /setup).
  • AC-3 now asserts TOTP enrolled · no passkey; the audit set is user.create · identity.mint · enroll.totp.begin · enroll.complete · setup.admin · setup.complete (no enroll.passkey).

The admin adds a passkey at first login on the real origin, via the passkey nudge (SPEC-006 §8).


9. Implementation notes — the setup code & boilerplate (D-044, 2026-10-15b)

Superseded in part by D-045 (§10). The D-044 mechanics (the signed unlock cookie, the single-use/rate-limited/redacted code, the generic 403) are unchanged; what changed is the gate: setup: true is now the gate, setup_code is optional, and the peer/tunnel gate is retired. The code-form page now lives only at GET /setup (not /), and the code is no longer generated when blank.

Built 2026-10-15. build · vet · test -race green; static CGO-free binary; live-smoked against the real binary (banner + code, boilerplate at / and /setup behind a proxy, wrong code → 403, correct code → 303 + unlock cookie, the wizard follows, a spent code is refused, and neither the code nor a query-borne copy appears in the access log).

What landed

  • internal/core/setup — Gate (gate.go): the setup code + the signed unlock cookie, and the first-run boilerplate renderer. Actions gains Unlock (the unlock action), Code and Gate.
  • internal/core/server — Deps.SetupGate (a narrow SetupGate interface); dispatch now honours gate (b) (an unlocked request) alongside gate (a) (the tunnel), and hands GET /
    • an un-unlocked GET /setup to the boilerplate (never a bare 404).
  • internal/core/httpx — Signer.Fingerprint: a keyed, one-way digest so the unlock cookie can bind to the code without carrying it.
  • internal/core/config — setup_code (see D-1 below); cmd/myday builds the gate, wires it into the door, and prints the startup banner.

The gate, precisely

The code 128-bit, pinned by setup_code (D-045: no code is generated when it is blank — the toggle alone admits). Entry ignores case/separators.
Single-use Accept retires the code on the first success; a failed attempt does not consume it.
The unlock A signed cookie (myday_setup) carrying a fingerprint of the code — not the code — so it survives the code's retirement but dies on a restart (new code) or on completion (Clear). It is not the session cookie (no user exists).
Generic A wrong and an absent code return the same 403 page/message.
Rate-limited The /setup class of the A3 limiter covers the unlock form.
Redacted The code rides a POST body (never a URL); a hand-typed ?code=… is redacted by RedactQuery (A8).
Audited setup.unlock (unlocked / refused) — no code value.
# Builder choice Ruling
D-1 The config key is setup_code (top level), not the literal setup.code: setup: is already the boolean mode flag (D-026), and YAML cannot be both a scalar and a mapping. Accepted — the smallest faithful encoding of "setup.code in config".
D-2 "Single-use" = the code retires on the first successful unlock; the browser keeps its signed unlock cookie (multi-tab works), a second visitor cannot re-use the printed value. A restart re-arms a pinned setup_code. Accepted — honours single-use without a lock-out when a tab is closed.
D-3 GET / shows the first-run page always while the wizard is enabled — / is not the wizard. (D-045): / is now a landing that points at /setup; the code form is at /setup. Accepted.
D-4 With no gate wired (a door built by hand) the first-run pages fall back to the strict 404 — the entrypoint always wires it. Accepted — keeps the door honest without a setup package.

10. Implementation notes — the toggle gate & the optional code (D-045, 2026-10-15c)

Built 2026-10-15. build · vet · test -race green; static CGO-free binary; live-smoked against the real binary across all three states (locked boilerplate at / and /setup, the code-less wizard, the code form → unlock → wizard, and a full bootstrap: domain.save → admin.create → admin.totp.begin/verify → finish → config.yaml reads setup: false and /setup → 404).

What changed

  • The gate is config.setup — setup: true enables the wizard; setup: false with no admin is the LOCKED state. The !bootstrapped() fail-safe clause was removed from setup mode and became the locked predicate (latched at startup).
  • setup_code is optional — setup.New no longer generates a code; a blank code makes Gate.Required() false and the toggle alone admits. When set, the D-044 code mechanics apply unchanged.
  • The peer/tunnel gate is retired — httpx.SetupRequestAllowed (and httpx.IsLoopbackIP) are deleted; the XFF/XFP handling is kept for the client IP and the Secure/__Host- cookies
    • HSTS (A6/A11/D-043).
  • Three first-run pages — Gate.CodeForm (setup + required code, un-entered), Gate.Landing (GET / while setup is enabled), Gate.Locked (setup off, no admin). Deps.LockedMode carries the locked predicate; Deps.SetupMode now means "the wizard is enabled".
  • The startup banner states the mode; it prints the code only when setup_code is set; in the locked state it tells the operator to set setup: true and restart.
# Builder choice Ruling
D-1 Static assets stay served in every first-run state (locked + enabled) so the boilerplate/code form render inside the shell — the "every other route → 404" rule covers app routes. (builder; the smallest faithful reading — a stylesheet for the page that replaces the 404)
D-2 "No admin" (for the locked predicate) = no user row at all, latched at startup — not "no ACTIVE admin". A pending admin is therefore productive (enrolls via /{GUID}), never locked. (builder)
D-3 The unlock action when no code is required is a no-op 303 → /setup rather than a 404. (builder — a stale bookmark works)

11. Fix — the TOTP step renders the wizard page (2026-10-15d)

Bug: POST /setup?do=setup&action=admin.totp.begin answered with the enroll flow's JSON ({"otpauth_url":…}, Content-Type: application/json) — setup.Actions.TOTPBegin delegated to flow.Actions.TOTPBegin, which is webauthn.js's JSON API. The wizard is a server-rendered, no-JS flow, so the browser showed raw JSON and stalled at step 2 (§4's "TOTP enrollment" step rendered no secret at all).

Fix (the enroll flow's JSON contract is kept intact — webauthn.js depends on it):

  • internal/core/auth/flow — the issue step is extracted into a write-free seam, Actions.IssueTOTP(ctx, guid) (string, error) (issues + persists the secret, returns the otpauth URL, writes nothing). TOTPBegin now calls it and keeps its JSON contract (writeJSON(w, 200, {"otpauth_url": url})).
  • internal/core/setup — Enroller gains the write-free IssueTOTP and drops TOTPBegin (so the wizard's seam no longer exposes a JSON-writing method at all); Actions.TOTPBegin now renders an HTML page (text/html, inside the shell, no inline scripts) showing the otpauth URL, its human-readable secret for manual entry, and the verify form (posting to admin.totp.verify, carrying the pre-session CSRF token, A15).
  • internal/core/setup/render.go — totpPage + secretFromOtpauth.
  • Tests — internal/core/server/setup_wizard_test.go now asserts the begin response is text/html and carries the issued secret and the verify form (not merely 200); the end-to-end path (begin → real code → 303) is kept. enroll_login_test.go pins the enroll flow's JSON contract (application/json + "otpauth_url").
# Builder choice Ruling
D-1 The secret is shown as text (the otpauth URL plus the base32 secret extracted from it) rather than a QR image: the wizard is no-JS and a QR would pull in an image/barcode renderer for no gain. (builder — "human-readable secret and/or QR")
D-2 pendingGUID now means pending AND in-window (its doc already read "still awaits enrollment"), so an expired window refuses the step (409) instead of letting IssueTOTP surface as a 500. TOTPVerify on an expired window answers 409 rather than the flow's 404 — a refusal either way. (builder)
D-3 Enroller.TOTPBegin is removed — the fix left it unused by the wizard, and keeping a JSON-writing method on a no-JS seam is the coupling that caused the bug. flow.Actions.TOTPBegin (and its JSON contract for webauthn.js) is unchanged. (builder — dead code after the fix; trivially reversible)