NextMyDay/docs/specs/spec-005-setup-wizard.md
Claus Lohmar cec7340c55 docs: document the setup-code first run for the LXC/Zoraxy topology
- PROJECT_PLAN: register D-044, re-revise D-025 to "tunnel OR setup code",
  update A13, O-8, §5.1, §12 and the change log.
- SPEC-003 §4/§7/§9/§12: the gate now serves the wizard to the tunnel OR an
  unlocked request; AC-7/AC-10 reworded ("refused the wizard" — the boilerplate
  may still answer `/` and `/setup`), AC-14 added.
- SPEC-005 §3/§4/§6/§9: the boilerplate, the unlock action, the code rules and
  the ACs; AC-7 added for single-use/rate-limit/redaction.
- deploy-zoraxy §1/§3/§5/§7 + README: the LXC/Proxmox topology, how to read the
  code (journalctl · pct exec 11020), the code gate replacing the tunnel-only
  bootstrap (the tunnel stays as gate a); firewall and Zoraxy sections kept.
2026-10-15 06:33:16 +00:00

195 lines
13 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** · **rev 2026-10-15b (D-044): the setup code + the first-run boilerplate** — see §9 |
| **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 **`/setup` wizard** reachable **only over the direct SSH
tunnel** 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**.
- The app **may bind any address** — including `0.0.0.0:8080` behind a reverse proxy (D-043); there is
**no** bind refusal.
- **Gated — never first-visitor-wins (D-025 · D-044).** `/setup` is served to a request that is
**either** **(a)** a **direct tunnel** — **TCP peer loopback** **and** **no `X-Forwarded-For`** (an
empty/unparseable peer fails closed; a same-host proxy presents a loopback peer, so the *absence of
XFF* is what proves no proxy) — **or** **(b)** **unlocked with the setup code** (below).
- **The first-run boilerplate (D-044).** In setup mode:
- `GET /` and `GET /setup` show a **friendly first-run page** — *"MyDay is not set up yet"* — with a
**setup-code entry form** and the instruction to read the code from the **server log**.
**Never a bare 404** (the domain must not look "down").
- Every **other** route → **404**.
- The correct code **unlocks the wizard**; the code is **single-use**, **rate-limited**, and
**redacted from the logs** (A8).
- 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 first-run boilerplate + the setup-code form
POST /setup?do=setup&action=unlock → the code → unlocks the wizard (a session flag)
GET /setup → the wizard (once unlocked, or via the tunnel)
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
```
- **The setup code (D-044):** a **128-bit** value generated at first run and printed in a **startup
banner** (`setup.code` in config overrides it). A direct-tunnel request (gate **a**) needs no code.
- **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 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 | Fresh instance → `GET /` and `GET /setup` show the **first-run boilerplate** (200 — **never a bare 404**); every **other** route → 404 |
| AC-2 | In setup mode the **wizard** is served to **either** the direct tunnel (loopback TCP peer **and** no `X-Forwarded-For`) **or** a request **unlocked with the setup code**; anything else gets the boilerplate (`/` · `/setup`) or a 404. Any bind is allowed (D-025 revised) |
| 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 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). The direct tunnel needs no code (D-044) |
---
## 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).
---
## 9. Implementation notes — the setup code & boilerplate (D-044, 2026-10-15b)
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, the tunnel
opens the wizard with no code, 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, generated at startup (`crypto/rand`), printed in the banner, or pinned by `setup_code`. 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 prints a **fresh** code. | **Accepted** — honours single-use without a lock-out when a tab is closed. |
| **D-3** | `GET /` shows the boilerplate **always** in setup mode (tunnel or not) — `/` is not the wizard. | **Accepted.** |
| **D-4** | With **no** gate wired (a door built by hand) setup mode keeps the strict 404 — the entrypoint always wires it. | **Accepted** — keeps the door honest without a setup package. |