- 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.
244 lines
17 KiB
Markdown
244 lines
17 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** · **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)* |
|
||
|