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)
This commit is contained in:
Claus Lohmar 2026-10-14 10:53:44 +00:00
parent f81dac9881
commit 57610cdbae
13 changed files with 1497 additions and 4 deletions

79
AGENT.md Normal file
View file

@ -0,0 +1,79 @@
# AGENT.md — MyDay
> Operating instructions for AI agents working in this repository.
> **Status:** M0 + M0.1 implemented (SPEC-002…009); next milestone **M1 — org structure**.
---
## 1. What MyDay is
**MyDay** is Sechpoint's **self-hosted, single-tenant** employee app (a **PWA** for mobile and
desktop): **time & absence**, **reporting**, and the **company org structure** those depend on.
It is **one Go binary** — a small **core** (`MyDay.auth`) plus drop-in **modules**.
---
## 2. The source of truth
| Artefact | Role |
|---|---|
| **`PROJECT_PLAN.md`** | the **decision log of record** — read it first. If a decision changes, update it **in the same change**. |
| **`docs/specs/`** | implementation specs. **Specs precede code.** |
| **`README.md`** | the human quickstart. |
| **`.opencode/git.env`** | the agent **git-workflow contract** (gitignored; see `.opencode/git.env.example`). |
---
## 3. Doctrine — the rules that must not be broken
1. **The path is the request.** Identity lives in the path; the query carries only transient state.
2. **The rule of the door.** The door *resolves*; the handler *authenticates*. A module **never
parses a raw path**.
3. **Identification ≠ authorisation.** The GUID is a *username*; the **session cookie is the
credential**. A bookmark is never a login.
4. **Two doors, one destination.** ROUTER (path) and HANDLER (`do`/`action`) both resolve to
`(module, function, params)` (D-039).
5. **Modules are independent.** A module depends only on **core interfaces** and **owns its own
tables** — never reach into another module's data.
6. **Delete, don't redirect.** Reserved names come from one source.
7. **Every page renders inside the shell.** The core layout owns the document, the header and the
nav (`Registry.Nav()`); a module supplies **content** only. The CSP is `script-src 'self'`:
**no inline `<script>`, no `on*=`, no `javascript:`** — the WebAuthn ceremonies are driven from
`data-*` attributes by `static/webauthn.js`. Static assets are served by the reserved `static`
workflow (`internal/core/server/static.go`).
---
## 4. Stack & layout
- **Go** (single binary) · **SQLite** (WAL · foreign keys ON · CGO-free driver) · `sqlc` · `slog`
- `internal/core/` — the door · registry · identity · sessions · roles · chain · DB
- `internal/modules/<name>/` — `app.go` · `routing.go` · `handlers.go` (+ `queries.go` ·
`migrations/` · `web/`)
See `PROJECT_PLAN.md` §12.1.
---
## 5. Working rules
- **Read `PROJECT_PLAN.md` before changing anything structural.** Decisions live there.
- **Spec before code.** New work starts as `docs/specs/spec-NNN-*.md`.
- **Commit AND push at every submilestone** (each spec slice or revision) once the gate —
`go build` · `go vet` · `go test -race` — is **green**. `main` is the source of truth.
Group into **Conventional Commits** (`feat:` · `fix:` · `docs:` · `chore:` …), one per area/submilestone.
- **Never commit secrets** — `config.yaml`, `.opencode/git.env`, `*.db` are gitignored (D-010).
- **Never set git identity globally** — repo-local only.
- **Do not force-push.**
- When a decision changes, **update `PROJECT_PLAN.md` in the same commit**.
---
## 6. Current phase
**M0 (auth + routing foundation) and M0.1 (the web layer / PWA) are complete** — SPEC-002 … SPEC-009
are implemented: routing · identity/sessions · the door · `/setup` · enroll/login (+ the passkey
nudge) · roles/chain/recovery · the module registry · the web shell & PWA.
**Next:** **M1 — org structure** (`PROJECT_PLAN.md` §11).

View file

@ -472,6 +472,9 @@ the top admin (first user)
| **D-037** | 2026-10-13 | **Every module has the same default skeleton** — `app.go` (the manifest: name · required · migrations · nav · `Start`) · `routing.go` (routes + actions) · `handlers.go` (the action handlers). More files as needed. | Predictability + drop-in: every module reads the same way. |
| **D-038** | 2026-10-13 | **Modules are enabled/disabled from a core Modules page** (state in config, D-035). Disabling unmounts routes + hides nav; **it never deletes data**. **Required** modules can't be disabled. | Runtime activation of compiled-in modules. Toggles are **audited** (A11). |
| **D-039** | 2026-10-13 | **Two level-1 entry points — ROUTER (path) and HANDLER (actions) — both resolve to `(module, function, params)`.** The branch is keyed on the **routing fields** (`do`/`action`), **not** the HTTP method. Identity + role gate run **before** the module function. | The top-level dispatch, made explicit: **two doors, one destination.** |
| **D-040** | 2026-10-13 | **Agent-workflow variables live in `.opencode/git.env`** (gitignored), with a committed `.opencode/git.env.example`. Git-native settings (identity · remote · branch · pull) live in repo-local `.git/config`. | Two-tier config (SPEC-001 A-04): git owns what git owns; automation knobs stay out of git. |
| **D-041** | 2026-10-14 | **The setup wizard enrolls TOTP only.** Any **TOTP login with no passkey** is **nudged** to enroll one, with *not now* / ***don't ask again*** (`users.passkey_opt_out`). The nudge is **suppressed while the origin is loopback** (or in setup mode). | Resolves **O-13**: a passkey enrolled over loopback binds RP ID `127.0.0.1` and is unusable at the public domain. Passkeys are encouraged, not forced — TOTP stays a valid factor (D-005). |
| **D-042** | 2026-10-14 | **TOTP-only enrollment is specific to the bootstrap admin (`/setup`).** **Invited users enroll both factors** (Passkey + TOTP) at the real origin; the passkey **nudge** remains the safety net for any identity without a passkey. | Resolves **O-14** (owner, Option A): keep the **strongest default** where the origin permits it. |
---
@ -489,6 +492,9 @@ the top admin (first user)
| O-9 | "KISS" = the **principle** | **RESOLVED** (D-034): a **simple tiered model, no ACL** — `admin > director > manager > team`; a role **implies all lower roles**; roles assigned via **multi-select tick boxes**. |
| O-10 | Where settings live | **RESOLVED** (D-035): **the config file** — settings are simpler to change there than via a DB migration. The **DB holds operational data only**. |
| O-11 | The `requests` engine (data-model) | **RESOLVED / build-time** — the dispatch is settled (D-032 · D-039). Whether `requests` is a generic engine or `absence`/`time` own their flows is an **internal data-model choice inside the module**, decided at build (M3). **Not a plan-blocker.** |
| O-13 | WebAuthn origin binding at bootstrap | **RESOLVED** (D-041): the wizard enrolls **TOTP only**; the passkey is added at first login on the real origin, via a **nudge** with an opt-out marker. |
| O-14 | Enrollment reach of the passkey nudge | **RESOLVED** (owner, Option A · D-042): TOTP-only is the **bootstrap admin only**; **invited users enroll both factors**. The nudge is the safety net for any identity without a passkey. |
| O-15 | Module names vs reserved level-1 workflows | **RESOLVED** (SPEC-009 §13 · Q-1): **extend the guard** — a discovered module named for a reserved level-1 workflow (`static` · `access` · `api` · `media` · `ws` · `dev`) **fails to load**, **except `home`** (a legitimate module). |
---
@ -513,6 +519,7 @@ the top admin (first user)
| A12 | Changing the **email on record** is a privileged, audited operation | HIGH | open |
| A13 | **Setup window hardened** — `/setup` served **only** in setup mode; **loopback-only (enforced)**; rate-limited; `/setup` → 404 once productive | HIGH | designed (D-025/D-026) |
| A14 | **Top admin protected** — cannot be deleted; `reset_admin` rotations audited; startup warning while setup mode is reachable | HIGH | open |
| A15 | **Pre-session CSRF** — the anonymous forms (`/setup` · `/access` · enroll/login) need a signed **double-submit** cookie (SPEC-005/006/007) | HIGH | open |
---
@ -520,8 +527,8 @@ the top admin (first user)
| # | Milestone | Depends on | Notes |
|---|---|---|---|
| **Prep** | **Repo bootstrap** — `git init` · `.gitignore` · `config.example.yaml` · **`AGENT.md`** · `README.md` · commit workflow | — | **planning artefact — done first** |
| **M0** | **Auth + routing foundation** — router cascade, door, identity, session, GUID enroll/login, **`/setup` wizard** + top admin, roles, recovery, minimal `manager_user_id` | Prep | *starting after Prep* |
| **Prep** | **Repo bootstrap** — git ✅ · `.gitignore` ✅ · `docs/specs/` ✅ · `AGENT.md` ✅ · `README.md` ✅ · `config.example.yaml` ✅ | — | **done** |
| **M0** | **Auth + routing foundation** — router cascade, door, identity, session, GUID enroll/login, **`/setup` wizard** + top admin, roles, recovery, minimal `manager_user_id` | Prep | **✅ done** (SPEC-002…008) |
| **M1** | **Org structure** — departments, positions, richer hierarchy | M0 | enriches the chain |
| **M2** | **Time** — clock/timesheets | M1 | kiosk/shared-device mode is a design concern here |
| **M3** | **Absence** — leave, approvals, balances | M1, M2 | `request/pto` lands here |
@ -571,14 +578,19 @@ shell/layout lives in `core/web`; a module's views live with the module.
- **Branching:** `main` is the source of truth; work happens on short-lived feature branches.
- **Commits:** **Conventional Commits** (`feat:` · `fix:` · `docs:` · `chore:` …), one logical change each.
- **The agent commits its own slices**; if a decision changes, **this plan is updated in the same change**.
- **The agent commits AND pushes at every submilestone** (each spec slice or revision) once the gate
(`build` · `vet` · `test -race`) is green; if a decision changes, **this plan is updated in the same commit**.
- **Secrets are never committed** — `config.yaml` is gitignored from commit one.
- **The agent git contract** is `.opencode/git.env` (gitignored); `.opencode/git.env.example` is
committed. Git-native settings are **repo-local** (`.git/config`) — **never** a global identity.
- **Remote & credentials:** the remote URL is **clean** (no token); the token lives in
`~/.git-credentials` (mode `600`). **Rotate any token ever transmitted in plaintext.**
## 13. Conventions
- **Language/stack:** Go; **single binary**; **SQLite** (WAL, foreign keys ON, CGO-free driver);
`sqlc`; embedded migrations; `slog` with a correlation id.
- **Docs:** specs live in `docs/specs/`; this file is the decision log of record.
- **Docs:** specs live in `docs/specs/` (index: `docs/specs/README.md`); this file is the decision log of record.
- **Source doctrine:** GMS `ROUTING.md` + `SECURITY.md` (references, not scope).
- **Rule of the door:** the door resolves; the handler authenticates; the handler never parses a raw path.
@ -603,3 +615,21 @@ shell/layout lives in `core/web`; a module's views live with the module.
| 2026-10-13 | Closed **O-7** (one admin — the Highlander), **O-9** (KISS = principle; tiered roles, no ACL, D-034), **O-10** (settings in the config file, D-035). Added **D-036 layout** (separated `core/` + `modules/`), rewrote **§12.1**. |
| 2026-10-13 | **D-034 amended** — one role per user, ripple-down, **no tick-boxes**. **D-037** module skeleton (`app.go` · `routing.go` · `handlers.go`); **D-038** Modules page (enable/disable, required modules, never deletes data); §12.1 shows the module skeleton. |
| 2026-10-13 | **Level-1 dispatch made explicit (D-039)** — ROUTER (path) vs HANDLER (`do`/`action`), keyed on the **routing fields, not the method**; both resolve to `(module, function, params)`. **O-11 closed** as a build-time data-model detail. **The open list is now empty.** |
| 2026-10-13 | **Prep artefacts written** — `AGENT.md` · `README.md` · `config.example.yaml`. **D-040** the `.opencode/git.env` contract (SPEC-001 A-04); §12.2 updated with the git/credential contract; Prep marked done. |
| 2026-10-13 | **M0 specs started** — **SPEC-002** (the routing model · the Stage-0 gate) · **SPEC-003** (the core door) · **SPEC-004** (identity & sessions) · `docs/specs/README.md` index. Next: SPEC-005 (`/setup`), SPEC-006 (enroll/login), SPEC-007 (roles/chain/recovery), SPEC-008 (registry). |
| 2026-10-13 | **M0 spec set COMPLETE** — **SPEC-005** (`/setup` wizard) · **SPEC-006** (enroll/login) · **SPEC-007** (roles/chain/recovery) · **SPEC-008** (module registry + Modules page). All eight specs are *Ready for implementation*; the index reflects it. |
| 2026-10-13 | **SPEC-002 ✅ IMPLEMENTED** — `go.mod` · `cmd/myday` stub · `internal/core/routing` (pure model + all 22 test rows + a real purity assertion). `build`/`vet`/`test` green. Builder's D-1..D-6 rulings accepted and recorded in SPEC-002 §7. **Next build slice: SPEC-004.** |
| 2026-10-13 | **SPEC-004 ✅ IMPLEMENTED** — `core/store` (modernc SQLite · WAL · migrations) · `core/audit` · `core/users` · `core/auth` (identities · sessions · AEAD TOTP sealer). AC-1..8 green, `-race` clean, **static CGO-free binary**. **R-1 ruling**: audit metadata threads via `context.Context`; SPEC-004 §5 rewritten. Next: **SPEC-003**. |
| 2026-10-13 | **SPEC-003 ✅ IMPLEMENTED** — `core/config` · `core/httpx` · `core/middleware` · `core/server` (the door) · `main` wired. AC-1..9 green, static binary, loopback fail-closed, live-smoked. Rulings: **R-4** (context actor for identity ops) · **R-5 → A15** (pre-session CSRF, also for enroll/login). Next: **SPEC-006**. |
| 2026-10-13 | **SPEC-006 ✅ IMPLEMENTED** — WebAuthn (`webauthn.Service`, fake in tests) · TOTP (sealed secret) · the GUID state machine in `auth/flow` · pre-session double-submit CSRF (**A15**) · enrollment window. AC-1..7 green, `-race` clean, live-smoked. **R-4** applied. **F-1** (login screen 200) → folded into SPEC-005; **SPEC-009** (web/PWA) recorded. Next: **SPEC-005**. |
| 2026-10-13 | **F-1 FIXED + SPEC-005 ✅ IMPLEMENTED** — the **login screen returns 200** (a rendered page; only a *failed credential* is 401/400). The **`/setup` wizard** (`core/setup`): setup mode is **latched at startup** (`config.setup` OR not-yet-bootstrapped) and cleared by an atomic `Done()` — so it holds through the whole wizard and avoids a per-request DB hit / data race; the door serves **only `/setup`** (+ static) in setup mode; steps domain → top admin (**reusing SPEC-006's enroll ceremony**, `flow.Deps.SuccessPath`) → mail/locale; persists `config.yaml` with `setup: false` (or **prints** when unwritable). One admin only; pre-session CSRF (**A15**) on every step. AC-1..6 green, `-race` clean, static binary, live-smoked (incl. the fail-safe `setup:false`/empty-DB path). Additive: `Identities.ByUserID` · `config.Marshal`. Class B flagged (secrets not generated; WebAuthn RP built at startup). Next: **SPEC-007**. |
| 2026-10-13 | **SPEC-005 rulings** — **R-6**: the wizard **generates strong secrets** when a secret is empty/placeholder. **F-2**: `Cache-Control: no-store` on the enroll/TOTP (`otpauth`) responses. **O-13 opened** (§9): a passkey enrolled over plain-HTTP loopback binds RP ID `127.0.0.1` — unusable at the public domain; **owner decision**. Next: **SPEC-007**. |
| 2026-10-13 | **SPEC-007 ✅ IMPLEMENTED** — `core/mail` (fake + SMTP) · `core/recovery` (`/access` flow · **D-033** admin path) · `core/server/roles.go` (the **D-034** RoleGate policy). AC-1..6 green, `-race` clean, live-smoked (generic response · admin flag on/off). **Step 0** (R-6 secrets · F-2 `no-store`) landed. **R-7 ruling**: recovery approval is the **chain** (manager upward) or the admin, never "role above" — folded into SPEC-008. Next: **SPEC-008**. |
| 2026-10-13 | **SPEC-008 ✅ IMPLEMENTED → M0 COMPLETE.** `core/module` (contract · registry · Modules page) · `internal/modules/` (the `go generate` discovery + the reference `home` module) · wiring. AC-1..6 green, `-race` clean, static binary, live-smoked (a dropped module registered with **no core edits**). **R-7** applied. Follow-ups: module **name-collision guard** · a neutral `handoff` package (invert `module → server`) · nav rendering is SPEC-009. **All M0 specs (002–008) implemented.** |
| 2026-10-14 | **O-13 RESOLVED (D-041).** The setup wizard enrolls **TOTP only**; any **TOTP login with no passkey** is **nudged** to add one (*not now* / *don't ask again* → `users.passkey_opt_out`). The nudge is **suppressed on loopback / in setup mode** (a `127.0.0.1` passkey is dead at the public domain). SPEC-005/006/004 revised. |
| 2026-10-14 | **D-041 IMPLEMENTED.** Migration `0002_passkey_opt_out.sql` · `users.PasskeyOptOut`/`SetPasskeyOptOut` · `/setup` step 2 enrolls **TOTP only** (`flow.Deps.TOTPOnlyEnroll`, the wizard's own flow) · the **passkey nudge** after a TOTP login (screen `GET /{GUID}/auth/passkey`, actions `passkey.add`/`later`/`never`, session-gated). AC-8…AC-11 + revised setup ACs green, `-race` clean, static binary. |
| 2026-10-14 | **D-041 rulings** — `passkey.later` is a skip (nothing persisted, **not** audited); `passkey.never` / `passkey.add` **are** audited. The nudge fires only on the **active-identity** TOTP branch; any lookup error **fails safe** (no nudge). **O-14 opened** (§9): the nudge's reach — keep both-factor onboarding, or go **TOTP-first universal**. |
| 2026-10-14 | **O-14 RESOLVED (D-042, Option A)** — TOTP-only enrollment is the **bootstrap admin only**; **invited users enroll both factors**; the nudge is the safety net. **SPEC-009 (web layer & PWA)** written — the thin *make-it-usable* slice (shell · nav · vanilla WebAuthn JS · manifest · a service worker with **no authenticated caching**, A5); folds in the SPEC-008 collision guard. **Next: build SPEC-009.** |
| 2026-10-14 | **SPEC-009 ✅ IMPLEMENTED → M0.1 (human-usable).** `internal/core/web/` — the **shell** (`layout.html` · `NavLister`/`Render` · nav from `Registry.Nav()`, D-038) and the embedded assets (`app.css` · **`webauthn.js`** — the three ceremonies, vanilla, **no inline scripts** · `sw.js` + the `CacheablePrefixes` policy, **A5** · `manifest.webmanifest` with **`start_url: "/"`**, D-011 · icons) · the reserved **`static`** workflow (`core/server/static.go`) · every screen restyled inside the shell (`/setup` · enroll · login · nudge · dashboard · `/access` · Modules page · `/home`) · **Step 0 the module name-collision guard** (`module.CoreDispatchNames` · `ErrCoreDispatchName` — a colliding module now **fails to load**, error + log, SPEC-008 §9 D-9). AC-3…AC-7 asserted **without a browser**; live-smoked end to end from a copy of `config.example.yaml` (bootstrap → TOTP login → **nudge** → dashboard → restart). `-race` clean · static CGO-free binary · `routing` purity intact. **D-1 (flag):** the **TOTP key is now generated at startup** — D-041 enrolls TOTP *before* `finish` persists the R-6 secrets, so a fresh copy of the example could never complete `/setup` ("no TOTP sealer configured"); the ruling R-6 is unchanged, only the timing. **D-2:** `home` is excluded from the collision guard (the dashboard *is* the discovered reference module). **O-15 opened** (§9): should a module named for a reserved level-1 workflow be refused too? |
| 2026-10-14 | **SPEC-009 rulings (§13)** — **D-1 accepted** (the TOTP key is generated at **startup**; it fixed a real blocker — a fresh `config.example.yaml` could never complete `/setup`); **D-2 accepted** (the collision guard = the core dispatch modules `auth·setup·recovery·core`; `home` is a level-1 *workflow*). **O-15 RESOLVED**: extend the guard to the reserved level-1 names, **except `home`**. Q-3 → a **README caveat** (reach loopback via `localhost`). Guard extension + README caveat folded into the next slice. |
| 2026-10-13 | **SPEC-008 ✅ IMPLEMENTED + Step 0 (R-7)** — the **module registry** & the core **Modules page**: `core/module` (the contract · Registry · page) · `internal/modules/` (**build-time discovery**, D-028: a `go generate` tool writes `imports_gen.go` from the directories) · the reference required **`home`** dashboard module · `main` wiring (load → migrations → mount → log). **R-7 landed**: `users.CanApprove(approver, requester, approverIsAncestor)` — approval authority is the **chain** (manager upward) or the admin (`recovery.isAncestor`). AC-1..6 green, `-race` clean, static binary, live-smoked; `core/routing` purity intact. **M0 complete.** Next: **M1 — org structure**. |

83
README.md Normal file
View file

@ -0,0 +1,83 @@
# MyDay
> A self-hosted employee **time, absence & reporting** app by **Sechpoint**.
> Delivered as a **PWA** for mobile and desktop. **Single-tenant** — one company per instance.
**Status:** 🚧 **planning / pre-alpha.** No application code yet.
The design of record is [`PROJECT_PLAN.md`](PROJECT_PLAN.md).
---
## What it will do
| Area | |
|---|---|
| **Org structure** | users, the manager hierarchy, departments, positions |
| **Time** | presence and timesheets (a per-user mode) |
| **Absence** | leave requests, approvals, balances |
| **Reporting** | rollups and aggregations |
Built as **one Go binary**: a small core (`MyDay.auth` — identity, sessions, the door) plus
drop-in **modules**.
---
## Running it
> **Not yet available** — nothing is built. These are the intended steps.
```bash
# 1. configure
cp config.example.yaml config.yaml # then edit; config.yaml is gitignored
# 2. build
go build -o myday ./cmd/myday
# 3. run
./myday
```
---
## ⚠️ First run — do NOT expose it publicly
The **first user becomes the top admin** (the root of the chain). So the instance bootstraps
**loopback-only**, running a **`/setup` wizard** until that admin exists.
```bash
# on the server it listens on 127.0.0.1:8080 only (config.example.yaml)
ssh -L 8080:127.0.0.1:8080 user@server # from your workstation
# then open http://127.0.0.1:8080/setup and complete the wizard
```
> **Reach the app over `localhost` / `127.0.0.1` — not a hostname.** With an `https://` `base_url`
> the CSRF and session cookies are marked **`Secure`**, so they are **not** sent over plain
> `http://`; and only a **loopback** origin counts as a trustworthy/secure context for the
> browser's **WebAuthn** dialogs. So for first run and any human testing, open the app at
> **`http://localhost:8080`** or **`http://127.0.0.1:8080`** (via the tunnel above) — never at a
> bare server hostname.
Only **after** the top admin exists should you put MyDay behind a reverse proxy and expose it.
---
## Documentation
| | |
|---|---|
| [`PROJECT_PLAN.md`](PROJECT_PLAN.md) | the plan & decision log of record |
| [`AGENT.md`](AGENT.md) | instructions for AI agents working here |
| [`docs/specs/`](docs/specs/) | implementation specs |
| [`config.example.yaml`](config.example.yaml) | configuration template |
---
## Security
- Secrets live in **`config.yaml`** (gitignored) — **never commit them**.
- Auth is **Passkey (primary) + TOTP (fallback)**.
- See `PROJECT_PLAN.md` §10 for the deferred-security register and its **beta gate**.
---
## License
TBD.

55
config.example.yaml Normal file
View file

@ -0,0 +1,55 @@
# MyDay — example configuration.
#
# Copy to `config.yaml` (gitignored — NEVER committed, D-010) and fill in.
# Settings live HERE, not in the database (D-035).
# ── setup & recovery ─────────────────────────────────────────────────────────
setup: true # D-015 / D-026 — while true the app serves ONLY /setup (loopback-only)
admin_recovery: false # D-033 — set true + restart to email user 1 an enrolment link
# ── server ───────────────────────────────────────────────────────────────────
server:
listen: "127.0.0.1:8080" # D-025 — loopback during setup; bind the public iface only after
base_url: "https://myday.example.com" # used in invite / recovery links
trusted_proxies: # XFF is resolved from these (A6)
- "127.0.0.1"
# ── app ──────────────────────────────────────────────────────────────────────
app:
locale: "en-GB"
timezone: "Europe/London"
# ── enrollment (A1) ──────────────────────────────────────────────────────────
# The bounded invite window: outside it, /{GUID} enrollment is refused.
enrollment:
window: "72h"
# ── database (D-012) ─────────────────────────────────────────────────────────
database:
path: "./data/myday.db" # SQLite · WAL · foreign keys ON · one instance only
# ── secrets — generate strong random values; never commit (D-010) ────────────
secrets:
session_key: "CHANGE_ME_32_BYTES_RANDOM"
cookie_secret: "CHANGE_ME_32_BYTES_RANDOM"
csrf_key: "CHANGE_ME_32_BYTES_RANDOM"
totp_encryption_key: "CHANGE_ME_32_BYTES_RANDOM" # A9 — TOTP secrets are encrypted at rest
# ── mail — invites & recovery ────────────────────────────────────────────────
mail:
host: "smtp.example.com"
port: 587
username: ""
password: "CHANGE_ME"
from: "myday@example.com"
# ── modules (D-038) — required modules cannot be disabled ────────────────────
modules:
org: true
time: true
absence: true
requests: true # required
hr: true
expense: false
reports: true
integrations: false

40
docs/specs/README.md Normal file
View file

@ -0,0 +1,40 @@
# Specs — MyDay
Implementation specs, in build order. **A spec precedes its code.**
The design of record is [`../../PROJECT_PLAN.md`](../../PROJECT_PLAN.md); decisions are `D-nnn`.
**Convention:** `spec-NNN-<slug>.md` · **Status:** `Draft` → `Ready for implementation` → `Implemented`
· one spec per coherent slice; each ends with **acceptance criteria**.
---
## Index
| # | Spec | Milestone | Status | Depends on |
|---|---|---|---|---|
| **001** | [Git connection bootstrap](spec-001-git-bootstrap.md) | Prep | Implemented | — |
| **002** | [Routing model (`internal/core/routing`)](spec-002-routing-model.md) | M0 | **Implemented** | — |
| **003** | [The core door (`server` + `httpx`)](spec-003-core-door.md) | M0 | **Implemented** | 002, 004 |
| **004** | [Identity & sessions (`core/auth`, `core/users`)](spec-004-identity-sessions.md) | M0 | **Implemented** | 002 |
| **005** | [`/setup` wizard + bootstrap](spec-005-setup-wizard.md) | M0 | **Implemented** | 003, 004 |
| **006** | [Enroll & login (Passkey + TOTP)](spec-006-enroll-login.md) | M0 | **Implemented** | 004 |
| **007** | [Roles, the chain & recovery](spec-007-roles-chain-recovery.md) | M0 | **Implemented** | 004, 006 |
| **008** | [Module registry & the Modules page](spec-008-module-registry.md) | M0 | **Implemented** | 003 |
| **009** | [Web layer & PWA (screens · WebAuthn JS · service worker)](spec-009-web-pwa.md) | M0.1 | **Implemented** | 005, 006, 007 |
---
## The M0 slice order
```
002 routing ─► 004 identity/sessions ─► 003 door ─┬─► 005 setup wizard
├─► 006 enroll/login
├─► 007 roles/chain/recovery
└─► 008 registry/modules page
│
└─► 009 web layer & PWA
```
> **002 is the Stage-0 gate** (ROUTING.md §11): the pure model is approved and unit-tested
> **before** any server code. **002 ✅ · 004 ✅ · 003 ✅ · 006 ✅ · 005 ✅ · 007 ✅ · 008 ✅ · 009 ✅
> implemented.** **M0 + M0.1 are complete**; next is **M1 — org structure**.

View file

@ -0,0 +1,210 @@
# SPEC-002 — `internal/core/routing` (the pure model)
| Field | Value |
|---|---|
| **Status** | **Implemented** (2026-10-13 — `go build` · `go vet` · `go test` green; purity confirmed via `go list -deps`) |
| **Created** | 2026-10-13 |
| **Author** | OpenArchitect |
| **Target milestone** | **M0** (first slice — the *Stage-0* gate) |
| **Governing decisions** | **D-001** · **D-004** · **D-017** · **D-018** · **D-032** · **D-039** · `PROJECT_PLAN.md` §4 |
| **Related** | SPEC-001 (git) · SPEC-003 (the door) |
---
## 1. Goal
Define the **pure routing model** — **no HTTP, no DB** — that resolves a request into
`(module, function, params)` for **both doors** (ROUTER + HANDLER). It must be exhaustive and
unit-testable **in isolation**: identity is an **injected interface**, never a concrete lookup.
> This is the **Stage-0 gate** (ROUTING.md §11). Get the model right here before any server code.
---
## 2. Non-goals
- The HTTP server, the door pipeline, sessions, cookies → **SPEC-003**.
- Identity storage, users, roles, the chain → **SPEC-004**.
- Module implementations and the registry contents → **SPEC-008**.
- The `/setup`, enroll and login **flows** (the model only *classifies* them).
---
## 3. The model
### 3.1 Inputs
| Input | Meaning |
|---|---|
| `path` | the request path |
| `do`, `action` | the routing fields (may be empty) |
| `Resolver` | **injected**: GUID → `(found, enrolled)` — the model never touches a DB |
### 3.2 Types (indicative contracts)
```go
type Params []string // exploded path segments — empty segments PRESERVED
type Kind int
const (
KindRoute Kind = iota // -> (module, function, params)
KindAction // -> (module, function, params) [via do/action]
KindRedirect // -> Redirect (the ONE canonical redirect)
KindNotFound // -> 404
KindEnroll // -> the enrollment screen (GUID found, not enrolled)
KindLogin // -> the login screen (enrolled, no session — set by the door)
KindSetup // -> the setup wizard (setup mode only)
)
type Resolution struct {
Kind Kind
Workflow string // PARAM1 classification: "home" | "access" | "setup" | "api"… | a GUID | ""
Module string
Function string
Params Params // the remainder the function receives
Redirect string
}
```
### 3.3 The level-1 dispatch (D-039)
```
Dispatch(path, do, action) → Resolution
if do != "" || action != "" → Handler(do, action, path)
else → Router(path)
```
**Keyed on the routing fields, never the HTTP method.**
### 3.4 `Parse`
- Strip the leading `/`, split on `/`, **preserve empty segments**.
| Input | Output |
|---|---|
| `"/"` | `{}` |
| `"/home"` | `{"home"}` |
| `"/home/"` | `{"home", ""}` |
| `"/{guid}/request/pto"` | `{guid, "request", "pto"}` |
| `"/a//b"` | `{"a", "", "b"}` |
**Presence rules** (the three rules, applied per level):
- an **absent** param (path ended) → *the default* if optional, **404** if required;
- a **trailing empty** segment (a trailing slash) → treated as **absent** → the default;
- an **interior empty** segment (`//`) → a **mismatch** → **404**.
### 3.5 `Router(path)` — the RESERVED switch, then the GUID classifier
```
Router(p):
if len(p) == 0 → Redirect "/home" (the ONE redirect)
switch p[0]:
"setup" → KindSetup
"home" → the neutral landing workflow (no DB)
"access" → the recovery workflow
"api" | "static" | "media" | "ws" | "dev" → the reserved infra workflows
default: → Resolver.GUID(p[0]):
not found → NotFound
found · NOT enrolled → KindEnroll
found · enrolled → Module(p[1], p[2], p[3:])
```
- Reserved cases are checked **before** the resolver — `/home` never touches identity.
- `Module(…, "request", "pto")` → `(module="requests", function="pto", params={})`.
- Empty `p[1]` inside a resolved GUID → the **module default** (the dashboard).
### 3.6 `Handler(do, action, path)` — the action door
```
Handler(do, action, p):
m = Registry.Module(do) → not found → NotFound
f = m.Action(action) → not found → NotFound
→ (module=m, function=f, params=p[2:]) // the GUID from the path (identity stays in the path)
```
### 3.7 Module + function resolution
- `PARAM2` = the **module** — from the registry (whitelist). Unknown or **disabled** → `NotFound`.
- `PARAM3` = the **function** — the module's own whitelist, plus its **classifier** for unknown
values.
| Module `requests` | Function |
|---|---|
| `empty` | the default list |
| `open` · `closed` | the filtered list |
| `pto` · `equipment` | new-request forms |
| *numeric* | `detail` with `params = {id}` (classifier) |
| anything else | **404** |
---
## 4. Reserved names — the single source
```go
// ONE constant. A GUID may never equal a reserved name; validate on create AND update.
var ReservedWorkflows = []string{"home", "access", "setup", "api", "static", "media", "ws", "dev"}
```
- **`PARAM1`** is reserved against this set.
- **`PARAM2`** (module names) is reserved against the **registry** (same principle, different source).
- A module's **function** names are reserved within that module.
---
## 5. Test matrix (acceptance — table-driven, no HTTP, no DB)
| # | Input | Expect |
|---|---|---|
| 1 | `/` | `KindRedirect` → `/home` |
| 2 | `/home` | workflow `home`; **resolver not called** |
| 3 | `/home/` | workflow `home` (trailing empty = absent → default) |
| 4 | `/access` | workflow `access` |
| 5 | `/setup` | `KindSetup` |
| 6 | `/holy-shit` | `KindNotFound` (unknown · not reserved · not a GUID) |
| 7 | `/{guid}` found+enrolled | `KindRoute`, module = default (dashboard) |
| 8 | `/{guid}` not found | `KindNotFound` |
| 9 | `/{guid}` found+**not** enrolled | `KindEnroll` |
| 10 | `/{guid}/requests/pto` | module `requests`, function `pto` |
| 11 | `/{guid}/requests/open` | module `requests`, function `open` |
| 12 | `/{guid}/requests/42` | module `requests`, function `detail`, params `{42}` |
| 13 | `/{guid}/requests/bogus` | `KindNotFound` |
| 14 | `/{guid}/badmodule/x` | `KindNotFound` |
| 15 | `/{guid}//pto` | `KindNotFound` (interior empty = mismatch) |
| 16 | `/{guid}/requests` | module `requests`, function = default (list) |
| 17 | `/api/v1/…` · `/static/x.css` | workflow `api` · `static` |
| 18 | `do=hr&action=save-data` | module `hr`, function `save-data` |
| 19 | `do=nope&action=x` | `KindNotFound` |
| 20 | `do=hr&action=nope` | `KindNotFound` |
| 21 | `do=hr&action=save-data` **and** `/some/path` | **Handler wins** (routing fields) |
| 22 | a GUID equal to `home`/`setup`/… | **impossible** — reserved validation rejects it on create & update |
**Purity assertion:** every case runs with an in-memory fake `Resolver` and a fake registry; **no
HTTP import, no DB import** in the package's test graph.
---
## 6. Notes / assumptions
- `web` is not yet a reserved workflow; only the set in §4 is asserted. Adding one later is a
single-line change to the constant **and** a test row.
- The `KindLogin` classification is **set by the door** (session check), not the pure model — the
model returns `KindRoute` and the door upgrades it to `KindLogin` when no session exists.
- `requests` is used as the example module in §3.7; the registry itself is **SPEC-008**.
---
## 7. Implementation notes (accepted rulings)
Built 2026-10-13. `go build` · `go vet` · `go test` all green; purity independently confirmed by
`go list -deps` (no `net/http`, no `database/sql` in the build **or** test graph).
| # | Choice made by the builder | Ruling |
|---|---|---|
| **D-1** | `Dispatch`/`Router`/`Handler` are **methods on a `Dispatcher`** struct holding the injected deps (constructor `New`), plus a package-level convenience. | **Accepted** — a global-free struct is the correct shape. |
| **D-2** | `Module`/`Registry` interface shapes; the **classifier is module-owned**; `IsNumeric` ships as the built-in numeric classifier. | **Accepted** — §3.7 makes the function set the module's own. |
| **D-3** | Reserved workflows resolve with `Workflow = PARAM1`, `Module/Function = ""`, `Params = remainder`. | **Accepted (resolves Q-1).** The door (SPEC-003) switches on `Workflow` for reserved cases. |
| **D-4** | `DefaultModule = "home"`, resolved **through the registry** (a disabled `home` → 404). | **Accepted** — matches §4.4 level-2. |
| **D-5** | `Handler` sets `Resolution.Workflow` from the path's `PARAM1`. | **Accepted** — keeps identity in the path (D-032). |
| **D-6** | Resolved `Params` drop a phantom trailing `""` (`Parse` still preserves empties). | **Accepted.** |
| — | `ReservedWorkflows` is an exported **mutable** slice (read-only by contract). | **Noted** — treated as read-only; guarded by `IsReservedWorkflow` / `ValidateWorkflowName`. |

View file

@ -0,0 +1,158 @@
# 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. |

View file

@ -0,0 +1,164 @@
# SPEC-004 — identity & sessions (`internal/core/auth`, `internal/core/users`)
| Field | Value |
|---|---|
| **Status** | **Implemented** (2026-10-13) — see §8; `build`/`vet`/`test`/`-race` green · static CGO-free binary |
| **Created** | 2026-10-13 |
| **Author** | OpenArchitect |
| **Target milestone** | **M0** |
| **Governing decisions** | **D-007** · **D-008** · **D-013** · **D-020** · **D-034** · `PROJECT_PLAN.md` §6 |
| **Related** | SPEC-003 (door) · SPEC-005 (`/setup`) · SPEC-006 (enroll/login) |
---
## 1. Goal
The **data model and core services** for users, identities, sessions, and the audit trail — the
foundation the `/setup`, enroll/login, roles, and recovery slices build on.
---
## 2. Non-goals
- The HTTP door → SPEC-003. The flows → SPEC-005/006/007. Module data → modules own it.
---
## 3. The three anchors (D-013)
| Anchor | Where | Rotates? |
|---|---|---|
| **`user_id`** | internal, stable; **the first user = 1** | **no** |
| **`guid`** | the access username in the path | **yes** (recovery) |
| **`email`** | contact + recovery authority | no |
*Stable user ≠ rotating identity.* Credentials hang off an **identity**, never the user.
---
## 4. Schema (SQLite · WAL · foreign keys ON)
| Table | Key columns |
|---|---|
| `users` | `user_id` PK · `email` UNIQUE NOT NULL · `display_name` · `role` (`admin`\|`director`\|`manager`\|`team`) · `manager_user_id` FK NULL (NULL only for the admin) · `passkey_opt_out` BOOL DEFAULT 0 · `status` · `created_at` |
| `identities` | `identity_id` PK · `user_id` FK · `guid` UNIQUE (≥128-bit) · `status` (`pending_enrollment`\|`active`\|`revoked`) · `created_at` · `enrolled_at` · `revoked_at` |
| `webauthn_credentials` | `credential_id` PK · `identity_id` FK · `public_key` · `sign_count` · `transports` · `aaguid` · `created_at` |
| `totp_secrets` | `identity_id` FK · `secret_encrypted` (A9) · `verified_at` |
| `sessions` | `session_id` PK (opaque random) · `identity_id` FK · `user_id` · `created_at` · `expires_at` · `revoked_at` · `client_ip` · `user_agent` |
| `recovery_requests` | `request_id` PK · `user_id` FK · `status` (`pending`\|`approved`\|`denied`\|`expired`) · `requested_at` · `decided_by` FK · `decided_at` · `client_ip` |
| `audit_log` | `id` PK · `at` · `actor_user_id` · `action` · `subject` · `detail` · `client_ip` · `location` (D-031) · `correlation_id` |
**Invariants**
- **Exactly one `active` identity per user** (enforced by a partial unique index + service checks).
- `guid` is **unique and never recycled**; a revoked GUID is **tombstoned** (D-008).
- **The admin is `user_id = 1`** and its `manager_user_id` is `NULL` (D-020, D-034).
- The **enrollment window** is bounded: `now − identity.created_at ≤ enroll_window` (A1).
---
## 5. Core services (contracts)
**Audit metadata rides the `context.Context`** — the door attaches it before calling any service:
```go
ctx = audit.WithMeta(ctx, audit.Meta{ActorUserID, ClientIP, CorrelationID})
```
```go
type Users interface {
Create(ctx context.Context, creatorID int64, email, displayName string, role Role) (UserID int64, err error)
Get(ctx context.Context, id int64) (User, error)
SetRole(ctx context.Context, id int64, role Role) error // ripple-down model (D-034)
Manager(ctx context.Context, id int64) (Optional[UserID], error) // the chain
}
type Identities interface {
Mint(ctx context.Context, userID int64) (IdentityID int64, guid string, err error) // pending_enrollment
Activate(ctx context.Context, id int64) error // pending -> active
Rotate(ctx context.Context, userID int64) (guid string, err error) // recovery: revoke old, mint new
Revoke(ctx context.Context, id int64) error // revoke identity AND its sessions
ByGUID(ctx context.Context, guid string) (Identity, error) // tombstoned -> not-found semantics
}
type Sessions interface {
Mint(ctx context.Context, identityID int64, ip, ua string) (Session, error)
Validate(ctx context.Context, sessionID string) (Session, error) // rejects empty · expired · revoked [A2]
Rotate(ctx context.Context, sessionID string) (Session, error) // on login
Revoke(ctx context.Context, sessionID string) error
RevokeAll(ctx context.Context, identityID int64) error
GC(ctx context.Context, now time.Time) (int, error)
}
type Audit interface {
// event: bootstrap · enroll · login · logout · recovery request/approve/deny · revoke
Record(ctx context.Context, e Event)
}
```
Every mutating service call writes an audit row, taking **actor · client IP · correlation** from the
context (A11).
---
## 6. Session lifecycle
- **Mint** on successful enroll/login → an opaque random id; cookie set by the door (SPEC-003).
- **Validate** on every request: the row must exist, **not expired**, **not revoked**, and the
**session id must be non-empty** (A2).
- **Rotate** on login (fixation defence). **Revoke** on logout; **RevokeAll** when an identity is
rotated or revoked (D-007).
- **GC** expired rows on a schedule — but **never** the principal's last valid session.
---
## 7. Acceptance criteria
| # | Criterion |
|---|---|
| AC-1 | Two `active` identities for one user is **impossible** (index + service) |
| AC-2 | `guid` is unique; a revoked GUID is never reissued (tombstoned) |
| AC-3 | `Sessions.Validate("")` → error (empty id rejected, A2) |
| AC-4 | An expired/revoked session → error; `RevokeAll` kills every session of an identity |
| AC-5 | `Identities.Rotate` revokes the old identity **and** all its sessions, and mints a new GUID |
| AC-6 | Creating a user with `role = admin` when user 1 exists is **rejected** (one admin, D-034/§7.1) |
| AC-7 | Every service mutation writes an `audit_log` row (A11) |
| AC-8 | `totp_secrets.secret_encrypted` is never stored or logged in plaintext (A9) |
---
## 8. Implementation notes (accepted rulings)
Built 2026-10-13. `go build` · `go vet` · `go test` (incl. `-race`) green; `CGO_ENABLED=0` build is a
**static** binary; `modernc.org/sqlite` in the dep graph, **no** `mattn/go-sqlite3`. AC-1…AC-8 all pass.
**Architect rulings on the builder's open questions:**
| # | Question | Ruling |
|---|---|---|
| **R-1** | Services can't populate `client_ip`/`correlation_id` (no context in §5). | **Thread `context.Context` + `audit.Meta`** (§5 rewritten). The door attaches the meta. **Must land before SPEC-003.** |
| **R-2** | The `CHECK ((role='admin') = (manager_user_id IS NULL))` couples role and manager. | **Confirmed intended** — the one admin is the root (§7.1/§5.3). |
| **R-3** | `RevokeAll` records an **anonymous** actor. | **Resolved by R-1** — the actor comes from the context (approver/operator/system). |
| **R-4** | *(raised while building SPEC-003)* `auth.Mint/Rotate/Revoke` hard-code the **owning user** as actor. | **Use the context actor** (creator/approver); record the affected user as the **subject**. Adjust the identity services accordingly. |
**Accepted deviations (Class B, reversible):**
1. Two service types over one `auth.Core` (`IdentitiesService` / `SessionsService`) — one Go type
can't hold two same-named methods. Interfaces match §5.
2. `Identities.ByGUID` also returns `revoked` rows, so `Resolver.GUID()` maps a tombstoned GUID to
*not found* (→ 404) while staying auditable.
3. `Users.Create(creatorID=0)` = the **bootstrap** path — only while `users` is empty, only for
`role=admin`. SPEC-005 drives it.
4. Additive helpers not in §5: `users.Count`/`ByEmail`, `identities.WithinEnrollmentWindow`, the
TOTP seam (`PutTOTPSecret`/`TOTPSecret`/`MarkTOTPVerified`).
5. Code defaults (config comes later, D-035): session TTL **24h** · enrollment window **72h**.
6. Enums not enumerated in §4: `users.status ∈ {active, disabled}`; `credential_id` as `BLOB`;
`transports` as comma-text. TOTP sealed with **AES-256-GCM**.
7. **Migration `0002_passkey_opt_out.sql`** (D-041, 2026-10-14) adds
`users.passkey_opt_out INTEGER NOT NULL DEFAULT 0` — append-only, `0001_init` untouched.
`Users` gains `PasskeyOptOut` (read) and `SetPasskeyOptOut` (write, audited
`user.passkey_opt_out`); `User` carries the field. It is a **user** preference, so it
survives identity rotation (D-008).
> **Packages added:** `internal/core/store` · `internal/core/audit` · `internal/core/users` ·
> `internal/core/auth`. `internal/core/routing` tests still pass.

View file

@ -0,0 +1,133 @@
# SPEC-005 — `/setup` wizard & bootstrap
| Field | Value |
|---|---|
| **Status** | **Implemented** (2026-10-13) — see §7; `build`/`vet`/`test`/`-race` green · static binary · live-smoked · **rev 2026-10-14 (D-041): TOTP-only setup** |
| **Created** | 2026-10-13 |
| **Author** | OpenArchitect |
| **Target milestone** | **M0** |
| **Governing decisions** | **D-015** · **D-021** · **D-025** · **D-026** · **D-035** · `PROJECT_PLAN.md` §5 |
| **Related** | SPEC-003 (door) · SPEC-004 (identity) |
---
## 1. Goal
Bring a fresh instance to life with a **loopback-only `/setup` wizard** that creates the **first
user = the top admin** (`user_id = 1`, role `admin`), then switches the instance to productive mode.
## 2. Non-goals
Module setup screens (each module owns its config). Multi-admin (there is **one**, D-034/§7.1).
## 3. Setup mode
`setup mode = (config.setup == true) OR (no user exists)` — **fail-safe**.
- In setup mode the app serves **only** `/setup` (+ static assets); **every other route → 404**.
- **Loopback-only, enforced (D-025):** if `server.listen` is **not** loopback while in setup mode,
the process **refuses to start**.
## 4. The wizard
```
GET /setup → the wizard
Step 1 domain / base_url
Step 2 the top admin — display_name · email · **TOTP enrollment** (Passkey deferred — D-041)
Step 3 mail (SMTP) · locale · timezone
POST /setup?do=setup&action=… → each step (CSRF-protected)
Finish → persist settings → config.setup = false
→ user_id = 1 (role admin) · identity minted + activated · session minted
```
- **Settings persist to `config.yaml`** (D-035). *Caveat:* if the file is not writable, the wizard
**prints the values** and instructs the operator to write them, then marks setup complete.
- **One admin only:** the wizard creates `user_id = 1`; later attempts to create another `admin`
are rejected (SPEC-004 AC-6).
- **TOTP only (D-041):** the wizard does **not** enroll a Passkey — over plain-HTTP loopback a passkey
would bind to `127.0.0.1` and be **unusable at the public domain**. The admin adds one at first login
on the real origin, via the **passkey nudge** (SPEC-006 §8).
## 5. After setup
- `/setup` → **404** (delete, don't redirect). Re-entering requires `config.setup: true` + restart.
- The instance may now bind a public interface (behind a reverse proxy).
- **Audit:** the bootstrap, the admin creation, and the enroll are all recorded (A11).
## 6. Acceptance criteria
| # | Criterion |
|---|---|
| AC-1 | Fresh instance → `/setup`; any other route → 404 |
| AC-2 | Non-loopback `listen` in setup mode → process exits with an error (D-025) |
| AC-3 | Completing the wizard creates `user_id = 1` (role `admin`) with **TOTP** enrolled and **no passkey** (D-041) |
| AC-4 | After completion `config.setup = false` (or printed) and `/setup` → 404 |
| AC-5 | A second `admin` cannot be created |
| AC-6 | The full bootstrap is audited |
---
## 7. Implementation notes (accepted rulings)
Built 2026-10-13. `build` · `vet` · `test` (incl. `-race`) green; static CGO-free binary;
live-smoked (`/setup`→200 + CSRF cookie · `/home`→404 · `/`→404 · action without CSRF→403 ·
step 1→2→2b advances · real WebAuthn begin and real sealed TOTP issue). AC-1…AC-6 all pass.
**What landed**
- `internal/core/setup` — the wizard (`Actions`): page + steps (`domain.save` · `admin.create` ·
`admin.enroll.*` · `admin.totp.*` · `finish`), the step state machine, and the persister.
- `internal/core/server` — `RegisterSetupFlow` (the `setup` core page + the `setup` action module);
the **setup-mode gate** in `dispatch` (§3: only `/setup` + static served) and `serveSetupAction`
(pre-session CSRF, **A15**).
- `cmd/myday` — wiring + `configTarget` (persists to `config.yaml`, **never** the committed example).
- `internal/core/config` — `Marshal` (the persisted document re-loads through the strict loader).
- `internal/core/auth` — `Identities.ByUserID` (additive; the wizard's "which step?" lookup).
- `internal/core/auth/flow` — `Deps.SuccessPath` (additive seam; the wizard injects `/setup` so a
successful ceremony stays on the wizard).
**Step 2 reuses SPEC-006** — the wizard calls `flow.Actions.EnrollBegin/Finish/TOTPBegin/Verify`
with the admin's GUID; the ceremony is **not** duplicated.
**The setup-mode gate (SPEC-005 §3, AC-1)** — while in setup mode **every** route except `/setup`
(+ `static`) is 404, including `/home`, `/access`, **the canonical `/` → `/home` redirect**, and
every GUID branch. *(Delete, don't redirect — a fresh instance has no app yet.)*
| # | Builder choice | Ruling |
|---|---|---|
| D-1 | **One admin only** enforced by `users.Create`; the wizard returns **409** on a second attempt. | **Accepted** (AC-5). |
| D-2 | The wizard's step is **derived** from the DB (admin exists? identity pending/active?) plus an in-memory *draft* for step 1's domain. | **Accepted** as the smallest state model. |
| D-3 | A not-writable `config.yaml` → the YAML is **printed** to stdout with an instruction; setup still completes in memory. | **Accepted** (AC-4; §4 caveat). |
| D-4 | **Setup mode is LATCHED at startup** (`config.setup` OR "not yet bootstrapped" — no user, or no ACTIVE admin) and cleared by an atomic `Actions.Done()`; `config.Setup` is never mutated (the live `Config` is an immutable startup snapshot, D-035). | **Accepted** — avoids a data race on the shared config and a per-request DB query, and **holds setup mode through the whole wizard**: once the admin row exists a live "no user" check would end setup mode before enrollment/finish. |
**Architect rulings on the flagged items:**
| # | Item | Ruling |
|---|---|---|
| **R-6** | Secrets are preserved verbatim, never generated — a `config.example.yaml` copy persists `CHANGE_ME` placeholders, so the **next boot fails closed**. | **Generate.** The wizard **generates strong secrets** (session · cookie · csrf · totp) when a secret is empty/placeholder, so a first boot from the example is viable. *Folded into the next slice.* |
| **O-13** | The WebAuthn RP is built at startup from `server.base_url`; step 1's domain doesn't rebuild it. | **Surface to the owner** — see **O-13** in `PROJECT_PLAN.md` §9. A passkey enrolled over plain-HTTP loopback binds RP ID `127.0.0.1` and is **unusable at the public domain**. Does **not** block SPEC-007/008. |
| **F-2** | The ruling "serve the otpauth URL `Cache-Control: no-store`" (SPEC-006 §7) is **not implemented**. | **Fix** — add `no-store` to the enroll/TOTP (and setup) responses. *Folded into the next slice.* |
| D-5 | Step-1's domain draft is **in-memory only** (a restart re-asks step 1). | **Accepted** — minor; DB state for steps 2–3 survives. |
---
## 8. Implementation notes — TOTP-only setup (D-041, 2026-10-14)
Built 2026-10-14. `build` · `vet` · `test` (incl. `-race`) green; static CGO-free binary.
**What changed**
- **Step 2 enrolls TOTP only** — the wizard renders the authenticator form and the
verify form; the passkey section and the `admin.enroll.begin` / `admin.enroll.finish`
actions are **gone**. `setup.Enroller` now declares only `TOTPBegin` / `TOTPVerify`.
- **`flow.Deps.TOTPOnlyEnroll`** (new, default false) lets a PENDING identity activate on a
verified TOTP code **alone** — the wizard's dedicated flow sets it. Normal GUID enrollment
(`/{GUID}`, SPEC-006 §4) still requires **both** factors (AC-2).
- `cmd/myday` builds **two** flows over the same deps: the door's (two-factor, nudge-suppressed
in setup mode) and the wizard's (`TOTPOnlyEnroll`, `SuccessPath → /setup`).
- **AC-3** now asserts TOTP enrolled · **no passkey**; the audit set is
`user.create · identity.mint · enroll.totp.begin · enroll.complete · setup.admin · setup.complete`
(no `enroll.passkey`).
The admin adds a passkey at first login on the real origin, via the **passkey nudge** (SPEC-006 §8).

View file

@ -0,0 +1,152 @@
# SPEC-006 — enroll & login (Passkey + TOTP)
| Field | Value |
|---|---|
| **Status** | **Implemented** (2026-10-13) — see §7; `build`/`vet`/`test`/`-race` green · static binary · live-smoked · **rev 2026-10-14 (D-041): the passkey nudge (§8)** |
| **Created** | 2026-10-13 |
| **Author** | OpenArchitect |
| **Target milestone** | **M0** |
| **Governing decisions** | **D-005** · **D-006** · **D-007** · `PROJECT_PLAN.md` §4.5/§6 |
| **Related** | SPEC-004 (identity/sessions) · SPEC-007 (recovery) |
---
## 1. Goal
The **GUID-driven** enroll/login state machine (D-006): `GET /{GUID}` decides — by the identity's
status — whether the visitor **enrolls**, **logs in**, or lands on the app. **Two factors only:
Passkey (primary) + TOTP (fallback)** (D-005). No `/auth/enroll/{token}`.
## 2. Non-goals
Recovery (SPEC-007). Module content. **No email/OTP as a login factor.**
## 3. The state machine
```
GET /{GUID}
identity not found → 404
status pending_enrollment
within the window → ENROLL
window expired → 404 ("ask your manager to re-invite")
status active · no session → LOGIN
status active · valid session → the app (module default)
status revoked → 404
```
**The GUID is identification; the session is the credential** — a bookmark is never a login.
## 4. Enrollment (mandatory, both factors)
```
POST /{GUID}?do=auth&action=enroll.begin → WebAuthn register (begin)
POST /{GUID}?do=auth&action=enroll.finish → WebAuthn register (finish) → credential stored
POST /{GUID}?do=auth&action=totp.begin → issue the TOTP secret (encrypted, A9)
POST /{GUID}?do=auth&action=totp.verify → verify a code → identity ACTIVE → session minted
```
- **Both** factors are required — enrollment does not complete with only one.
- The window is bounded by `enroll_window` (A1); outside it, enrollment is refused.
## 5. Login
```
POST /{GUID}?do=auth&action=login.begin → WebAuthn assertion (begin)
POST /{GUID}?do=auth&action=login.finish → assertion (finish) → session minted
(fallback) action=totp.verify → a valid TOTP code → session minted
POST /{GUID}?do=auth&action=logout → session revoked
```
- **Passkey is primary**; TOTP is the fallback (device loss without a full recovery).
- The session is **rotated on login** (fixation defence).
- Failures return **generic** responses; the routes are **rate-limited** (A3).
- **Pre-session CSRF (A15):** the anonymous enroll/login forms carry a signed **double-submit**
cookie token, verified server-side — SPEC-003 §6's session-bound CSRF does not apply yet.
## 6. Acceptance criteria
| # | Criterion |
|---|---|
| AC-1 | `/{GUID}` with `pending_enrollment` (in window) → enrollment, **not** login |
| AC-2 | Enrollment completes **only** with Passkey **and** TOTP |
| AC-3 | `/{GUID}` with `active` + no session → login; with a session → the app |
| AC-4 | The enrollment window expiry blocks enrollment (A1) |
| AC-5 | Login rotates the session id; logout revokes it |
| AC-6 | TOTP secret is stored encrypted and never logged (A9) |
| AC-7 | The enroll/login routes return **429** under rate-limit (A3) and use **generic** error text |
---
## 7. Implementation notes (accepted rulings)
Built 2026-10-13. `build` · `vet` · `test` · `-race` green; static binary; live-smoked
(no-CSRF → 403 · TOTP-only → 400 (two-factor) · unknown GUID → 404). **R-4** applied (context actor
+ affected-user subject). WebAuthn behind `webauthn.Service` (fake in tests); TOTP real
(`pquerna/otp`); the ceremony challenge rides a **signed** cookie (no new table).
| # | Item | Ruling |
|---|---|---|
| D-1 | `auth.Identities` gained `WithinEnrollmentWindow` | **Accepted** — keeps the flow on interfaces. |
| D-2 | `server.Deps.PreSession` + config `enrollment.window` (default 72h) | **Accepted** — documented here + in `config.example.yaml`. |
| **F-1** | The **login screen** returns **401** (HTML). | **RESOLVED (SPEC-005)** — a rendered page is **200**; only a **failed credential attempt** is 401/400. Applied to `flow.LoginScreen` + `server.LoginPlaceholder`; SPEC-003/006 tests updated. |
| D-3 | Ceremony state cookie is **signed**, not encrypted | **Accepted** — the challenge must be unpredictable + tamper-evident, not confidential. |
| D-4 | `flow` covered end-to-end via the door (no package-local unit test) | **Accepted** — stronger evidence than unit mocks. |
| D-5 | `flow.Deps.SuccessPath` — an optional seam overriding where a successful enroll/login lands (default `/{GUID}/home`). | **Accepted** — added for **SPEC-005**'s wizard (it injects `/setup`); nil keeps the SPEC-006 behaviour unchanged. |
| — | `totp.begin` returns the otpauth URL (embeds the secret) by design | **Accepted** — serve it **`Cache-Control: no-store`**, HTTPS only; never logged/audited (proven). |
**Follow-ups:** a **web layer / PWA** slice (**SPEC-009**) — the screens need same-origin WebAuthn
JS (CSP is `script-src 'self'`). Per-action rate-limit buckets deferred.
---
## 8. The passkey nudge (revision 2026-10-14 · D-041)
After a successful **TOTP** login, redirect to a passkey-enrollment screen when **all** hold:
the identity has **no passkey** · `users.passkey_opt_out` is false · the origin is **not loopback** ·
not in setup mode.
```
POST /{GUID}?do=auth&action=passkey.add → WebAuthn register → credential stored (audited)
POST /{GUID}?do=auth&action=passkey.later → skip this time (nothing persisted)
POST /{GUID}?do=auth&action=passkey.never → users.passkey_opt_out = true (audited)
```
- **Suppressed on loopback / in setup mode** — a passkey enrolled at `127.0.0.1` is unusable at the
public domain (O-13 · D-041).
- The **marker is a user preference** (`users.passkey_opt_out`, SPEC-004 §4) — it **survives identity
rotation**, so an opted-out user is not re-nudged after a recovery.
- A TOTP login **with** a passkey present is not nudged; a passkey login never is.
- A session exists here, so SPEC-003 §6's session-bound CSRF applies (A15 does not).
| # | Criterion |
|---|---|
| AC-8 | A TOTP login with no passkey, on a **non-loopback** origin → the nudge screen |
| AC-9 | *Don't ask again* sets `passkey_opt_out`; later logins never nudge |
| AC-10 | The nudge does **not** appear on loopback or in setup mode |
| AC-11 | `passkey.add` completes a real WebAuthn registration and is **audited** |
### 8.1 Implementation notes (built 2026-10-14, D-041)
`build` · `vet` · `test` (incl. `-race`) green; static CGO-free binary. AC-8…AC-11 green.
- **Where it fires** — `flow.TOTPVerify` (the ACTIVE-identity branch only): a successful TOTP
login redirects to `/{GUID}/auth/passkey` when `shouldNudge` holds; otherwise it lands on the
app. `login.finish` (passkey login) never nudges.
- **`shouldNudge`** — `Users.PasskeyOptOut` is **false** · the identity has **no** passkey
(`Credentials.HasWebAuthnCredential`) · the request origin (`r.Host`) is **not** loopback
(`httpx.IsLoopbackHost`) · the nudge is not suppressed (`Deps.SuppressNudge`, wired to setup
mode). Any lookup error fails safe — **no** nudge.
- **The screen** — a session-gated **route** on the `auth` module:
`GET /{GUID}/auth/passkey` (function `passkey.prompt`). The door already holds a session, so the
forms carry the **session-bound** CSRF token (`httpx.TokenFromRequest`), not the pre-session one.
- **WebAuthn is two rounds, D-041 names one action** — the screen's GET performs the
**begin** (`BeginRegistration`, signed ceremony cookie) and embeds the publicKey options; the
single POST `passkey.add` performs the **finish** (`FinishRegistration` → `PutWebAuthnCredential`)
and is audited as `passkey.add`. This keeps the three POST actions each doing one thing.
- **Session-gating the actions** — the `auth` module's anonymous actions (enroll/login) run
before the session gate (`serveAuthAction`). `passkey.add` / `passkey.later` / `passkey.never`
require a session, so the door forwards the `passkey.` prefix to `serveGUID` (like `logout`).
- **`passkey.later`** persists **nothing** and is **not** audited (it is a skip, not an event);
**`passkey.never`** writes `users.passkey_opt_out = true` through the users service, which
audits `user.passkey_opt_out` (A11). The marker is on the **user**, so it survives identity
rotation (D-008).

View file

@ -0,0 +1,90 @@
# SPEC-007 — roles, the chain & recovery
| Field | Value |
|---|---|
| **Status** | **Implemented** (2026-10-13) — see §7; `build`/`vet`/`test`/`-race` green · static binary · live-smoked |
| **Created** | 2026-10-13 |
| **Author** | OpenArchitect |
| **Target milestone** | **M0** |
| **Governing decisions** | **D-007** · **D-009** · **D-019** · **D-020** · **D-033** · **D-034** · `PROJECT_PLAN.md` §6.4/§7 |
| **Related** | SPEC-004 (identity) · SPEC-006 (login) |
---
## 1. Goal
The **role ladder**, the **manager chain**, and the two **recovery** paths.
Onboarding is **top-down** (D-019): a manager always exists before their reports.
## 2. Roles (D-034) — simple tiers, no ACL, one per user
```
admin > director > manager > team
```
- The assigned role **ripples down** — a director may do all a manager/team member can.
- **One role per user.** The **chain** (`manager_user_id`) is separate: it answers *who approves
you*, not *what you may do*.
## 3. The chain & creation authority (D-019)
- **Creation authority = a role strictly above the new user's**; the new user's manager defaults to
the creator. `admin` is the root (`manager_user_id = NULL`).
- **Top-down:** the admin creates the first directors; directors create managers; managers create team.
## 4. Recovery — non-admin (manager escalation)
```
GET /access → enter the email on record
POST /access?do=auth&action=recover → ALWAYS generic ("if a record exists, we've emailed you")
└─ match → recovery_request(pending) · notify the approver chain + the employee
Approver (**an ancestor in the chain** — the manager upward — or the **admin**) → approve / deny (approver ≠ requester · both audited)
└─ on approval: revoke the OLD identity → mint a NEW GUID → email the enrolment link
Employee re-enrols Passkey + TOTP under the NEW GUID → /{old GUID} permanently dead
```
- Pending requests **expire** (~72h); a capped number per user.
- **Email only initiates**; a **human in the chain authorises** (D-009). Identity rotation (D-007)
means a compromised inbox cannot take over alone.
## 5. Recovery — the admin (config-gated)
```
operator sets admin_recovery: true in config.yaml → restart
→ rotate user 1's identity (pending_enrollment)
→ EMAIL the enrolment link to user 1's ADDRESS ON RECORD
→ audited · the flag is cleared
```
Without the flag, **nothing happens** (dual control, D-033).
## 6. Acceptance criteria
| # | Criterion |
|---|---|
| AC-1 | Creating a user requires a role **above** theirs; the creator is the default manager |
| AC-2 | A user cannot approve their **own** recovery request |
| AC-3 | Approval revokes the old identity + all its sessions and mints a new GUID |
| AC-4 | `/access` never reveals whether the email matched (generic response) |
| AC-5 | `admin_recovery: false` ⇒ no rotation, no email |
| AC-6 | Every request, approval, denial, rotation and enable is audited (A11) |
---
## 7. Implementation notes (accepted rulings)
Built 2026-10-13. `build` · `vet` · `test` · `-race` green; static binary; live-smoked —
`/access` returns a **byte-identical generic page** for known and unknown email; admin flag **on** →
rotate + email + flag cleared; flag **off** → nothing. **Step 0** landed (R-6 secrets generated ·
F-2 `no-store`).
| # | Builder choice | Ruling |
|---|---|---|
| D-1 | Approve/deny at `POST /{GUID}?do=recovery&action=approve\|deny&request={id}` (session-gated) | **Accepted** — the request action lives under the approver's own GUID. |
| **R-7** | Approver rule = *"a role strictly above the requester, never self"*. | **Change** — the authority is the **chain**: the requester's **manager upward** (an ancestor) **or the admin**. §4 amended. **Landed with SPEC-008 (Step 0):** `users.CanApprove(approver, requester, approverIsAncestor)` + `recovery.isAncestor`; `TestCanApproveChainRule` and `TestSpec007AC2_R7_ChainAuthority`. |
| D-2 | Only the immediate manager + the employee are notified | **Accepted** for M0; wider chain fan-out deferred. |
| D-3 | Claim-then-rotate; on a rotation failure the request stays *approved + audited* (never double-rotates) | **Accepted** — safe. |
| D-4 | A live pending request is **reused**, so the cap (3) is a hard bound only under concurrency | **Accepted.** |
| D-5 | Admin-recovery flag clearing rewrites `config.yaml`; unwritable → logged, continue | **Accepted** (D-035). |
**Follow-up (deferred):** notify the **wider chain**, not only the direct manager.

View file

@ -0,0 +1,107 @@
# SPEC-008 — module registry & the Modules page
| Field | Value |
|---|---|
| **Status** | **Implemented** (2026-10-13) — see §9; `build`/`vet`/`test`/`-race` green · static binary · live-smoked |
| **Created** | 2026-10-13 |
| **Author** | OpenArchitect |
| **Target milestone** | **M0** |
| **Governing decisions** | **D-027** · **D-028** · **D-036** · **D-037** · **D-038** · `PROJECT_PLAN.md` §4.6–4.8 |
| **Related** | SPEC-002 (routing) · SPEC-003 (door) |
---
## 1. Goal
The **module registry** — build-time discovery, the module contract, and the core **Modules** page
that **enables/disables** optional modules. Modules are **compiled in**, activated at runtime.
## 2. Non-goals
Module internals (each module owns its routes, handlers, tables). Third-party/WASM plugins (future).
## 3. Discovery (D-028) — the Caddy pattern
- A module is a package under `internal/modules/<name>/` that **self-registers** via `init()`.
- A **generated aggregator** (`internal/modules/imports_gen.go`, from `go generate`) blank-imports
every module dir — **the directory is the source of truth**.
- Adding a module = add the dir → `go generate` → rebuild. **No core edits.**
- **Deterministic order** (defined sequence, never map iteration) — migrations and precedence depend
on it. **Log the loaded set at startup.**
## 4. The module contract (`app.go`)
```go
type Module interface {
Name() string // the PARAM2 segment
Required() bool // false = optional (can be disabled)
Migrations() []Migration // the module's own schema, versioned
Routes() []Route // path-mode functions (§4.2)
Actions() []Action // handler-mode functions (§4.3)
Register(r *Registry) error
Start(ctx context.Context) error // optional — background work (integrations)
}
```
## 5. The module skeleton (D-037)
```
internal/modules/expense/
app.go the manifest (above)
routing.go routes + actions
handlers.go the action handlers
queries.go its own tables (sqlc) — as needed
migrations/ its own schema (embedded) — as needed
web/ its templates/partials — as needed
```
## 6. Enable / disable (D-038)
- State lives in **`config.yaml`** → `modules:` map (D-035).
- **Disabled** → the module is **not in the registry**: its routes return **404**, its nav is hidden.
**Its data is never deleted.** Re-enabling restores it.
- **Required** modules (the core; `requests` per the "must-have" call) **cannot be disabled**.
- **Toggles are audited** (A11). Applied at **startup**; a live remount is out of scope.
## 7. The Modules page (core)
- Lists every module: name · kind · required · enabled.
- Toggling writes `config.yaml` (the D-035 caveat: needs write access, else "edit and restart").
- A required module shows as **locked**.
## 8. Acceptance criteria
| # | Criterion |
|---|---|
| AC-1 | A new module dir + `go generate` + rebuild → it appears, with **no core edits** |
| AC-2 | Registration order is deterministic; the loaded set is logged at startup |
| AC-3 | Disabling a module → its routes 404 and its nav hides; **its tables are intact** |
| AC-4 | A **required** module cannot be disabled (API + UI) |
| AC-5 | Each module's migrations run in registration order; the core owns the runner |
| AC-6 | Every enable/disable is audited (A11) |
---
## 9. Implementation notes (accepted rulings)
Built 2026-10-13. `go build` · `go vet` · `go test` (incl. `-race`) green — old and new
packages; static CGO-free binary; live-smoked (startup logs the loaded set; setup mode still serves
only `/setup`). `internal/core/routing` purity intact.
**Files:** `internal/core/module/` (the contract + registry + Modules page) ·
`internal/modules/{generate.go,imports_gen.go,gen/}` (the `go generate` discovery tool) ·
`internal/modules/home/` (the reference required module) · `cmd/myday/main.go` (wiring).
| # | Choice made by the builder | Ruling |
|---|---|---|
| **D-1** | The contract lives in `internal/core/module`; a module's manifest is `app.go`. | **Accepted** — matches D-036/D-037. |
| **D-2** | `Routes()`/`Actions()` are **declarative**; `Register(r)` is the self-registration hook. | **Accepted.** |
| **D-3** | `module` imports `server` (for `Handler`/`RequestContext`); no cycle, but the registry depends on the door. | **Accepted for M0**; a neutral `handoff` package to invert the layering is a **follow-up**. |
| **D-4** | `Kind()` is optional (`KindOf`); default `module`. | **Accepted.** |
| **D-5** | Migrations run for **enabled** modules only, versions prefixed `<module>/<version>`; disabling never drops tables. | **Accepted.** |
| **D-6** | The Modules page is **admin-only** (`GET /{GUID}/core/modules` · `POST /{GUID}?do=core&action=modules.toggle`). | **Accepted** — only the admin toggles modules. |
| **D-7** | Toggles write `config.yaml` and apply **at next start** (D-038). | **Accepted.** |
| **D-8** | `Registry.Nav()` is the nav seam; rendering is SPEC-009. | **Accepted.** |
| **D-9** | Core dispatch modules take precedence over a same-named discovered module; **no guard yet**. | **Fix** — add a **name-collision guard** (a colliding discovered module must **fail to load**, error + log). **Follow-up.** |
**Step 0 (R-7) landed with this slice** — recovery approval authority is the **chain**: `users.CanApprove(approver, requester, approverIsAncestor)`, with the walk in `recovery.isAncestor`. `users_test.go` and `spec007_recovery_test.go` cover it.

View file

@ -0,0 +1,192 @@
# SPEC-009 — the web layer & PWA (first slice)
| Field | Value |
|---|---|
| **Status** | **Implemented** (2026-10-14) — see §10; `build`/`vet`/`test`/`-race` green · static binary · live-smoked |
| **Created** | 2026-10-14 |
| **Author** | OpenArchitect |
| **Target milestone** | **M0.1** (make M0 human-usable) |
| **Governing decisions** | **D-003** · **D-011** · **D-041** · **D-042** · register **A5** · `PROJECT_PLAN.md` §4/§12.1 |
| **Related** | SPEC-003 (door) · SPEC-005 (`/setup`) · SPEC-006 (enroll/login + nudge) · SPEC-008 (modules) |
---
## 1. Goal
Make the M0 backend **human-usable**: a real shell, nav, and the **WebAuthn ceremony** in the
browser, so the flow **`/setup` → TOTP login → the passkey nudge → the home dashboard** works
end-to-end for a person — and MyDay **installs as a PWA**.
Today the screens are bare server-rendered HTML with **no same-origin JS**, so no human can complete
setup or enroll a passkey in a browser. This slice closes that gap.
---
## 2. Non-goals
- **Offline / caching** — explicitly out (A5). The service worker must **never** cache authenticated
responses or `/{GUID}`.
- A JS framework or a build step. **Server-rendered HTML + a little vanilla JS.**
- The feature-module UIs (`org` · `time` · `absence` · …) — they ship with their modules.
- Theming beyond a clean, minimal stylesheet.
---
## 3. Structural changes
| Path | Purpose |
|---|---|
| `internal/core/web/` | the shared **shell/layout** + template helpers |
| `internal/core/web/static/` | embedded assets: `app.css` · `webauthn.js` · `sw.js` · icons · `manifest.webmanifest` |
| `internal/core/web/embed.go` | `//go:embed static` |
| `internal/core/server/static.go` | serve the reserved `static` workflow (D-004) |
The **module view contract** — a module renders its page **inside the core shell** (title · nav ·
content slot). `home` is the reference.
---
## 4. The shell & nav
- One base layout (`layout.html`) with: a header, the **nav** (from `Registry.Nav()`, D-038),
a content slot, a flash/notice area, and the CSRF token for forms.
- **Nav is driven by the module registry** — a disabled module's entries are absent (D-038).
- Every page renders **inside** the shell; a module supplies only its content.
## 5. The WebAuthn client (vanilla JS)
`webauthn.js` — no framework, no build step; **`script-src 'self'`** holds (no inline scripts).
Data (challenge · options) reaches the script via **`data-*` attributes** or a JSON endpoint.
Three ceremonies:
| Flow | Begin | Finish |
|---|---|---|
| **Enroll** (invited users, both factors) | `enroll.begin` | `enroll.finish` |
| **Login** | `login.begin` | `login.finish` |
| **Passkey nudge** (`passkey.add`) | the nudge screen's GET | `passkey.add` |
A small helper for **base64url ⇄ ArrayBuffer**; **generic** error text on failure (A3 unchanged).
## 6. The PWA
- **`manifest.webmanifest`** — name · icons · **`start_url: "/"`** (D-011 — the install survives
identity rotation because `/` resolves the session and redirects to the current GUID).
- **A minimal service worker** (`sw.js`) — registered, but **no caching of authenticated responses
or `/{GUID}`** (A5). At most, static assets. *(Offline is a later slice.)*
- Icons (192/512) + `theme_color`.
## 7. Screens (restyle only — behaviour unchanged)
`/setup` wizard · `/{GUID}` enroll · `/{GUID}` login · the **nudge** (`/{GUID}/auth/passkey`) ·
the **home dashboard** + nav. Behaviour stays exactly as SPEC-005/006 define it.
---
## 8. Step 0 — fold in the SPEC-008 follow-up
Add the **module name-collision guard**: a **discovered** module whose name collides with a core
dispatch module (`auth` · `setup` · `recovery` · `core` · `home`) must **fail to load** (error +
log) rather than silently losing precedence (SPEC-008 §9·D-9).
---
## 9. Acceptance criteria
| # | Criterion |
|---|---|
| AC-1 | A human completes **`/setup`** in a browser (over the tunnel) and reaches the dashboard |
| AC-2 | A **TOTP login** works in a browser; the **nudge** appears (non-loopback); **adding a passkey** succeeds; a later **passkey login** works |
| AC-3 | The page emits **no inline scripts** (the CSP `script-src 'self'` is not weakened) |
| AC-4 | The **manifest** is valid and the app **installs**; `start_url` is `/` |
| AC-5 | The **service worker never caches** an authenticated response or `/{GUID}` (A5) — asserted |
| AC-6 | A discovered module colliding with a core dispatch name **fails to load** (Step 0) |
| AC-7 | The whole suite stays green; the binary is still **static** (`CGO_ENABLED=0`) and the `routing` package stays **pure** |
---
## 10. Implementation notes (built 2026-10-14)
`go build` · `go vet` · `go test` · `go test -race` green (old and new packages); **static
CGO-free** binary; `internal/core/routing` purity intact; **live-smoked** end to end from a copy
of `config.example.yaml` (bootstrap → TOTP login → nudge → dashboard → restart).
**What landed**
| Path | What |
|---|---|
| `internal/core/web/` | the shell (`layout.html` · `shell.go`: `NavLister`/`SetNav`/`Page`/`Render` · `Markup`/`Attrs`) · the embedded assets (`embed.go`) · the SW cache policy (`pwa.go`) |
| `internal/core/web/static/` | `app.css` · `webauthn.js` · `app.js` · `sw.js` · `manifest.webmanifest` · `icon-192.png` · `icon-512.png` |
| `internal/core/server/static.go` | the reserved `static` workflow (D-004) — GET/HEAD, traversal-safe, correct media types, `Service-Worker-Allowed` on `sw.js` |
| restyled screens | `/setup` · enroll · login · the nudge · the dashboard · `/access` · the Modules page · `/home` — all inside the shell, behaviour unchanged |
| Step 0 | the module **name-collision guard** (`module.CoreDispatchNames` · `ErrCoreDispatchName`) |
| `cmd/myday` | registers `static` · `web.SetNav(modules)` · **the TOTP key is generated at startup** (see D-1) |
**The WebAuthn contract** (`webauthn.js`, no framework, no build step): the ceremony root carries
`data-webauthn="enroll|login|passkey"`, the trigger `data-webauthn-start`, the endpoints
`data-begin`/`data-finish`, the nudge's pre-begun options `data-options`, and the status line
`data-webauthn-status`. The CSRF token is read from `<meta name="csrf-token">` and rides the
**query string**, because the finish step's body must be the raw credential JSON (that is what the
WebAuthn server parses). The door reads `csrf` via `FormValue`, which covers the query string.
**Tests without a browser**: the shell/nav unit tests (`web_test.go`), no-inline-scripts over every
real screen (§AC-3), the manifest (§AC-4), the served service worker + the Go cache policy (§AC-5),
the collision guard (§AC-6), the static workflow, and the shell round-trip through the door. The
**ceremonies themselves are browser-bound** — see §11.
## 11. Manual smoke (AC-1/AC-2) — the browser half
Not asserted by the suite (it needs a real authenticator). Steps, in order:
1. Copy `config.example.yaml` → `config.yaml`; point `database.path` at a scratch file; keep
`setup: true` and `server.listen: 127.0.0.1:8080`. `go run ./cmd/myday`.
2. Open `http://127.0.0.1:8080/setup` (**`localhost`/`127.0.0.1`, not a hostname** — with an
`https://` base URL the CSRF/session cookies are `Secure`). AC-1: the wizard is styled; step 1
the domain; step 2 the admin; step 2b the authenticator (scan the `otpauth://` URL, enter a
code); step 3 mail/locale; `Finish` → the dashboard.
3. `/{GUID}` → the login screen. Sign in with the authenticator code. AC-2: on a **non-loopback**
origin (i.e. at the real domain, or with a `Host:` override) the **nudge** appears after a TOTP
login → *Add a passkey* → the browser's platform dialog → the credential is stored and the app
returns to the dashboard; a later **passkey** sign-in works. *Not now* / *Don't ask again*
behave as SPEC-006 §8 defines.
4. PWA: DevTools → Application → Manifest (installs, `start_url` `/`); Service Workers (registered,
scope `/`); then check that navigating `/{GUID}` is **not** served from the cache (Network →
"from ServiceWorker" must be absent).
## 12. Deviations & questions for the Architect
| # | Item | Why | Ruling |
|---|---|---|---|
| **D-1** | **The TOTP encryption key is generated at STARTUP (setup mode)**, not only at `finish`. | **Blocker found live, not by the suite.** D-041 made the wizard enroll TOTP *before* `finish` persists the generated secrets (R-6), so `admin.totp.begin` failed with `auth: no TOTP sealer configured` on a fresh copy of `config.example.yaml` — **the documented bootstrap could never complete** (AC-1). Generating the key at startup and adopting it into the live config (the wizard's persister skips a non-placeholder, so the SAME value is written) implements R-6 as ruled and keeps runtime ≡ file. Verified across a restart: the sealed TOTP secret still unseals. **Flagged because it touches secret lifecycle** — the alternative is to refuse to start and instruct the operator. | |
| **D-2** | `home` is **excluded** from the collision guard. | §8 lists `home` among the core dispatch names, but the dashboard **is** a discovered module (`internal/modules/home`, the reference module, SPEC-008 AC-1) and the core registers no `home` *module* — the level-1 `home` is a reserved *workflow*, a separate switch (SPEC-002 §4.4). Guarding `home` would delete the reference module. The guard therefore covers **`auth` · `setup` · `recovery` · `core`** — exactly the names the core registers into the dispatch registry, i.e. the ones that can silently lose (or steal) precedence. | |
| **D-3** | `app.js` added (one more static asset than §3 lists). | The service worker must be registered from a same-origin file (no inline scripts) and that is not a "webauthn ceremony"; folding it into `webauthn.js` would conflate the two seams. | |
| **D-4** | The nav source is **process-wide** (`web.SetNav`), set once by the entrypoint. | The `home` module instance is created in `init()` (D-028 discovery) before the registry exists, so the nav cannot be injected through a Deps field without main reaching into a module — the very thing D-028 forbids. `SetNav` mirrors the discovery wiring point; it is mutex-guarded and set before serving. | |
| **D-5** | `/access` and the Modules page are restyled too (beyond §7's list). | Both are human-facing core pages; leaving them raw made the shell inconsistent. Behaviour unchanged. | |
| **D-6** | The nudge's passkey button is JS-only (no form fallback). | A `passkey.add` POST without the credential JSON cannot succeed (400) — the ceremony *is* the body. `passkey.later`/`passkey.never` keep their plain forms and a `<noscript>` explains. | |
**Open question (not acted on):** a discovered module may still be named for a **reserved level-1
workflow** (`static`, `access`, `api`, `media`, `ws`, `dev`). It cannot shadow the level-1 handler
(reserved names are matched *before* the registry, SPEC-002 §3.5), but it is confusing at PARAM2
(`/{GUID}/static/…`). Extending the guard to those names needs a ruling — `home` must stay
allowed, so it would need an exception list.
**Observation (A5, no action):** the service worker's cache policy is asserted in Go and mirrored
by the served `sw.js`, but the JavaScript itself is not *executed* by the suite (there is no JS
runtime in it). The Go policy function is the single testable statement of the rule; running the
worker's `isCacheable` in Node (skipped when Node is absent) would harden AC-5 further.
---
## 13. Architect rulings (2026-10-14)
| # | Item | Ruling |
|---|---|---|
| **D-1** | The TOTP key is generated at **startup** (setup mode), not only at `finish`. | **Accepted.** D-041 enrolls TOTP *before* `finish` persists the secrets, so the sealer key must exist first. Adopting the startup-generated key into the live config (the persister skips a non-placeholder) keeps **runtime ≡ file**. *A genuine bootstrap blocker — the live smoke earned its keep.* |
| **D-2** | `home` is **excluded** from the collision guard. | **Accepted.** The guard covers the **core dispatch modules** (`auth` · `setup` · `recovery` · `core`); `home` is a reserved **level-1 workflow** — a different switch (SPEC-002 §3.5). SPEC-008 §9·D-9 wording to be aligned. |
| **D-3** | `static/app.js` added. | **Accepted** — the SW registration is its own seam. |
| **D-4** | The nav source is process-wide (`web.SetNav`). | **Accepted** — it mirrors the D-028 discovery wiring; a neutral `handoff` package is a later cleanup. |
| **D-5** | `/access` + the Modules page restyled. | **Accepted** — both are human-facing core pages. |
| **D-6** | The nudge's passkey button is **JS-only**. | **Accepted** — the ceremony *is* the request body; `later`/`never` keep plain forms + `<noscript>`. |
| **Q-1** | A discovered module may be named for a reserved **level-1** workflow (`static` · `access` · …). | **Resolved — extend the guard** to those names, **except `home`** (a legitimate module). A colliding module fails to load (error + log). *Fold into the next slice; O-15 resolved.* |
| **Q-2** | AC-5's service-worker JS is not *executed* by the suite. | **Accepted as-is** — the Go policy is the single testable statement of the rule. Running `isCacheable` under Node (skipped when absent) is optional hardening, later. |
| **Q-3** | AC-1/AC-2 need a browser; the tunnel must target `localhost`. | **Accepted** — the README gains the caveat: with an `https://` base URL the cookies are `Secure`, so reach the app at **`localhost`/`127.0.0.1`** (a trustworthy origin). *Fold into the next slice.* |