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)