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.
137 lines
8.2 KiB
Markdown
137 lines
8.2 KiB
Markdown
# 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).
|
||
|