NextMyDay/docs/specs/spec-005-setup-wizard.md
Claus Lohmar 9fdc9eded6 docs: update the plan, specs and deploy guide for the setup toggle (D-045)
- PROJECT_PLAN.md: rewrite §5.1 for the toggle gate, mark D-025 superseded,
  refresh D-026 / O-8 / A13 / §12 and add the D-045 change-log entry.
- SPEC-005: §1/§3/§5/§6 (AC-1/AC-2/AC-7 + AC-8) reflect the three states and
  the optional code; §9 marks the D-044 tunnel bits superseded; new §10.
- SPEC-003: §4/§7 pipeline + AC-7/AC-10/AC-14; new §13 (D-045); D-025 ref
  removed from the governing decisions.
- docs/deploy-zoraxy.md: §5 rewritten around the toggle + optional code;
  §1/§2/§3/§7 de-tunnelled; see-also updated.
- README: first-run + deployment sections rewritten (enable setup: true,
  open /setup, complete, the app flips it off).
- config.example.yaml: document the toggle and the optional code.
2026-10-15 06:47:36 +00:00

244 lines
17 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** · **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** — see §9/§10 |
| **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)* |