NextMyDay/docs/specs/spec-005-setup-wizard.md
Claus Lohmar 92344d9783 docs(spec): revise D-025 to a tunnel-only setup gate and add D-043
The app is deployed behind Zoraxy (myday.sechpoint.app) with TLS terminated
at the proxy, so it must listen on 0.0.0.0:8080 over HTTP — a bind refusal is
impossible.

- SPEC-003 §4/§6/§7/§9: the bind refusal is gone; in setup mode the door
  serves only a request whose TCP peer is loopback AND which carries no
  X-Forwarded-For (the direct SSH tunnel), else 404 (AC-7/AC-10); the proxy's
  X-Forwarded-Proto drives Secure + __Host- cookies and HSTS (AC-13).
- SPEC-005 §3/§5/§6: AC-2 becomes the per-request tunnel-only gate; any bind
  is allowed.
- review-001: record the Architect's rulings and the adopted strict gate.
2026-10-15 05:40:30 +00:00

142 lines
8.8 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): tunnel-only per-request gate** |
| **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**.
- In setup mode the app serves **only** `/setup` (+ static assets); **every other route → 404**.
- **Tunnel-only, enforced per request (D-025, revised 2026-10-15):** the app **may bind any address**
— including `0.0.0.0:8080` behind a reverse proxy (D-043); there is **no** bind refusal. The gate
is **per request**: only a request whose **TCP peer is loopback** **and** which carries **no
`X-Forwarded-For`** is served (the **direct SSH tunnel**). Anything else — including a request
relayed by a trusted proxy — → **404** before dispatch; an empty/unparseable peer **fails closed**
(SPEC-003 §7 · AC-7/AC-10). *A proxy on the same host presents a loopback peer, so the absence of
XFF is what proves the request did not traverse one.*
- 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 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 → `/setup`; any other route → 404 |
| AC-2 | In setup mode **only the direct tunnel** is served (loopback TCP peer **and** no `X-Forwarded-For`); anything else → 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 |
---
## 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).