NextMyDay/docs/specs/spec-003-core-door.md
Claus Lohmar 57610cdbae docs: add the project plan, M0/M0.1 specs, agent guide and README
- 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)
2026-10-14 10:53:44 +00:00

158 lines
7.4 KiB
Markdown

# 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 |
| **Created** | 2026-10-13 |
| **Author** | OpenArchitect |
| **Target milestone** | **M0** |
| **Governing decisions** | **D-002** · **D-003** · **D-025** · **D-031** · **D-039** · `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`; SET `X-Forwarded-For` (never append) [A6]
3. security headers — X-Content-Type-Options · Referrer-Policy · a strict CSP · HSTS (when https)
4. routing.Dispatch() — the pure model (SPEC-002)
5. 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
6. audit — every auth event + every mutation (A11)
7. 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 door renders no dynamic HTML** — it resolves, gates, and hands off.
---
## 5. `RequestContext` (in-process hand-over)
```go
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).
---
## 6. Session cookie & CSRF
- Cookie `myday_session`: **`HttpOnly` · `Secure` · `SameSite=Lax` · `Path=/`**.
- 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.
- **Logout** revokes the session row.
---
## 7. Loopback enforcement (D-025)
- In **setup mode**, if `server.listen` is **not loopback**, the process **refuses to start**
(fail-closed). The operator bootstraps over an **SSH tunnel** to `127.0.0.1:8080`.
- Once the top admin exists (`setup: false`), a public bind is allowed.
---
## 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 | In setup mode with a non-loopback `listen`, the process **exits with an error** (D-025) |
| 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) |
---
## 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: `session_key`/`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. |
| 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. |