Lock in the behaviour the fix introduces (docs/specs/spec-005-setup-wizard.md
§11 D-2): once the admin's bounded enrollment window has closed (SPEC-004 §4,
A1) `admin.totp.begin` answers a clean 409 and issues no secret, rather than
propagating the flow's "no pending identity" as a server fault.
Add §11 (the bug, the write-free IssueTOTP seam, the rendered TOTP page, the
tightened tests) and bump the status revision to 2026-10-15d. Note the two
builder choices: the secret is shown as text rather than a QR image, and
`pendingGUID` now means pending AND in-window (409 on an expired window).
The first-run wizard's `admin.totp.begin` delegated to the enroll flow's
`TOTPBegin`, which is `webauthn.js`'s JSON API: the browser was shown raw
`{"otpauth_url":…}` and the wizard stalled at step 2, rendering no secret.
Extract the issue step into a write-free seam, `flow.Actions.IssueTOTP`, and
keep `TOTPBegin`'s JSON contract by having it call the seam. The wizard now
issues via the seam and renders its own HTML page (`text/html`, inside the
shell, no inline scripts) showing the otpauth URL, the human-readable secret
for manual entry, and the verify form (pre-session CSRF, A15).
`setup.Enroller` drops `TOTPBegin` — the wizard no longer calls it, and a
JSON-writing method on a no-JS seam is the coupling that caused the bug.
Tighten the tests that missed it: `setup_wizard_test.go` asserts the `begin`
response is `text/html` and carries the secret and the verify form (not merely
200), keeping the end-to-end begin→code→303 path; `enroll_login_test.go` pins
the enroll flow's JSON contract (`application/json` + `otpauth_url`).
Spec: docs/specs/spec-005-setup-wizard.md §11.
- 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.
D-045 — the setup gate is now `config.setup`, not the peer/SSH tunnel:
- setup: true -> /setup serves the wizard (the code form first when
`setup_code` is set, via the existing D-044 gate);
GET / points at the wizard; every other route -> 404.
- setup: false + no admin -> LOCKED: GET / and /setup render an "enable
setup" boilerplate; every other route -> 404; the
wizard is not claimable.
- completion -> writes `setup: false` and creates the admin.
`setup_code` is now OPTIONAL: blank => Gate.Required() false => the toggle
alone admits; set => required (reusing the unlock cookie/fingerprint/
redaction/rate-limit). 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). The !bootstrapped() fail-safe becomes the locked predicate,
latched at startup.
New door seams: Deps.LockedMode and SetupGate.{Required,CodeForm,Landing,
Locked}; the Gate renders three first-run pages. The startup banner states
the mode and prints the code only when required.
Tests cover the three states, the boilerplate, the code optionality and the
unlock mechanics; live-smoked end to end (locked / wizard / code form /
full bootstrap -> setup: false).
- PROJECT_PLAN: register D-044, re-revise D-025 to "tunnel OR setup code",
update A13, O-8, §5.1, §12 and the change log.
- SPEC-003 §4/§7/§9/§12: the gate now serves the wizard to the tunnel OR an
unlocked request; AC-7/AC-10 reworded ("refused the wizard" — the boilerplate
may still answer `/` and `/setup`), AC-14 added.
- SPEC-005 §3/§4/§6/§9: the boilerplate, the unlock action, the code rules and
the ACs; AC-7 added for single-use/rate-limit/redaction.
- deploy-zoraxy §1/§3/§5/§7 + README: the LXC/Proxmox topology, how to read the
code (journalctl · pct exec 11020), the code gate replacing the tunnel-only
bootstrap (the tunnel stays as gate a); firewall and Zoraxy sections kept.
The real topology is an LXC container (11020) behind Proxmox + Zoraxy, so
the SSH tunnel is impractical: it lands on the container gateway, not
loopback. D-025 is re-revised and D-044 adds the general gate.
- core/setup: a Gate (gate.go) holding the 128-bit setup code (generated at
first run, or pinned by config `setup_code`) plus a signed unlock cookie
(`myday_setup`) that carries a keyed one-way FINGERPRINT of the code, never
the code itself. The code is single-use (retired on the first success) and
the comparison ignores case/separators.
- The unlock action (`POST /setup?do=setup&action=unlock`) is CSRF-protected
(A15), rate-limited (the /setup A3 class) and audited; a wrong or absent
code gets the same generic 403 page.
- The door (server.Deps.SetupGate): in setup mode the wizard is served to
EITHER the direct tunnel (gate a) OR an unlocked request (gate b); `GET /`
and an un-unlocked `GET /setup` render a friendly first-run page with the
code form — never a bare 404 — and every other route stays 404.
- cmd/myday prints an unmistakable startup banner: setup mode, the wizard
URL, the code, where to read it (journalctl / pct exec 11020) and the
restart that adopts the persisted secrets.
- httpx.Signer.Fingerprint backs the unlock cookie; config gains `setup_code`.
Tests cover the boilerplate at `/` and `/setup` (200, not 404), the unlock
(code → wizard; wrong/absent → generic, 429 under the limit), single-use, the
CSRF requirement, access-log redaction, and that a non-/setup route is still
404. Gate green (build · vet · test -race); live-smoked against the binary.
Document the D-043 deployment: the browser -> Zoraxy (10.1.0.10, TLS) ->
app (0.0.0.0:8080, http) topology, the config.yaml, the Zoraxy ProxyHost
headers (X-Forwarded-For + X-Forwarded-Proto), the firewall rule, the
tunnel-only bootstrap, systemd, and the misconfig warnings.
- config.example.yaml: trusted_proxies: ["10.1.0.10"] with the
silent-failure and firewall caveats.
- deploy/myday.service: the systemd unit (Type=simple, Restart=on-failure,
User/WorkingDirectory/ExecStart, After=network-online.target).
- docs/deploy-zoraxy.md: the full walkthrough.
- README.md: the Deployment section points at the guide.
Docs/deploy only — no code change.
- config.example.yaml: listen 0.0.0.0:8080, base_url https://myday.sechpoint.app,
trusted_proxies = Zoraxy; a comment stating :8080 must be firewalled to Zoraxy
only and that the bootstrap is tunnel-only (D-025/D-043).
- README: a Deployment section (TLS at the proxy, the app on 0.0.0.0:8080, the
firewall rule) and the tunnel-only bootstrap (ssh -L 8080:127.0.0.1:8080 →
http://127.0.0.1:8080/setup, the one path a proxy request cannot take).
Implements D-025 (revised 2026-10-15) and D-043.
Setup gate (D-025):
- Remove the bind refusal (CheckSetupBind/ErrNonLoopbackSetup/server.IsLoopback);
Serve binds any address, so the app can listen on 0.0.0.0:8080 behind Zoraxy.
- The gate is now per request and strict (httpx.SetupRequestAllowed): a loopback
TCP peer AND no X-Forwarded-For — the direct SSH tunnel. Anything else is 404
before dispatch; an unparseable peer fails closed (AC-7/AC-10). The raw XFF is
read before ClientIP SETS it (A6).
Proxy scheme (D-043):
- httpx.RequestIsHTTPS resolves the scheme per request: direct TLS, or a trusted
proxy's X-Forwarded-Proto. The door carries it on the request context
(httpx.WithSecure) so the session cookie, its __Host- prefix, the pre-session
and ceremony cookies, and HSTS all agree (AC-13).
- Over plain http (the tunnel) cookies stay bare and non-Secure, which fixes the
bootstrap from an https:// config.example.yaml; Config.IsSecure() is removed
as now dead.
Tests: rewrite the setup-gate tests for AC-7/AC-10, add AC-13, and unit-test
SetupRequestAllowed / RequestIsHTTPS.
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.
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.
REVIEW-001 (M0) HIGH H2: loopback was enforced only at BIND, so a reverse
proxy on 127.0.0.1 relaying remote clients reached /setup and could create
the top admin — exactly the first-visitor-wins D-025 claims to close "in
code". In setup mode, reject any request whose RESOLVED client IP is not
loopback (404 before dispatch); an unparseable/empty client IP fails closed.
The resolved IP is the one XFF produces, so a trusted proxy forwarding a
remote client does not launder it (see the new test).
Also:
- M1c/L3s: pin the setup module at the door. serveSetupAction rejects
Module != "setup", and any other resolution naming it (a GUID path, a
reserved workflow) 404s instead of falling through to serveGUID.
- L1s: read/clear the session cookie under its transport-correct name.
Tests: the harness speaks from a loopback peer by default (the operator
reaches /setup over a tunnel) and configures a public base_url where the
nudge must fire (L7c). New tests cover the per-request setup gate (including
the trusted-proxy relay and the loopback tunnel) and the module pin, both of
which fail without the fix.
REVIEW-001 (M0) M1s: the /setup wizard's pre-session double-submit ceremony
(A15) was signed with csrf_key/cookie_secret left at the shipped CHANGE_ME
placeholder until `finish` persisted generated values — a publicly-known
signing key for the whole bootstrap.
Generate strong ephemeral values at startup in setup mode, mirroring the
TOTP key (R-6/D-1): `finish` skips a non-placeholder, so the wizard persists
the very values the process already uses.
Also add cmd/myday's first test — REVIEW-001 M2c: the D-1 invariant (the
startup TOTP key is the persisted key) was covered only by a manual smoke. It
generates the key, enrolls a secret, persists via the wizard, reopens the DB
with the persisted key and asserts the secret still unseals.
REVIEW-001 (M0) L1c/L2s: secrets.session_key was validated and generated by
the wizard but never read by any code path — dead config (KISS).
Remove the field, its validation entry and its generation; the example notes
that a placeholder is replaced at startup in setup mode (M1s) as well as by
the wizard's finish.
REVIEW-001 (M0) M3s: the fixed-window limiter's state map was never pruned,
so a flood of distinct keys grew it without bound (memory DoS).
Sweep elapsed windows on insert — gated on a size threshold and at most once
per window, so it stays amortised — and hard-cap the map (65536 entries) as
the backstop. Expose Len() for tests/observability and cover both the sweep
and the cap.
REVIEW-001 (M0) M2s: POST /access was a timing oracle — a hit did two
synchronous SMTP sends, a miss one SELECT, so "is this email on record?"
leaked (A3).
Dispatch the notification through a Deps.Dispatch seam (default `go fn()`),
detached from the request context (context.WithoutCancel, so audit values
survive) and bounded by a 30s timeout. Tests inject a synchronous runner
where they assert no-spam semantics, and a new test asserts the default is
asynchronous with a blocking mailer. A panic in the detached goroutine is
recovered: net/http no longer guards it, and a notification must never take
the process down.
REVIEW-001 (M0) L7c: shouldNudge trusted the request Host, so a reverse
proxy that rewrites Host wrongly suppressed (or enabled) the nudge.
Prefer the configured server.base_url host (Deps.BaseURL) and fall back to
the request Host only when it is unset. Wire the secure session-cookie name
through SessionCookieNameFor so the `__Host-` prefix (L1s) is used
consistently by the flows.
REVIEW-001 (M0) L5s: a code valid for ±90s could be replayed. TOTP is a
login fallback (D-005) and the only factor the bootstrap admin has (D-041),
so a code observed once (shoulder-surfed, phished, in a log) must not work
twice.
Track the highest accepted counter per identity and refuse any counter that
is not strictly newer. The search is newest-first and compares in constant
time; a replay is reported as ErrInvalidCode — indistinguishable from a wrong
code, so the response is not an oracle (A3). A fresh secret (re-enrollment)
resets the counter.
REVIEW-001 (M0) perimeter findings:
- H1: resolveForwarded walked X-Forwarded-For left→right and returned the
leftmost untrusted entry. An append-style proxy (nginx's default) appends
the peer it saw to the RIGHT of whatever the client sent, so the leftmost
entry is attacker-controlled — a fresh rate-limit bucket per request (A3)
and a forged client_ip in the audit trail (A11/D-031). Walk right→left and
return the first untrusted entry, falling back to the peer.
- L1s: the session cookie now carries the `__Host-` prefix when it is Secure
(https), so it is pinned to the exact origin and cannot be shadowed from a
sibling subdomain. Conditional on purpose: a browser rejects a `__Host-`
cookie without Secure, which would break the http loopback bootstrap.
- L4s: add Permissions-Policy, Cross-Origin-Opener-Policy and
Cross-Origin-Resource-Policy.
- Add httpx.IsLoopbackIP: a RESOLVED client IP test that fails closed on an
empty/unparseable value (the setup-mode gate, H2), sharing a classifier
with IsLoopbackHost whose "unknown" direction is the opposite, each
fail-closed for its own decision.
Tests updated: the unit test encoding the vulnerable leftmost expectation is
rewritten, plus right→left/forged-entry/host-classification coverage.
Wires the door: config → store → migrations → the discovered module registry →
nav into the shell → the reserved `static` workflow → serve. Generates the TOTP
sealing key at startup in setup mode (SPEC-009 D-1) so the wizard's TOTP
enrollment precedes Finish. Fails closed on a bad module set.
- One base layout (layout.html) with the header, the nav from Registry.Nav()
(D-038), a content slot and the CSRF token; every page renders inside it and
a module supplies content only
- Embedded same-origin assets (app.css · app.js · webauthn.js · sw.js ·
manifest.webmanifest · icons) served by the reserved `static` workflow
- webauthn.js drives the three ceremonies from data-* attributes — no inline
script, so the CSP `script-src 'self'` holds (AC-3)
- manifest start_url "/" (D-011); the service worker never caches an
authenticated response or /{GUID} (A5)
- Build-time discovery (D-028, the Caddy pattern): a module self-registers from
init(); internal/modules/imports_gen.go (go:generate) blank-imports every
module directory, so THE DIRECTORY IS THE SOURCE OF TRUTH
- The registry: deterministic registration order, config-backed enable/disable
(D-038), required modules, name-prefixed migrations, Mount() into the door
- The name-collision guard now also fails a module named for a reserved
level-1 workflow (SPEC-009 §13 Q-1) — access · static · api · media · ws ·
dev — derived from routing.ReservedWorkflows, `home` excepted (D-2)
- home: the reference dashboard module, and the core Modules page
Reserved, setup-mode-only, loopback-only: the domain, the top admin, the
authenticator (TOTP), mail/locale — then Finish persists and the dashboard
becomes reachable. The wizard IS the bootstrap (D-026); the TOTP key is
generated at startup so TOTP enrollment precedes Finish (SPEC-009 D-1).
- webauthn: the passkey ceremony service (a fake in tests)
- totp: the TOTP authenticator; the secret is AEAD-sealed at rest (A9)
- sealer: the AEAD TOTP sealer
- identities/sessions/credentials: the GUID state machine
- flow: the enroll/login screens + the passkey nudge (pre-session
double-submit CSRF — A15), rendered inside the shell
- store: SQLite (WAL, foreign keys ON, CGO-free driver) + the embedded
migration runner; each module's migrations are name-prefixed (D-012)
- audit: the append-only audit log; the actor threads via context (R-1)
- users: the user_id/GUID/email anchors (D-013)
- config: the YAML settings file (D-035) + validation
- httpx: cookies · loopback detection · trusted-proxy XFF · CSRF · security
headers · redaction
- middleware: correlation id · audit · rate limiting
- mail: the invite/recovery mailer seam
- server: the core DOOR — the resolution switch, the dispatch registry
(RegisterModule), the role gates, /setup · enroll/login · recovery flows,
the reserved `static` workflow, and the loopback-only bootstrap (D-025)
Turns a request path plus the two routing fields (`do`/`action`) into a single
Resolution for BOTH doors (D-039): the RESERVED level-1 switch first
(home/access/setup/api/static/media/ws/dev — D-004), then the GUID classifier,
then the registry. Identity and the module registry are injected interfaces, so
the package is PURE — no HTTP stack, no DB (asserted by purity_test.go). All 22
spec rows are covered.
The bare `myday` pattern matched any path component named `myday`, including
the `cmd/myday/` source directory — so `cmd/myday/main.go` (the entrypoint)
could never be committed and a fresh clone would not build. Anchor it to the
repo root (`/myday`); the built binary still falls out of git.
- PROJECT_PLAN.md: the decision log of record through D-042 and the M0.1 slice
- docs/specs: SPEC-002…009 — routing · door · identity/sessions · /setup ·
enroll/login · roles/chain/recovery · module registry · the web layer & PWA
(plus docs/specs/README.md, the index)
- AGENT.md: the working rules — commit AND push at every submilestone (gate
green), Conventional Commits, never commit secrets
- README.md: the quickstart, the first-run/SSH-tunnel steps and the
localhost/loopback caveat (SPEC-009 §13 Q-3): with an https base URL the
CSRF/session cookies are Secure, so reach the app at localhost/127.0.0.1
- config.example.yaml: the settings template (D-035)