- 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.
17 KiB
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:8080behind 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 /andGET /setupshow a boilerplate — "MyDay is not set up. Setsetup: trueinconfig.yamland restart." Every other route → 404. This state is not publicly claimable.setup: true→/setupserves the wizard (the operator's deliberate switch).GET /→ a friendly page pointing at/setup. Every other route → 404.- Completion → the app writes
setup: falseand creates the admin → productive.
setup_codeis OPTIONAL (D-044 · D-045). If set inconfig.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: trueis the gate; theSetupRequestAllowedpeer+X-Forwarded-Forrule no longer decides setup access. The XFF/XFP handling is kept for the client IP and theSecure/__Host-cookies + HSTS (A6/A11/D-043). - The
setupmodule is pinned at the door: the wizard's actions are reachable only through the reservedsetupworkflow (M1c/L3s · SPEC-003 §4). - Residual (D-045): while
setup: true, whoever reaches/setupfirst can claim the admin — setsetup_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 anotheradminare 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.1and 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 requiresconfig.setup: true+ restart.config.yamlnow readssetup: 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; setsetup: trueand restart to resume.)- The instance is served on its configured bind (typically
0.0.0.0:8080behind the reverse proxy, D-043); the request scheme is taken fromX-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(thesetupcore page + thesetupaction module); the setup-mode gate indispatch(§3: only/setup+ static served) andserveSetupAction(pre-session CSRF, A15).cmd/myday— wiring +configTarget(persists toconfig.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/setupso 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.finishactions are gone.setup.Enrollernow declares onlyTOTPBegin/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/mydaybuilds 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(noenroll.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: trueis now the gate,setup_codeis optional, and the peer/tunnel gate is retired. The code-form page now lives only atGET /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.ActionsgainsUnlock(theunlockaction),CodeandGate.internal/core/server—Deps.SetupGate(a narrowSetupGateinterface);dispatchnow honours gate (b) (an unlocked request) alongside gate (a) (the tunnel), and handsGET /- an un-unlocked
GET /setupto the boilerplate (never a bare 404).
- an un-unlocked
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/mydaybuilds 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: trueenables the wizard;setup: falsewith 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_codeis optional —setup.Newno longer generates a code; a blank code makesGate.Required()false and the toggle alone admits. When set, the D-044 code mechanics apply unchanged.- The peer/tunnel gate is retired —
httpx.SetupRequestAllowed(andhttpx.IsLoopbackIP) are deleted; the XFF/XFP handling is kept for the client IP and theSecure/__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.LockedModecarries the locked predicate;Deps.SetupModenow means "the wizard is enabled". - The startup banner states the mode; it prints the code only when
setup_codeis set; in the locked state it tells the operator to setsetup: trueand 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) |