# 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 `` 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 `