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:
parent
f81dac9881
commit
57610cdbae
13 changed files with 1497 additions and 4 deletions
79
AGENT.md
Normal file
79
AGENT.md
Normal 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).
|
||||||
|
|
@ -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-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-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-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-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-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-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 |
|
| 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) |
|
| 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 |
|
| 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 |
|
| # | Milestone | Depends on | Notes |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| **Prep** | **Repo bootstrap** — `git init` · `.gitignore` · `config.example.yaml` · **`AGENT.md`** · `README.md` · commit workflow | — | **planning artefact — done first** |
|
| **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 | *starting after Prep* |
|
| **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 |
|
| **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 |
|
| **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 |
|
| **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.
|
- **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.
|
- **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.
|
- **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
|
## 13. Conventions
|
||||||
|
|
||||||
- **Language/stack:** Go; **single binary**; **SQLite** (WAL, foreign keys ON, CGO-free driver);
|
- **Language/stack:** Go; **single binary**; **SQLite** (WAL, foreign keys ON, CGO-free driver);
|
||||||
`sqlc`; embedded migrations; `slog` with a correlation id.
|
`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).
|
- **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.
|
- **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 | 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 | **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 | **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
83
README.md
Normal 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
55
config.example.yaml
Normal 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
40
docs/specs/README.md
Normal 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**.
|
||||||
210
docs/specs/spec-002-routing-model.md
Normal file
210
docs/specs/spec-002-routing-model.md
Normal 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`. |
|
||||||
158
docs/specs/spec-003-core-door.md
Normal file
158
docs/specs/spec-003-core-door.md
Normal 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. |
|
||||||
164
docs/specs/spec-004-identity-sessions.md
Normal file
164
docs/specs/spec-004-identity-sessions.md
Normal 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.
|
||||||
133
docs/specs/spec-005-setup-wizard.md
Normal file
133
docs/specs/spec-005-setup-wizard.md
Normal 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).
|
||||||
|
|
||||||
152
docs/specs/spec-006-enroll-login.md
Normal file
152
docs/specs/spec-006-enroll-login.md
Normal 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).
|
||||||
90
docs/specs/spec-007-roles-chain-recovery.md
Normal file
90
docs/specs/spec-007-roles-chain-recovery.md
Normal 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.
|
||||||
107
docs/specs/spec-008-module-registry.md
Normal file
107
docs/specs/spec-008-module-registry.md
Normal 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.
|
||||||
192
docs/specs/spec-009-web-pwa.md
Normal file
192
docs/specs/spec-009-web-pwa.md
Normal 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.* |
|
||||||
Loading…
Reference in a new issue