diff --git a/internal/core/web/embed.go b/internal/core/web/embed.go
new file mode 100644
index 0000000..ebc55d2
--- /dev/null
+++ b/internal/core/web/embed.go
@@ -0,0 +1,34 @@
+// Package web is MyDay's shared web layer (SPEC-009): the core shell every
+// screen renders inside, the template helpers, and the embedded static assets
+// (stylesheet · WebAuthn client · shell bootstrap · service worker · PWA
+// manifest · icons).
+//
+// It is deliberately SERVER-RENDERED and BUILDLESS: no framework, no npm, no
+// bundler. The CSP is `script-src 'self'` (SPEC-003 §4), so every script is a
+// same-origin file and there are NO INLINE SCRIPTS — the layout emits none, and
+// the WebAuthn ceremonies are driven from data-* attributes by webauthn.js.
+//
+// The package is a leaf: it imports only the standard library, so any package
+// (the door, the flows, a module) may render inside the shell without creating
+// an import cycle. The nav comes from the module registry through the NavLister
+// seam (SPEC-008 §7), installed once at startup by the entrypoint.
+//
+// Spec: docs/specs/spec-009-web-pwa.md.
+package web
+
+import "embed"
+
+// AppName is the application's display name (the shell header, the manifest).
+const AppName = "MyDay"
+
+// StaticPath is the URL prefix of the reserved `static` workflow (D-004). The
+// door serves the embedded assets under it.
+const StaticPath = "/static/"
+
+// StaticDir is the directory inside the embedded filesystem.
+const StaticDir = "static"
+
+// StaticFS holds the embedded assets (SPEC-009 §3).
+//
+//go:embed static
+var StaticFS embed.FS
diff --git a/internal/core/web/layout.html b/internal/core/web/layout.html
new file mode 100644
index 0000000..43b8fea
--- /dev/null
+++ b/internal/core/web/layout.html
@@ -0,0 +1,26 @@
+
+
+
+
+
+{{.Title}}
+
+{{if .CSRF}} {{end}}
+
+
+
+
+
+
+
+
+
+{{if .Flash}}{{.Flash}}
{{end}}
+
+{{.Content}}
+
+
+
diff --git a/internal/core/web/pwa.go b/internal/core/web/pwa.go
new file mode 100644
index 0000000..c2d6b6a
--- /dev/null
+++ b/internal/core/web/pwa.go
@@ -0,0 +1,45 @@
+package web
+
+import (
+ "net/http"
+ "path"
+ "strings"
+)
+
+// ── the service worker's cache policy (A5, SPEC-009 §6) ──────────────────────
+//
+// THE RULE: the service worker may cache STATIC ASSETS — and nothing else. It
+// must never cache an authenticated response and never a /{GUID} page (A5), so
+// it does not intercept such a request at all.
+//
+// StaticCachePrefixes is the ONE source of truth for "static". The embedded
+// service worker (static/sw.js) declares the same list; a test asserts the two
+// agree, so the policy cannot drift (AC-5).
+
+// StaticCachePrefixes are the only request-path prefixes the service worker may
+// cache. Authenticated responses and /{GUID} pages are never in it.
+var StaticCachePrefixes = []string{StaticPath}
+
+// CacheablePath reports whether a same-origin request may be cached by the
+// service worker (A5): a GET of a static asset, and nothing else. The path is
+// CLEANED first, so "/static/../{GUID}" is judged as "/{GUID}" — a traversal
+// cannot smuggle an authenticated path into the cache.
+//
+// It is the policy the embedded worker implements (static/sw.js) and the shape
+// its test asserts; no caching decision is taken anywhere else.
+func CacheablePath(method, requestPath string) bool {
+ if method != http.MethodGet {
+ return false
+ }
+ p := requestPath
+ if !strings.HasPrefix(p, "/") {
+ p = "/" + p
+ }
+ clean := path.Clean(p)
+ for _, prefix := range StaticCachePrefixes {
+ if strings.HasPrefix(clean, prefix) {
+ return true
+ }
+ }
+ return false
+}
diff --git a/internal/core/web/shell.go b/internal/core/web/shell.go
new file mode 100644
index 0000000..9a32140
--- /dev/null
+++ b/internal/core/web/shell.go
@@ -0,0 +1,186 @@
+package web
+
+import (
+ "bytes"
+ _ "embed"
+ "html/template"
+ "net/http"
+ "strings"
+ "sync"
+)
+
+//go:embed layout.html
+var layoutHTML string
+
+// layout is the ONE base layout (SPEC-009 §4). A parse failure of a constant is
+// impossible; a non-nil error here would be a programming fault.
+var layout = template.Must(template.New("layout").Parse(layoutHTML))
+
+// NavLister is the shell's navigation seam (SPEC-008 §7 / SPEC-009 §4): the
+// names of the ENABLED modules, in registration order. A disabled module's
+// entry is absent (D-038). *module.Registry satisfies it. Declaring the seam
+// here — rather than importing the registry — keeps this package a leaf.
+type NavLister interface {
+ Nav() []string
+}
+
+var (
+ navMu sync.RWMutex
+ navSrc NavLister
+)
+
+// SetNav installs the process-wide navigation source. The entrypoint calls it
+// once at startup with the runtime module registry — the same single, explicit
+// wiring point as build-time discovery (D-028): the directory set is the source
+// of truth, and the shell reads the resulting nav. Until it is set (and in
+// tests) pages render without a nav.
+func SetNav(l NavLister) {
+ navMu.Lock()
+ navSrc = l
+ navMu.Unlock()
+}
+
+// NavSource returns the installed nav source (nil when unset).
+func NavSource() NavLister {
+ navMu.RLock()
+ defer navMu.RUnlock()
+ return navSrc
+}
+
+// Markup is page body HTML a caller has ALREADY escaped. The shell escapes
+// everything it interpolates except this type, so the conversion at the call
+// site is deliberate and greppable: escape first, then convert.
+type Markup = template.HTML
+
+// Attrs is an escaped attribute set for an element — the WebAuthn client's
+// data-* contract lives here. Every value interpolated into it MUST be escaped
+// by the caller.
+type Attrs = template.HTMLAttr
+
+// NavItem is one rendered navigation entry.
+type NavItem struct { // Label is the human label.
+ Label string
+ // Href is the target — /{GUID}/{module} inside a GUID context.
+ Href string
+ // Active marks the current page (aria-current).
+ Active bool
+}
+
+// Page is one page rendered inside the shell.
+type Page struct {
+ // Title is the document title (defaults to AppName).
+ Title string
+ // GUID is the identity riding the path. The nav renders only in a GUID
+ // context; identity stays in the URL (D-001).
+ GUID string
+ // Guest marks an anonymous screen (enroll · login · setup · access): no nav
+ // — there is no identity yet.
+ Guest bool
+ // Active is the PARAM2 module to mark as the current nav entry ("" none).
+ Active string
+ // Flash is a notice or error banner ("" for none).
+ Flash string
+ // FlashKind is "error" for an error banner; anything else is a notice.
+ FlashKind string
+ // CSRF is the token every form — and the WebAuthn client — submits. It is
+ // rendered once as .
+ CSRF string
+ // Content is the page body: HTML the caller has ALREADY escaped.
+ Content template.HTML
+ // MainAttrs are extra attributes for the element — the WebAuthn
+ // ceremony's data-* contract. The caller MUST escape them.
+ MainAttrs template.HTMLAttr
+}
+
+// layoutData is the layout's input (the unexported shape of a Page).
+type layoutData struct {
+ AppName string
+ Title string
+ CSRF string
+ Flash string
+ FlashKind string
+ Nav []NavItem
+ HomeHref string
+ StaticPath string
+ Content template.HTML
+ MainAttrs template.HTMLAttr
+}
+
+// Render writes a page inside the shell. The caller is responsible for
+// Cache-Control (the enroll/login/ceremony screens set `no-store` — F-2); the
+// shell sets the content type and the status.
+//
+// The page is rendered to a buffer first: a template fault never leaves a
+// half-written body behind a 200.
+func Render(w http.ResponseWriter, status int, p Page) error {
+ data := layoutData{
+ AppName: AppName,
+ Title: strings.TrimSpace(p.Title),
+ CSRF: p.CSRF,
+ Flash: p.Flash,
+ FlashKind: flashKind(p.FlashKind),
+ StaticPath: StaticPath,
+ Content: p.Content,
+ MainAttrs: p.MainAttrs,
+ }
+ if data.Title == "" {
+ data.Title = AppName
+ }
+ if p.Guest || strings.TrimSpace(p.GUID) == "" {
+ data.HomeHref = "/"
+ } else {
+ data.HomeHref = "/" + p.GUID + "/home"
+ data.Nav = navItems(p.GUID, p.Active)
+ }
+
+ var buf bytes.Buffer
+ if err := layout.Execute(&buf, data); err != nil {
+ return err
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ _, err := w.Write(buf.Bytes())
+ return err
+}
+
+// flashKind normalises a banner kind for the class attribute.
+func flashKind(kind string) string {
+ if strings.EqualFold(strings.TrimSpace(kind), "error") {
+ return "error"
+ }
+ return "notice"
+}
+
+// navItems builds the nav from the installed NavLister (SPEC-008 §7: the nav is
+// driven by the registry, so a disabled module's entry is absent — D-038).
+func navItems(guid, active string) []NavItem {
+ l := NavSource()
+ if l == nil || guid == "" {
+ return nil
+ }
+ names := l.Nav()
+ items := make([]NavItem, 0, len(names))
+ for _, name := range names {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ continue
+ }
+ items = append(items, NavItem{
+ Label: ModuleLabel(name),
+ Href: "/" + guid + "/" + name,
+ Active: name == active,
+ })
+ }
+ return items
+}
+
+// ModuleLabel renders a module name as a nav label: the first letter upcased
+// and separators read as spaces ("home" -> "Home", "time_off" -> "Time off").
+func ModuleLabel(name string) string {
+ s := strings.TrimSpace(name)
+ if s == "" {
+ return ""
+ }
+ s = strings.NewReplacer("-", " ", "_", " ").Replace(s)
+ return strings.ToUpper(s[:1]) + s[1:]
+}
diff --git a/internal/core/web/static/app.css b/internal/core/web/static/app.css
new file mode 100644
index 0000000..795cb49
--- /dev/null
+++ b/internal/core/web/static/app.css
@@ -0,0 +1,166 @@
+/* MyDay — the core stylesheet (SPEC-009 §4/§7).
+ Hand-written, no build step, no framework. Same-origin only (CSP style-src
+ 'self' — no inline styles). */
+
+:root {
+ --bg: #f5f7fa;
+ --surface: #ffffff;
+ --ink: #101828;
+ --ink-soft: #475467;
+ --line: #dfe3e8;
+ --brand: #1f4bd8;
+ --danger: #b42318;
+ --notice-bg: #ecfdf3;
+ --notice-ink: #054f31;
+ --error-bg: #fef3f2;
+ --radius: 10px;
+ color-scheme: light;
+}
+
+* { box-sizing: border-box; }
+
+html { -webkit-text-size-adjust: 100%; }
+
+body {
+ margin: 0;
+ background: var(--bg);
+ color: var(--ink);
+ font: 16px/1.5 system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
+}
+
+/* ── the shell ─────────────────────────────────────────────────────────────── */
+
+.app-header {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 0.5rem 1.25rem;
+ padding: 0.75rem 1rem;
+ background: var(--ink);
+ color: #fff;
+}
+
+.app-header .brand {
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ color: #fff;
+ text-decoration: none;
+}
+
+.app-header nav ul {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.25rem;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.app-header nav a {
+ display: inline-block;
+ padding: 0.35rem 0.75rem;
+ border-radius: 999px;
+ color: #d0d5dd;
+ text-decoration: none;
+}
+
+.app-header nav a:hover { color: #fff; background: rgba(255, 255, 255, 0.12); }
+
+.app-header nav a[aria-current="page"] {
+ background: #fff;
+ color: var(--ink);
+ font-weight: 600;
+}
+
+main {
+ max-width: 42rem;
+ margin: 0 auto;
+ padding: 1.25rem 1rem 3rem;
+}
+
+h1 { font-size: 1.5rem; margin: 0 0 0.5rem; }
+h2 { font-size: 1.15rem; margin: 1.5rem 0 0.5rem; }
+h3 { font-size: 1rem; margin: 1rem 0 0.25rem; }
+p { margin: 0.5rem 0; color: var(--ink-soft); }
+
+section {
+ background: var(--surface);
+ border: 1px solid var(--line);
+ border-radius: var(--radius);
+ padding: 1rem;
+ margin: 1rem 0;
+}
+
+section p:first-child { margin-top: 0; }
+
+/* ── forms ─────────────────────────────────────────────────────────────────── */
+
+form { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: flex-end; margin: 0.5rem 0; }
+
+label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.9rem; color: var(--ink-soft); }
+
+input {
+ font: inherit;
+ color: var(--ink);
+ padding: 0.5rem 0.6rem;
+ border: 1px solid var(--line);
+ border-radius: 8px;
+ background: #fff;
+ min-width: 12rem;
+}
+
+input:focus-visible,
+button:focus-visible,
+a:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; }
+
+button {
+ font: inherit;
+ font-weight: 600;
+ padding: 0.55rem 0.9rem;
+ border: 1px solid var(--brand);
+ border-radius: 8px;
+ background: var(--brand);
+ color: #fff;
+ cursor: pointer;
+}
+
+button[disabled] { opacity: 0.6; cursor: progress; }
+
+button.secondary {
+ background: #fff;
+ border-color: var(--line);
+ color: var(--ink);
+}
+
+/* ── banners & status ──────────────────────────────────────────────────────── */
+
+.flash {
+ max-width: 42rem;
+ margin: 1rem auto 0;
+ padding: 0.6rem 0.9rem;
+ border-radius: 8px;
+ font-size: 0.95rem;
+}
+
+.flash.notice { background: var(--notice-bg); color: var(--notice-ink); }
+.flash.error { background: var(--error-bg); color: var(--danger); }
+
+#passkey-status { font-size: 0.95rem; color: var(--ink-soft); }
+#passkey-status.error { color: var(--danger); }
+
+/* ── the Modules page ──────────────────────────────────────────────────────── */
+
+table { width: 100%; border-collapse: collapse; font-size: 0.95rem; }
+th, td { text-align: left; padding: 0.4rem 0.5rem; border-bottom: 1px solid var(--line); }
+th { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--ink-soft); }
+td form { margin: 0; }
+.locked { color: var(--ink-soft); font-size: 0.85rem; }
+.restart { color: var(--danger); }
+
+/* ── responsive ────────────────────────────────────────────────────────────── */
+
+@media (max-width: 30rem) {
+ input { min-width: 100%; }
+ form { flex-direction: column; align-items: stretch; }
+ button { width: 100%; }
+}
diff --git a/internal/core/web/static/app.js b/internal/core/web/static/app.js
new file mode 100644
index 0000000..8d44921
--- /dev/null
+++ b/internal/core/web/static/app.js
@@ -0,0 +1,21 @@
+// MyDay shell bootstrap (SPEC-009 §6). Vanilla: no framework, no build step.
+//
+// It registers the service worker. The worker itself caches NOTHING but static
+// assets — an authenticated response and a /{GUID} page are never intercepted
+// (A5) — see sw.js.
+//
+// This file is served same-origin from the reserved `static` workflow, so the
+// CSP `script-src 'self'` holds. There are no inline scripts anywhere.
+"use strict";
+
+(function () {
+ if (!("serviceWorker" in navigator)) {
+ return;
+ }
+ window.addEventListener("load", function () {
+ navigator.serviceWorker.register("/static/sw.js", { scope: "/" })["catch"](function () {
+ // A failed registration (an insecure origin, a locked-down browser) is
+ // not fatal: the app is fully server-rendered and works without it.
+ });
+ });
+})();
diff --git a/internal/core/web/static/icon-192.png b/internal/core/web/static/icon-192.png
new file mode 100644
index 0000000..4680cae
Binary files /dev/null and b/internal/core/web/static/icon-192.png differ
diff --git a/internal/core/web/static/icon-512.png b/internal/core/web/static/icon-512.png
new file mode 100644
index 0000000..d15559a
Binary files /dev/null and b/internal/core/web/static/icon-512.png differ
diff --git a/internal/core/web/static/manifest.webmanifest b/internal/core/web/static/manifest.webmanifest
new file mode 100644
index 0000000..1e300b0
--- /dev/null
+++ b/internal/core/web/static/manifest.webmanifest
@@ -0,0 +1,26 @@
+{
+ "name": "MyDay",
+ "short_name": "MyDay",
+ "description": "Sechpoint's employee time, absence and reporting.",
+ "id": "/",
+ "start_url": "/",
+ "scope": "/",
+ "display": "standalone",
+ "orientation": "any",
+ "background_color": "#f5f7fa",
+ "theme_color": "#101828",
+ "icons": [
+ {
+ "src": "/static/icon-192.png",
+ "sizes": "192x192",
+ "type": "image/png",
+ "purpose": "any"
+ },
+ {
+ "src": "/static/icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "any maskable"
+ }
+ ]
+}
diff --git a/internal/core/web/static/sw.js b/internal/core/web/static/sw.js
new file mode 100644
index 0000000..d1fa7bf
--- /dev/null
+++ b/internal/core/web/static/sw.js
@@ -0,0 +1,79 @@
+// MyDay service worker (SPEC-009 §6, register A5).
+//
+// THE RULE: cache STATIC ASSETS ONLY. An authenticated response and a /{GUID}
+// page are NEVER cached — and not merely "not cached": such a request is not
+// intercepted by this worker at all, so it cannot reach a cache. Offline is
+// explicitly out of scope.
+//
+// CACHEABLE_PREFIXES is kept in step with web.StaticCachePrefixes (Go) by
+// TestAC5_ServiceWorkerPolicy — there is no second, drifting copy of the rule.
+"use strict";
+
+var CACHEABLE_PREFIXES = ["/static/"];
+var CACHE_NAME = "myday-static-v1";
+
+// isCacheable is the single gate: GET · same origin · a static asset.
+function isCacheable(request) {
+ if (request.method !== "GET") {
+ return false;
+ }
+ var url = new URL(request.url);
+ if (url.origin !== self.location.origin) {
+ return false;
+ }
+ for (var i = 0; i < CACHEABLE_PREFIXES.length; i++) {
+ if (url.pathname.indexOf(CACHEABLE_PREFIXES[i]) === 0) {
+ return true;
+ }
+ }
+ return false;
+}
+
+self.addEventListener("install", function (event) {
+ // Nothing is precached (offline is out of scope) — just take over.
+ event.waitUntil(self.skipWaiting());
+});
+
+self.addEventListener("activate", function (event) {
+ event.waitUntil(
+ caches
+ .keys()
+ .then(function (keys) {
+ return Promise.all(
+ keys
+ .filter(function (key) {
+ return key !== CACHE_NAME;
+ })
+ .map(function (key) {
+ return caches.delete(key);
+ })
+ );
+ })
+ .then(function () {
+ return self.clients.claim();
+ })
+ );
+});
+
+self.addEventListener("fetch", function (event) {
+ // Anything that is not a static asset is NOT intercepted: an authenticated
+ // response can never be read from — or written to — a cache (A5).
+ if (!isCacheable(event.request)) {
+ return;
+ }
+ event.respondWith(
+ caches.open(CACHE_NAME).then(function (cache) {
+ return cache.match(event.request).then(function (hit) {
+ if (hit) {
+ return hit;
+ }
+ return fetch(event.request).then(function (response) {
+ if (response && response.ok && response.type === "basic") {
+ cache.put(event.request, response.clone());
+ }
+ return response;
+ });
+ });
+ })
+ );
+});
diff --git a/internal/core/web/static/webauthn.js b/internal/core/web/static/webauthn.js
new file mode 100644
index 0000000..ad20673
--- /dev/null
+++ b/internal/core/web/static/webauthn.js
@@ -0,0 +1,306 @@
+// MyDay's WebAuthn client (SPEC-009 §5). Vanilla JavaScript: no framework, no
+// build step, no inline script — this file is served same-origin from the
+// reserved `static` workflow, so the CSP `script-src 'self'` holds (AC-3).
+//
+// It drives three ceremonies against the SPEC-006 endpoints:
+//
+// enroll POST ?do=auth&action=enroll.begin -> navigator.credentials.create
+// POST ?do=auth&action=enroll.finish (audited; TOTP still follows)
+// login POST ?do=auth&action=login.begin -> navigator.credentials.get
+// POST ?do=auth&action=login.finish
+// passkey the nudge screen's GET has ALREADY begun the ceremony and embedded
+// the options; this completes it with
+// POST ?do=auth&action=passkey.add
+//
+// The routing fields (do/action) and the CSRF token ride the QUERY STRING: the
+// finish step's body must be the raw credential JSON, because that is what the
+// WebAuthn server parses (go-webauthn reads the body). The door reads `csrf`
+// via FormValue, which covers the query string.
+//
+// Failures are GENERIC on purpose (A3): the client never reports which factor
+// failed, whether the identity exists, or why.
+
+"use strict";
+
+(function () {
+ // ── the data-* contract ────────────────────────────────────────────────────
+ var ROOT = "[data-webauthn]"; // data-webauthn="enroll" | "login" | "passkey"
+ var START = "[data-webauthn-start]";
+ var STATUS = "[data-webauthn-status]";
+
+ var MESSAGES = {
+ waiting: "Waiting for your device\u2026",
+ cancelled: "Cancelled. You can try again.",
+ failed: "Could not complete the passkey step. Try again, or use your authenticator code.",
+ unavailable: "This browser cannot use passkeys. Use your authenticator code instead."
+ };
+
+ // Shown only when the server does NOT redirect away (a successful enroll
+ // stays on the enrollment screen so the TOTP step can follow).
+ var SUCCESS = {
+ enroll: "Passkey registered. Now set up your authenticator app.",
+ login: "Signed in.",
+ passkey: "Passkey added."
+ };
+
+ // ── base64url <-> ArrayBuffer ─────────────────────────────────────────────
+ //
+ // The server serializes every binary WebAuthn field as base64url WITHOUT
+ // padding (go-webauthn's protocol.URLEncodedBase64), so both padded and
+ // unpadded input are accepted and the output is always unpadded.
+
+ function base64urlToBuffer(value) {
+ var b64 = String(value).replace(/-/g, "+").replace(/_/g, "/");
+ while (b64.length % 4 !== 0) {
+ b64 += "=";
+ }
+ var raw = window.atob(b64);
+ var bytes = new Uint8Array(raw.length);
+ for (var i = 0; i < raw.length; i++) {
+ bytes[i] = raw.charCodeAt(i);
+ }
+ return bytes.buffer;
+ }
+
+ function bufferToBase64url(buffer) {
+ var bytes = new Uint8Array(buffer);
+ var raw = "";
+ for (var i = 0; i < bytes.length; i++) {
+ raw += String.fromCharCode(bytes[i]);
+ }
+ return window.btoa(raw).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
+ }
+
+ // ── options ───────────────────────────────────────────────────────────────
+
+ function decodeCreationOptions(options) {
+ var publicKey = options.publicKey || options;
+ var out = { publicKey: publicKey };
+ out.publicKey.challenge = base64urlToBuffer(publicKey.challenge);
+ if (publicKey.user && publicKey.user.id) {
+ out.publicKey.user.id = base64urlToBuffer(publicKey.user.id);
+ }
+ if (publicKey.excludeCredentials) {
+ out.publicKey.excludeCredentials = publicKey.excludeCredentials.map(function (credential) {
+ return {
+ id: base64urlToBuffer(credential.id),
+ type: credential.type,
+ transports: credential.transports
+ };
+ });
+ }
+ return out;
+ }
+
+ function decodeRequestOptions(options) {
+ var publicKey = options.publicKey || options;
+ var out = { publicKey: publicKey };
+ out.publicKey.challenge = base64urlToBuffer(publicKey.challenge);
+ if (publicKey.allowCredentials) {
+ out.publicKey.allowCredentials = publicKey.allowCredentials.map(function (credential) {
+ return {
+ id: base64urlToBuffer(credential.id),
+ type: credential.type,
+ transports: credential.transports
+ };
+ });
+ }
+ return out;
+ }
+
+ // ── the credential the server expects ─────────────────────────────────────
+
+ function credentialToJSON(credential) {
+ var response = credential.response;
+ var payload = {
+ id: credential.id,
+ rawId: bufferToBase64url(credential.rawId),
+ type: credential.type,
+ clientExtensionResults:
+ typeof credential.getClientExtensionResults === "function"
+ ? credential.getClientExtensionResults()
+ : {}
+ };
+ if (response.attestationObject) {
+ // Registration (create).
+ payload.response = {
+ attestationObject: bufferToBase64url(response.attestationObject),
+ clientDataJSON: bufferToBase64url(response.clientDataJSON)
+ };
+ } else {
+ // Assertion (get).
+ payload.response = {
+ authenticatorData: bufferToBase64url(response.authenticatorData),
+ clientDataJSON: bufferToBase64url(response.clientDataJSON),
+ signature: bufferToBase64url(response.signature)
+ };
+ if (response.userHandle) {
+ payload.response.userHandle = bufferToBase64url(response.userHandle);
+ }
+ }
+ return payload;
+ }
+
+ // ── transport ─────────────────────────────────────────────────────────────
+
+ function csrfToken() {
+ var meta = document.querySelector('meta[name="csrf-token"]');
+ return meta ? meta.getAttribute("content") || "" : "";
+ }
+
+ function withCSRF(url) {
+ var token = csrfToken();
+ if (!token) {
+ return url;
+ }
+ return url + (url.indexOf("?") === -1 ? "?" : "&") + "csrf=" + encodeURIComponent(token);
+ }
+
+ // begin posts an empty, cookied request and expects the options as JSON.
+ function begin(url) {
+ return fetch(withCSRF(url), {
+ method: "POST",
+ credentials: "same-origin",
+ headers: { Accept: "application/json" }
+ }).then(function (response) {
+ if (!response.ok) {
+ throw new Error("begin");
+ }
+ return response.json();
+ });
+ }
+
+ // finish posts the credential as the RAW body (the WebAuthn server parses it)
+ // and follows the resulting redirect when the server sends one.
+ function finish(url, credential) {
+ return fetch(withCSRF(url), {
+ method: "POST",
+ credentials: "same-origin",
+ headers: { "Content-Type": "application/json", Accept: "application/json" },
+ body: JSON.stringify(credential)
+ }).then(function (response) {
+ if (response.redirected && response.url) {
+ window.location.assign(response.url);
+ return;
+ }
+ if (!response.ok) {
+ throw new Error("finish");
+ }
+ });
+ }
+
+ // ── the ceremonies ────────────────────────────────────────────────────────
+
+ function runEnroll(root) {
+ return begin(root.getAttribute("data-begin"))
+ .then(function (options) {
+ return navigator.credentials.create(decodeCreationOptions(options));
+ })
+ .then(function (credential) {
+ return finish(root.getAttribute("data-finish"), credentialToJSON(credential));
+ });
+ }
+
+ function runLogin(root) {
+ return begin(root.getAttribute("data-begin"))
+ .then(function (options) {
+ return navigator.credentials.get(decodeRequestOptions(options));
+ })
+ .then(function (credential) {
+ return finish(root.getAttribute("data-finish"), credentialToJSON(credential));
+ });
+ }
+
+ // The nudge screen's GET already began the registration, so the options are
+ // embedded in the markup (data-options). A data-begin URL is accepted as a
+ // fallback, which keeps the same client usable if that changes.
+ function runPasskey(root) {
+ var embedded = root.getAttribute("data-options");
+ var started;
+ if (embedded) {
+ started = Promise.resolve(JSON.parse(embedded));
+ } else if (root.getAttribute("data-begin")) {
+ started = begin(root.getAttribute("data-begin"));
+ } else {
+ return Promise.reject(new Error("no options"));
+ }
+ return started
+ .then(function (options) {
+ return navigator.credentials.create(decodeCreationOptions(options));
+ })
+ .then(function (credential) {
+ return finish(root.getAttribute("data-finish"), credentialToJSON(credential));
+ });
+ }
+
+ var RUNNERS = { enroll: runEnroll, login: runLogin, passkey: runPasskey };
+
+ // ── wiring ────────────────────────────────────────────────────────────────
+
+ function statusOf(root) {
+ return root.querySelector(STATUS) || document.getElementById("passkey-status");
+ }
+
+ function setStatus(root, message, isError) {
+ var node = statusOf(root);
+ if (!node) {
+ return;
+ }
+ node.textContent = message;
+ node.className = isError ? "error" : "";
+ node.setAttribute("role", "status");
+ }
+
+ function passkeysUnavailable() {
+ return !window.PublicKeyCredential || !navigator.credentials;
+ }
+
+ function reportFailure(root, error) {
+ if (error && error.name === "NotAllowedError") {
+ setStatus(root, MESSAGES.cancelled, false);
+ return;
+ }
+ setStatus(root, MESSAGES.failed, true);
+ }
+
+ function wire(root) {
+ var kind = String(root.getAttribute("data-webauthn") || "").toLowerCase();
+ var runner = RUNNERS[kind];
+ var button = root.querySelector(START);
+ if (!runner || !button) {
+ return;
+ }
+ if (passkeysUnavailable()) {
+ button.disabled = true;
+ setStatus(root, MESSAGES.unavailable, false);
+ return;
+ }
+ button.addEventListener("click", function () {
+ button.disabled = true;
+ setStatus(root, MESSAGES.waiting, false);
+ runner(root)
+ .then(function () {
+ setStatus(root, SUCCESS[kind] || "Done.", false);
+ })
+ ["catch"](function (error) {
+ reportFailure(root, error);
+ })
+ .then(function () {
+ button.disabled = false;
+ });
+ });
+ }
+
+ function ready() {
+ var roots = document.querySelectorAll(ROOT);
+ for (var i = 0; i < roots.length; i++) {
+ wire(roots[i]);
+ }
+ }
+
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", ready);
+ } else {
+ ready();
+ }
+})();
diff --git a/internal/core/web/web_test.go b/internal/core/web/web_test.go
new file mode 100644
index 0000000..b98f5a8
--- /dev/null
+++ b/internal/core/web/web_test.go
@@ -0,0 +1,342 @@
+package web_test
+
+import (
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "regexp"
+ "strings"
+ "testing"
+
+ "git.lohmar.co.uk/cclohmar/NextMyDay/internal/core/web"
+)
+
+// fakeNav is the NavLister seam (SPEC-008 §7): the enabled module names.
+type fakeNav []string
+
+func (f fakeNav) Nav() []string { return f }
+
+// renderShell renders a page and returns the body.
+func renderShell(t *testing.T, p web.Page) string {
+ t.Helper()
+ rec := httptest.NewRecorder()
+ if err := web.Render(rec, http.StatusOK, p); err != nil {
+ t.Fatalf("web.Render: %v", err)
+ }
+ if got := rec.Header().Get("Content-Type"); !strings.HasPrefix(got, "text/html") {
+ t.Errorf("Content-Type = %q, want text/html", got)
+ }
+ return rec.Body.String()
+}
+
+// ── the shell (SPEC-009 §4) ──────────────────────────────────────────────────
+
+// TestShellRendersContentAndNav: every page renders inside the shell, and the
+// nav comes from the injected NavLister — the registry seam.
+func TestShellRendersContentAndNav(t *testing.T) {
+ web.SetNav(fakeNav{"home", "time", "absence"})
+ t.Cleanup(func() { web.SetNav(nil) })
+
+ body := renderShell(t, web.Page{
+ Title: "Dashboard",
+ GUID: "guid-1",
+ Active: "time",
+ CSRF: "tok-123",
+ Content: web.Markup(`Hello `),
+ })
+
+ for _, want := range []string{
+ "",
+ "Dashboard ",
+ `Hello `,
+ `meta name="csrf-token" content="tok-123"`,
+ `href="/static/app.css"`,
+ `href="/static/manifest.webmanifest"`,
+ "alert(1)`,
+ Flash: `bad news `,
+ FlashKind: "error",
+ CSRF: `">`,
+ Content: web.Markup(`trusted `),
+ })
+ if strings.Contains(body, ``) {
+ t.Error("the title was not escaped")
+ }
+ if strings.Contains(body, "bad news ") {
+ t.Error("the flash was not escaped")
+ }
+ if strings.Contains(body, ``) {
+ t.Error("the CSRF token was not escaped into the meta tag")
+ }
+ if !strings.Contains(body, "trusted ") {
+ t.Error("explicit Markup was escaped — the content slot must render it")
+ }
+ // The banner carries its kind as a class.
+ if !strings.Contains(body, `class="flash error"`) {
+ t.Errorf("the error banner lost its kind:\n%s", body)
+ }
+}
+
+// ── AC-3: no inline scripts ──────────────────────────────────────────────────
+
+// TestShellHasNoInlineScripts: the layout references same-origin files only.
+// The CSP is `script-src 'self'` (SPEC-003 §4) — one inline script would force
+// weakening it, so there must never be one (AC-3).
+func TestShellHasNoInlineScripts(t *testing.T) {
+ body := renderShell(t, web.Page{GUID: "g", Content: web.Markup(`x `)})
+
+ for _, m := range scriptTag.FindAllString(body, -1) {
+ if !strings.Contains(m, "src=") {
+ t.Errorf("the shell emitted an inline