NextMyDay/docs/specs/spec-009-web-pwa.md
Claus Lohmar 57610cdbae docs: add the project plan, M0/M0.1 specs, agent guide and README
- PROJECT_PLAN.md: the decision log of record through D-042 and the M0.1 slice
- docs/specs: SPEC-002…009 — routing · door · identity/sessions · /setup ·
  enroll/login · roles/chain/recovery · module registry · the web layer & PWA
  (plus docs/specs/README.md, the index)
- AGENT.md: the working rules — commit AND push at every submilestone (gate
  green), Conventional Commits, never commit secrets
- README.md: the quickstart, the first-run/SSH-tunnel steps and the
  localhost/loopback caveat (SPEC-009 §13 Q-3): with an https base URL the
  CSRF/session cookies are Secure, so reach the app at localhost/127.0.0.1
- config.example.yaml: the settings template (D-035)
2026-10-14 10:53:44 +00:00

192 lines
No EOL
13 KiB
Markdown

# 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.* |