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

13 KiB

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.