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

137 lines
8.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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