NextMyDay/docs/specs/spec-003-core-door.md
Claus Lohmar 9fdc9eded6 docs: update the plan, specs and deploy guide for the setup toggle (D-045)
- 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.
2026-10-15 06:47:36 +00:00

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 the context.Context, so services can write their audit rows (SPEC-004 §5, ruling R-1).
  • The setup module is pinned at the door: the wizard's functions are reachable only through the reserved setup workflow; 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.
  • Principal and the credential (session cookie) are separate concerns (D-001).

  • 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 with Secure · Path=/ · no Domain, 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 without Secure, 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:8080 behind Zoraxy (D-043).
  • The gate is the config toggle setup (D-045; the peer/tunnel rule of D-025 is retired).
    • setup: true → /setup serves the wizard. If setup_code is 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 / and GET /setup show an "enable setup" boilerplate; every other route is 404. Not publicly claimable.
  • Once productive (setup: false with an admin), normal service: the trusted proxy's X-Forwarded-Proto drives Secure on 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).

  • dispatch gains 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 the setup module and CSRF-protected (A15) and rate-limited (A3).
  • GET / and an un-unlocked GET /setup are handed to the first-run boilerplate — the door resolves to SetupGate.Boilerplate rather 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 no setup_code is required, else it needs a valid SetupGate.Unlocked(r). /setup with 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 / and GET /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 }.