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.
8.8 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): 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:8080behind 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 noX-Forwarded-Foris 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
setupmodule is pinned at the door: the wizard's actions are reachable only through the reservedsetupworkflow (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 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.- 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 | 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(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 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.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).