- 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.
15 KiB
SPEC-003 — the core door (internal/core/server + httpx)
| Field | Value |
|---|---|
| Status | Implemented (2026-10-13) — see §10; build/vet/test/-race green · static binary · live-smoked. Hardened 2026-10-14 — see §11 (REVIEW-001 H1/H2/M1c/M3s/L1s/L4s). |
| Created | 2026-10-13 |
| Author | OpenArchitect |
| Target milestone | M0 |
| Governing decisions | D-002 · D-003 · D-031 · D-039 · D-043 · D-045 · PROJECT_PLAN.md §4/§12 |
| Related | SPEC-002 (routing) · SPEC-004 (identity) |
1. Goal
The single HTTP entry — the door — that turns an inbound request into a dispatched module function, applying identity / session / role and all cross-cutting concerns. The door resolves and gates; a module never sees a request it shouldn't (D-002, D-039).
2. Non-goals
- The routing model → SPEC-002.
- Identity/session storage, users, roles, the chain → SPEC-004.
- The
/setup, enroll, login, recovery flows → SPEC-005+.
3. Structural changes
| Path | Purpose |
|---|---|
cmd/myday/main.go |
assemble config → core → discover modules → serve |
internal/core/server/ |
the door pipeline + dispatch |
internal/core/httpx/ |
session cookie · client IP (trusted proxies) · RedactPath · security headers · CSRF |
internal/core/middleware/ |
correlation id · rate limit · audit |
4. The door pipeline
request
1. correlation id — generate a UUID; put it on the context and in every log line
2. client IP — resolve from `server.trusted_proxies`; walk XFF **right→left** and
SET it to the one resolved value (never append) [A6/H1]
3. security headers — X-Content-Type-Options · Referrer-Policy · a strict CSP · HSTS (when https)
· Permissions-Policy · COOP · CORP [L4s]
4. routing.Dispatch() — the pure model (SPEC-002)
5. setup gate — the gate is the config toggle `setup` (D-045). While `setup: true`
`/setup` serves the wizard (the code form first when `setup_code` is
set, D-044); `GET /` points at it; everything else 404. `setup: false`
with no admin is the LOCKED state: `/` and `/setup` get the "enable
setup" boilerplate, everything else 404 (§7)
6. switch on Kind:
Redirect → 302
NotFound → 404
Setup → the setup wizard, ONLY in setup mode; else 404
Enroll → the enrollment screen (if already enrolled → redirect)
Route/Action → [the identity gate]
a. load the session cookie → validate:
exists · not expired · not revoked · **non-empty session id** [A2]
b. session.user_id == the resolved GUID's user (the identity match)
c. missing/invalid → KindLogin (login screen)
d. ROLE gate: does the user's role permit this function?
e. build the RequestContext → dispatch to the module function
7. audit — every auth event + every mutation (A11)
8. rate limit — `/{GUID}`, `/access`, `/setup` (A3)
- Before dispatching, the door attaches
audit.Meta{ActorUserID · ClientIP · CorrelationID}to thecontext.Context, so services can write their audit rows (SPEC-004 §5, ruling R-1). - The
setupmodule is pinned at the door: the wizard's functions are reachable only through the reservedsetupworkflow; any other resolution naming it 404s (M1c/L3s).
The door renders no dynamic HTML — it resolves, gates, and hands off.
5. RequestContext (in-process hand-over)
type RequestContext struct {
Workflow string // PARAM1 classification
UserID int64 // the resolved user (0 when anonymous)
Principal string // "user:<id>" — IDENTIFICATION only, never a credential
Function string // the resolved module function / action
Page string // the joined remainder
Correlation string // the request's correlation id
ClientIP string // resolved client IP
Location string // IP-derived, AUDIT ONLY (D-031) — no tracking
}
- Carries identification, never a credential.
Principaland the credential (session cookie) are separate concerns (D-001).
6. Session cookie & CSRF
- Cookie
myday_session:HttpOnly·Secure·SameSite=Lax·Path=/. - Over https the cookie carries the
__Host-prefix (__Host-myday_session, L1s): a browser only accepts it withSecure·Path=/· noDomain, so the cookie is pinned to the exact origin and a sibling subdomain cannot shadow it. On plain-http loopback (the bootstrap / human test) the bare name is used — a browser rejects a__Host-cookie sent withoutSecure, so the prefix cannot be unconditional. - Opaque random id; the server-side row is authoritative (SPEC-004).
- CSRF: a per-session token on every action (
do/action) — hidden field, verified server-side (A8). The action fields are not a security control on their own. - The pre-session double-submit token (A15) covers every anonymous form (
/setup,/access, enroll/login). In setup mode the CSRF + cookie signing keys are generated at startup when left at a placeholder, so the ceremony is never signed with a shipped, public key (M1s). - Logout revokes the session row.
7. Setup gate (D-045)
- No bind refusal. The app may bind any address —
0.0.0.0:8080behind Zoraxy (D-043). - The gate is the config toggle
setup(D-045; the peer/tunnel rule of D-025 is retired).setup: true→/setupserves the wizard. Ifsetup_codeis set the code form comes first (D-044) and the unlock is a signed cookie; otherwise the toggle alone admits.GET /points at the wizard; every other route is 404.setup: false, no admin → the LOCKED state:GET /andGET /setupshow an "enable setup" boilerplate; every other route is 404. Not publicly claimable.
- Once productive (
setup: falsewith an admin), normal service: the trusted proxy'sX-Forwarded-ProtodrivesSecureon the session cookie, the__Host-prefix, and HSTS (§6 · D-043). The XFF handling keeps the client IP honest (A6) — it is no longer a gate.
8. Logging & redaction
slog, structured, with the correlation id on every line.- The access log records the raw path +
PARAMS; the GUID is public (a username) but any credential that rides the path is redacted (RedactPath) — A8/A11. - The door logs the resolved
(workflow, module, function); a module logs its own steps.
9. Acceptance criteria
| # | Criterion |
|---|---|
| AC-1 | / → 302 /home; /holy-shit → 404 |
| AC-2 | /home renders with no session and no DB lookup |
| AC-3 | /{GUID} with no session → login; with a valid session → the module function |
| AC-4 | A session whose user_id ≠ the GUID's user is rejected |
| AC-5 | An empty / missing session id is rejected on every path (A2) |
| AC-6 | A do/action request without a valid CSRF token is rejected |
| AC-7 | While the wizard is enabled (setup: true) with a required setup_code, GET /setup shows the code form to every peer; only an unlocked request reaches the wizard. The peer/tunnel rule of D-025 is retired (D-045) |
| AC-8 | Every response carries a correlation id, echoed in the log; path credentials are redacted |
| AC-9 | The rate limiter returns 429 on the auth-sensitive routes (A3) |
| AC-10 | While the wizard is enabled with a required code, no peer reaches the wizard without the unlock; the code form is shown instead, and every non-wizard route is 404 (D-045, superseding D-025/H2) |
| AC-11 | The setup module is reachable only through Workflow == "setup"; any other resolution naming it is 404 (M1c/L3s) |
| AC-12 | X-Forwarded-For is resolved right→left; a forged leftmost entry is ignored (H1/A6) |
| AC-13 | Behind the proxy, X-Forwarded-Proto: https ⇒ the session cookie is Secure + __Host-, and HSTS is set (D-043) |
| AC-14 | setup: true → GET /setup serves the wizard (or the code form when setup_code is set); GET / points at it; the correct setup code unlocks it, a wrong/absent one does not (D-044/D-045). setup: false with no admin → a locked boilerplate at / and /setup (never a bare 404) |
10. Implementation notes (accepted rulings)
Built 2026-10-13. build · vet · test · -race green; static CGO-free binary; live smoke
verified (/→302 · /home→200 · /setup→200 · /holy-shit→404 · correlation id in header and
log · loopback enforcement · fail-closed secrets). internal/core/routing purity intact.
| # | Builder choice | Ruling |
|---|---|---|
| D-1 | gopkg.in/yaml.v3 + KnownFields(true) (unknown config key = error) |
Accepted — pure-Go, static-safe, fail-closed. |
| D-2 | Secret strength: cookie_secret/csrf_key ≥ 32 bytes; totp_encryption_key = 32 bytes; CHANGE_ME rejected in productive mode |
Accepted — to be documented in the config reference. Amended 2026-10-14: the dead session_key is removed (L1c/L2s). |
| D-3 | CSRF = stateless HMAC-SHA256(csrf_key, "myday.csrf.v1:"+session_id) |
Accepted — no extra table; dies with the session. |
| D-4 | In-memory fixed-window rate limiter (30/min; classes access·setup·guid) |
Accepted. |
| D-5 | RoleGate seam, defaulting to allow any authenticated user |
Accepted — the policy lands in SPEC-007/008. |
| D-6 | Env: config path via MYDAY_CONFIG only |
Accepted; per-key env override deferred. |
| D-7 | RequestContext = exactly §5 (no Module field) |
Accepted — module identity stays on Resolution. |
Rulings on the flagged items:
| # | Item | Ruling |
|---|---|---|
| R-4 | auth.Mint/Rotate/Revoke hard-code the owning user as actor. |
Use the context actor (creator/approver); the affected user is the subject — see SPEC-004 §8. |
| R-5 | Pre-session CSRF — /setup, /access and the enroll/login actions run before a session, so §6's session-bound CSRF doesn't cover them. |
Must be added — a signed double-submit cookie for anonymous forms. Applies to SPEC-005/006/007; tracked as A15. |
| — | RequestContext.Location always empty (D-031) |
Accepted — the seam exists; the IP-geo lookup is deferred. |
| — | RedactPath over-redacts a segment after a credential-like name |
Accepted — log-only; no leak. |
11. M0 review hardening (2026-10-14 · REVIEW-001)
The independent M0 review found the identity/session/CSRF core sound but the perimeter weak. The fixes touching this spec:
| Finding | Fix |
|---|---|
H2 — dispatch checked loopback only at bind |
§7: a per-request gate — in setup mode a non-loopback resolved client IP is 404 before dispatch (AC-10). |
H1 — resolveForwarded returned the leftmost untrusted hop |
ClientIP now walks right→left and returns the first untrusted entry, falling back to the peer (AC-12). An append-style proxy can no longer be forged past. |
M1c — the /setup action door was not pinned to the module |
serveSetupAction rejects Module != "setup" (AC-11). |
L3s — /{GUID}?do=setup&… fell through to serveGUID |
The door rejects any resolution naming the setup module outside Workflow == "setup", in every mode (AC-11). |
| M1s — placeholder signing keys ran the whole setup | cmd/myday generates the CSRF/cookie keys at startup in setup mode, mirroring the TOTP key (R-6/D-1); finish persists the same values. |
| M3s — the rate limiter's state map was never pruned | middleware.RateLimiter sweeps elapsed windows (time-gated, amortised) and caps the map. |
L1s — no __Host- cookie prefix |
§6: session cookie is __Host-myday_session over https. |
| L4s — missing hardening headers | Permissions-Policy · Cross-Origin-Opener-Policy · Cross-Origin-Resource-Policy (§4 step 3). |
L1c/L2s — dead secrets.session_key |
Removed (D-2 amended). |
L7c — the passkey nudge trusted the client Host |
SPEC-006 §8: the configured base_url host is the origin signal, falling back to the request Host. |
build · vet · test -race green; the static CGO-free binary is unchanged;
internal/core/routing purity intact.
12. D-044 — the setup code gate & the first-run boilerplate (2026-10-15b)
Superseded in part by D-045 (§13). The code mechanics below stand; the tunnel gate (a) and the generated code are retired, and
GET /is no longer the code form.
The topology (LXC behind Proxmox + Zoraxy) makes the SSH tunnel impractical: pct enter/SSH
lands on the container gateway, so a tunnel to 127.0.0.1:8080 cannot be formed and gate (a)
alone would lock the operator out. D-025 is re-revised so /setup is served when either
(a) the request is the direct tunnel or (b) it is unlocked with the setup code (D-044).
dispatchgains the gate:unlocked := SetupGate.Unlocked(r);allowed := setupTunnel || unlocked. The unlock action (POST /setup?do=setup&action=unlock) is the ONE request served without either — it is what grants the unlock — and it is still pinned to thesetupmodule and CSRF-protected (A15) and rate-limited (A3).GET /and an un-unlockedGET /setupare handed to the first-run boilerplate — the door resolves toSetupGate.Boilerplaterather than a 404, so the domain never looks "down".- The gate is a narrow interface on
server.Deps(implemented by*setup.Gate), so the door keeps rendering no dynamic HTML itself. - The code is single-use, rate-limited and redacted (A8); the unlock is a signed cookie carrying a keyed fingerprint of the code, never the code (SPEC-005 §9).
AC-7/AC-10 are reworded: "refused" now means "refused the wizard" — the boilerplate may still
answer GET //GET /setup with 200 (AC-14).
13. D-045 — the toggle gate, the optional code, the retired tunnel (2026-10-15c)
The peer/tunnel gate is retired: setup: true is the gate. dispatch evaluates the config
toggle per request:
setup: true—setupAdmitted(r)is true when nosetup_codeis required, else it needs a validSetupGate.Unlocked(r)./setupwith a required code un-entered →SetupGate.CodeForm;GET /→SetupGate.Landing; the unlock action is the one carve-out; every other route → 404 (static assets are served so the first-run pages render inside the shell).setup: false, no admin — the LOCKED state (Deps.LockedMode):GET /andGET /setup→SetupGate.Locked; every other route → 404.
Removed: httpx.SetupRequestAllowed, httpx.IsLoopbackIP (and their tests). Kept: the XFF
right→left client-IP resolution and the X-Forwarded-Proto-driven Secure/__Host-/HSTS (A6/A11
· D-043). SetupGate is now { Required · Unlocked · CodeForm · Landing · Locked }.