- PROJECT_PLAN.md: rewrite §5.1 for the toggle gate, mark D-025 superseded, refresh D-026 / O-8 / A13 / §12 and add the D-045 change-log entry. - SPEC-005: §1/§3/§5/§6 (AC-1/AC-2/AC-7 + AC-8) reflect the three states and the optional code; §9 marks the D-044 tunnel bits superseded; new §10. - SPEC-003: §4/§7 pipeline + AC-7/AC-10/AC-14; new §13 (D-045); D-025 ref removed from the governing decisions. - docs/deploy-zoraxy.md: §5 rewritten around the toggle + optional code; §1/§2/§3/§7 de-tunnelled; see-also updated. - README: first-run + deployment sections rewritten (enable setup: true, open /setup, complete, the app flips it off). - config.example.yaml: document the toggle and the optional code.
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 (fromRegistry.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 (while the wizard is enabled, D-045) 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:
- Copy
config.example.yaml→config.yaml; pointdatabase.pathat a scratch file; keepsetup: trueandserver.listen: 127.0.0.1:8080.go run ./cmd/myday. - Open
http://127.0.0.1:8080/setup(localhost/127.0.0.1, not a hostname — with anhttps://base URL the CSRF/session cookies areSecure). AC-1: the wizard is styled; step 1 the domain; step 2 the admin; step 2b the authenticator (scan theotpauth://URL, enter a code); step 3 mail/locale;Finish→ the dashboard. /{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 aHost: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.- 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. | Accepted — the README notes the WebAuthn caveat: the passkey is enrolled at the app's real origin (a trustworthy https/localhost context), and with an https:// base URL the cookies are Secure. Fold into the next slice. |