feat(web): add the web shell and the PWA (SPEC-009)

- One base layout (layout.html) with the header, the nav from Registry.Nav()
  (D-038), a content slot and the CSRF token; every page renders inside it and
  a module supplies content only
- Embedded same-origin assets (app.css · app.js · webauthn.js · sw.js ·
  manifest.webmanifest · icons) served by the reserved `static` workflow
- webauthn.js drives the three ceremonies from data-* attributes — no inline
  script, so the CSP `script-src 'self'` holds (AC-3)
- manifest start_url "/" (D-011); the service worker never caches an
  authenticated response or /{GUID} (A5)
This commit is contained in:
Claus Lohmar 2026-10-14 10:53:57 +00:00
parent d7d2c7d2b6
commit dd865d4f27
12 changed files with 1231 additions and 0 deletions

View file

@ -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

View file

@ -0,0 +1,26 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}}</title>
<meta name="theme-color" content="#101828">
{{if .CSRF}}<meta name="csrf-token" content="{{.CSRF}}">{{end}}
<link rel="manifest" href="{{.StaticPath}}manifest.webmanifest">
<link rel="icon" type="image/png" sizes="192x192" href="{{.StaticPath}}icon-192.png">
<link rel="apple-touch-icon" href="{{.StaticPath}}icon-192.png">
<link rel="stylesheet" href="{{.StaticPath}}app.css">
<script src="{{.StaticPath}}app.js" defer></script>
<script src="{{.StaticPath}}webauthn.js" defer></script>
</head>
<body>
<header class="app-header">
<a class="brand" href="{{.HomeHref}}">{{.AppName}}</a>
{{if .Nav}}<nav aria-label="Main"><ul>{{range .Nav}}<li><a href="{{.Href}}"{{if .Active}} aria-current="page"{{end}}>{{.Label}}</a></li>{{end}}</ul></nav>{{end}}
</header>
{{if .Flash}}<p class="flash {{.FlashKind}}" role="status">{{.Flash}}</p>{{end}}
<main id="main"{{.MainAttrs}}>
{{.Content}}
</main>
</body>
</html>

45
internal/core/web/pwa.go Normal file
View file

@ -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
}

186
internal/core/web/shell.go Normal file
View file

@ -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 <meta name="csrf-token">.
CSRF string
// Content is the page body: HTML the caller has ALREADY escaped.
Content template.HTML
// MainAttrs are extra attributes for the <main> 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:]
}

View file

@ -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%; }
}

View file

@ -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.
});
});
})();

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3 KiB

View file

@ -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"
}
]
}

View file

@ -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;
});
});
})
);
});

View file

@ -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();
}
})();

View file

@ -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(`<h1>Hello</h1>`),
})
for _, want := range []string{
"<!doctype html>",
"<title>Dashboard</title>",
`<h1>Hello</h1>`,
`meta name="csrf-token" content="tok-123"`,
`href="/static/app.css"`,
`href="/static/manifest.webmanifest"`,
"<nav",
`href="/guid-1/home"`,
`href="/guid-1/time"`,
`href="/guid-1/absence"`,
`aria-current="page"`,
} {
if !strings.Contains(body, want) {
t.Errorf("the shell is missing %q:\n%s", want, body)
}
}
}
// TestShellActiveNavEntry: the current module is marked, and only it.
func TestShellActiveNavEntry(t *testing.T) {
web.SetNav(fakeNav{"home", "time"})
t.Cleanup(func() { web.SetNav(nil) })
body := renderShell(t, web.Page{GUID: "g", Active: "home", Content: web.Markup("x")})
if got := strings.Count(body, `aria-current="page"`); got != 1 {
t.Errorf("aria-current appears %d times, want exactly 1:\n%s", got, body)
}
// The marked entry is `home`.
idx := strings.Index(body, `aria-current="page"`)
if !strings.Contains(body[idx-40:idx+40], "/g/home") {
t.Errorf("the wrong nav entry is marked as current:\n%s", body)
}
}
// TestShellGuestHasNoNav: an anonymous screen (enroll/login/setup/access) has
// no identity, so it has no nav.
func TestShellGuestHasNoNav(t *testing.T) {
web.SetNav(fakeNav{"home"})
t.Cleanup(func() { web.SetNav(nil) })
body := renderShell(t, web.Page{Guest: true, GUID: "g", Content: web.Markup("x")})
if strings.Contains(body, "<nav") {
t.Errorf("a guest page rendered a nav:\n%s", body)
}
}
// TestShellNoNavWithoutASource: with no installed NavLister (e.g. a unit test,
// or a core page before wiring) the shell renders without a nav rather than
// failing.
func TestShellNoNavWithoutASource(t *testing.T) {
web.SetNav(nil)
body := renderShell(t, web.Page{GUID: "g", Content: web.Markup("x")})
if strings.Contains(body, "<nav") {
t.Errorf("the shell invented a nav with no NavLister installed:\n%s", body)
}
}
// TestShellEscapesAndTrustsOnlyMarkup: everything the shell interpolates is
// escaped except the explicitly converted Markup — the flash, the title and the
// CSRF token cannot inject markup.
func TestShellEscapesAndTrustsOnlyMarkup(t *testing.T) {
body := renderShell(t, web.Page{
Title: `A "title" <script>alert(1)</script>`,
Flash: `bad <b>news</b>`,
FlashKind: "error",
CSRF: `"><script>alert(2)</script>`,
Content: web.Markup(`<h1>trusted</h1>`),
})
if strings.Contains(body, `<script>alert(1)</script>`) {
t.Error("the title was not escaped")
}
if strings.Contains(body, "bad <b>news</b>") {
t.Error("the flash was not escaped")
}
if strings.Contains(body, `<script>alert(2)</script>`) {
t.Error("the CSRF token was not escaped into the meta tag")
}
if !strings.Contains(body, "<h1>trusted</h1>") {
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(`<h1>x</h1>`)})
for _, m := range scriptTag.FindAllString(body, -1) {
if !strings.Contains(m, "src=") {
t.Errorf("the shell emitted an inline <script>: %q", m)
}
}
if got := strings.Count(body, `<script`); got != 2 {
t.Errorf("the shell emitted %d script tags, want exactly the 2 same-origin files:\n%s", got, body)
}
if inlineHandler.MatchString(body) {
t.Errorf("the shell emitted an inline event handler:\n%s", body)
}
}
var (
scriptTag = regexp.MustCompile(`(?is)<script\b[^>]*>`)
inlineHandler = regexp.MustCompile(`(?is)\son[a-z]+\s*=`)
)
// ── AC-4: the manifest ───────────────────────────────────────────────────────
// manifestFile is the subset of the web app manifest this slice depends on.
type manifestFile struct {
Name string `json:"name"`
ShortName string `json:"short_name"`
StartURL string `json:"start_url"`
Scope string `json:"scope"`
Display string `json:"display"`
ThemeColor string `json:"theme_color"`
Background string `json:"background_color"`
Icons []struct {
Src string `json:"src"`
Sizes string `json:"sizes"`
Type string `json:"type"`
Purpose string `json:"purpose"`
} `json:"icons"`
}
// TestAC4_ManifestIsValidAndStartsAtRoot: the PWA manifest parses and installs,
// and `start_url` is "/" (D-011): the door resolves the session and redirects
// to the CURRENT GUID, so an installed app survives identity rotation.
func TestAC4_ManifestIsValidAndStartsAtRoot(t *testing.T) {
raw, err := web.StaticFS.ReadFile(web.StaticDir + "/manifest.webmanifest")
if err != nil {
t.Fatalf("the manifest is not embedded: %v", err)
}
var m manifestFile
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatalf("the manifest is not valid JSON: %v\n%s", err, raw)
}
if m.StartURL != "/" {
t.Errorf("start_url = %q, want %q (D-011)", m.StartURL, "/")
}
if m.Name == "" || m.ShortName == "" {
t.Error("the manifest has no name/short_name")
}
if m.Display != "standalone" {
t.Errorf("display = %q, want standalone", m.Display)
}
if m.Scope != "/" {
t.Errorf("scope = %q, want /", m.Scope)
}
if m.ThemeColor == "" || m.Background == "" {
t.Error("the manifest has no theme_color/background_color")
}
// Both install sizes are declared, and each icon is really embedded (and
// really a PNG).
sizes := map[string]bool{}
for _, icon := range m.Icons {
sizes[icon.Sizes] = true
if icon.Type != "image/png" {
t.Errorf("icon %s type = %q, want image/png", icon.Src, icon.Type)
}
if !strings.HasPrefix(icon.Src, web.StaticPath) {
t.Errorf("icon %s is not under %s", icon.Src, web.StaticPath)
}
data, err := web.StaticFS.ReadFile(web.StaticDir + "/" + strings.TrimPrefix(icon.Src, web.StaticPath))
if err != nil {
t.Errorf("icon %s is not embedded: %v", icon.Src, err)
continue
}
if len(data) < 8 || string(data[1:4]) != "PNG" {
t.Errorf("icon %s is not a PNG (first bytes %q)", icon.Src, data[:min(8, len(data))])
}
}
for _, want := range []string{"192x192", "512x512"} {
if !sizes[want] {
t.Errorf("the manifest declares no %s icon (AC-4); got %v", want, sizes)
}
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// ── AC-5: the service worker's cache policy (A5) ─────────────────────────────
// TestAC5_ServiceWorkerNeverCachesAuthenticatedOrGUIDRequests asserts the
// policy: ONLY static assets are cacheable. An authenticated response and a
// /{GUID} page never are — including behind a traversal.
func TestAC5_ServiceWorkerNeverCachesAuthenticatedOrGUIDRequests(t *testing.T) {
cases := []struct {
name string
method string
path string
want bool
}{
{"static stylesheet", "GET", "/static/app.css", true},
{"static script", "GET", "/static/webauthn.js", true},
{"the worker itself", "GET", "/static/sw.js", true},
{"nested static asset", "GET", "/static/icons/deep.png", true},
{"a GUID root", "GET", "/AbCdEf123456", false},
{"a GUID page", "GET", "/AbCdEf123456/home", false},
{"a GUID module page", "GET", "/AbCdEf123456/core/modules", false},
{"the app root", "GET", "/", false},
{"the landing page", "GET", "/home", false},
{"the setup wizard", "GET", "/setup", false},
{"the recovery entry", "GET", "/access", false},
{"an action", "GET", "/AbCdEf123456?do=auth&action=passkey.add", false},
// Traversal must not smuggle an authenticated path into the cache.
{"traversal out of static", "GET", "/static/../AbCdEf123456", false},
{"traversal to the root", "GET", "/static/../..", false},
// Only GET: a mutation is never cached.
{"POST of a static path", "POST", "/static/app.css", false},
{"HEAD of a static path", "HEAD", "/static/app.css", false},
// A lookalike prefix is not the static namespace.
{"lookalike prefix", "GET", "/staticly", false},
{"static without a file", "GET", "/static", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := web.CacheablePath(tc.method, tc.path); got != tc.want {
t.Errorf("CacheablePath(%s, %s) = %v, want %v (A5)", tc.method, tc.path, got, tc.want)
}
})
}
}
// TestAC5_ServiceWorkerMirrorsThePolicy: the worker's own allowlist is the SAME
// list as the Go policy — there is no second, drifting copy of the rule — and
// the worker refuses everything else BEFORE it can reach a cache (A5).
func TestAC5_ServiceWorkerMirrorsThePolicy(t *testing.T) {
raw, err := web.StaticFS.ReadFile(web.StaticDir + "/sw.js")
if err != nil {
t.Fatalf("the service worker is not embedded: %v", err)
}
src := string(raw)
// The declared allowlist must equal the Go source of truth.
want := `var CACHEABLE_PREFIXES = ["` + strings.Join(web.StaticCachePrefixes, `", "`) + `"];`
if !strings.Contains(src, want) {
t.Errorf("the service worker's allowlist does not match web.StaticCachePrefixes.\nwant: %s\ngot:\n%s", want, src)
}
if len(web.StaticCachePrefixes) != 1 || web.StaticCachePrefixes[0] != web.StaticPath {
t.Errorf("StaticCachePrefixes = %v, want exactly [%q]", web.StaticCachePrefixes, web.StaticPath)
}
// The gate: a non-cacheable request is NOT intercepted, so an authenticated
// response cannot be served from — or written to — any cache.
if !strings.Contains(src, "if (!isCacheable(event.request)) {") {
t.Error("the fetch handler does not gate on isCacheable (A5)")
}
// The only way into a cache is inside that gate.
if got := strings.Count(src, "cache.put("); got != 1 {
t.Errorf("the worker writes to a cache in %d places, want exactly 1 (guarded) — A5", got)
}
// A cached response is only stored when the browser classified it as a
// basic (same-origin) response, and the cache is never populated at install
// time (offline is out of scope).
if strings.Contains(src, "cache.addAll") || strings.Contains(src, "caches.open(CACHE_NAME).then(function (cache) {\n return cache.addAll") {
t.Error("the worker precaches at install time; offline is out of scope (SPEC-009 §2)")
}
if !strings.Contains(src, `response.type === "basic"`) {
t.Error("the worker caches a response without checking it is same-origin")
}
}
// ── helpers ──────────────────────────────────────────────────────────────────
func TestModuleLabel(t *testing.T) {
cases := map[string]string{
"home": "Home",
"time": "Time",
"time_off": "Time off",
"hr-data": "Hr data",
"": "",
}
for in, want := range cases {
if got := web.ModuleLabel(in); got != want {
t.Errorf("ModuleLabel(%q) = %q, want %q", in, got, want)
}
}
}