NextMyDay/docs/specs/spec-005-setup-wizard.md
Claus Lohmar 04c2394c34 docs: record the M0 review fixes and correct the README
REVIEW-001 triage updated (H1, H2, M1c, M1s, L3s, L1c/L2s, L8c, M2s, M3s,
M2c, L1s, L5s, L4s, L7c marked FIXED; L3c, L4c, L5c, L6c stay open with the
reason), and each fix is tied to its code and test.

- SPEC-003: the door pipeline (XFF right→left, the per-request setup gate,
  the isolated hardening headers, the pinned setup module), §6 the `__Host-`
  cookie and the startup-generated signing keys, §7 loopback enforcement
  per request, new AC-10…AC-12, D-2 amended (session_key removed), and a new
  §11 that maps every REVIEW-001 finding to its fix.
- SPEC-005 / SPEC-006 / SPEC-007: the same three points where they belong
  (the wizard's loopback + module pin, the nudge's configured-origin signal,
  the async /access notification ruled in).
- PROJECT_PLAN §10 register: A13 re-stated as enforced (per request + pin);
  A2/A5/A8 re-stated as enforced where the review verified them; A3/A6/A9/A15
  now enforced by this pass; A11 left open (L4c). §14 change log entries.
- README: the status said "planning / pre-alpha · nothing is built" — it is
  stale. State M0+M0.1, note that /setup PERSISTS config.yaml and a restart
  adopts the generated secrets, and state the per-request loopback rule.
2026-10-14 11:30:17 +00:00

8.2 KiB
Raw 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
Created 2026-10-13
Author OpenArchitect
Target milestone M0
Governing decisions D-015 · D-021 · D-025 · D-026 · D-035 · PROJECT_PLAN.md §5
Related SPEC-003 (door) · SPEC-004 (identity)

1. Goal

Bring a fresh instance to life with a loopback-only /setup wizard 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

setup mode = (config.setup == true) OR (no user exists) — fail-safe.

  • In setup mode the app serves only /setup (+ static assets); every other route → 404.
  • Loopback-only, enforced (D-025): if server.listen is not loopback while in setup mode, the process refuses to start — and, per request, a client whose resolved IP is not loopback is 404 before dispatch (H2/REVIEW-001 · SPEC-003 §7), so a reverse proxy on 127.0.0.1 relaying remote clients cannot reach the wizard.
  • 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).

4. The wizard

GET  /setup            → the wizard
  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 → config.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.
  • The instance may now bind a public interface (behind a reverse proxy).
  • Audit: the bootstrap, the admin creation, and the enroll are all recorded (A11).

6. Acceptance criteria

# Criterion
AC-1 Fresh instance → /setup; any other route → 404
AC-2 Non-loopback listen in setup mode → process exits with an error (D-025)
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

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 OR "not yet bootstrapped" — no user, or no ACTIVE admin) 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.

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).