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