From dd865d4f277f178eff5f05c6214a01964fa47941 Mon Sep 17 00:00:00 2001 From: Claus Lohmar Date: Wed, 14 Oct 2026 10:53:57 +0000 Subject: [PATCH] feat(web): add the web shell and the PWA (SPEC-009) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- internal/core/web/embed.go | 34 ++ internal/core/web/layout.html | 26 ++ internal/core/web/pwa.go | 45 +++ internal/core/web/shell.go | 186 ++++++++++ internal/core/web/static/app.css | 166 +++++++++ internal/core/web/static/app.js | 21 ++ internal/core/web/static/icon-192.png | Bin 0 -> 1074 bytes internal/core/web/static/icon-512.png | Bin 0 -> 3109 bytes internal/core/web/static/manifest.webmanifest | 26 ++ internal/core/web/static/sw.js | 79 ++++ internal/core/web/static/webauthn.js | 306 ++++++++++++++++ internal/core/web/web_test.go | 342 ++++++++++++++++++ 12 files changed, 1231 insertions(+) create mode 100644 internal/core/web/embed.go create mode 100644 internal/core/web/layout.html create mode 100644 internal/core/web/pwa.go create mode 100644 internal/core/web/shell.go create mode 100644 internal/core/web/static/app.css create mode 100644 internal/core/web/static/app.js create mode 100644 internal/core/web/static/icon-192.png create mode 100644 internal/core/web/static/icon-512.png create mode 100644 internal/core/web/static/manifest.webmanifest create mode 100644 internal/core/web/static/sw.js create mode 100644 internal/core/web/static/webauthn.js create mode 100644 internal/core/web/web_test.go 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}} + + + + + + + + +
+{{.AppName}} +{{if .Nav}}{{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 0000000000000000000000000000000000000000..4680caebd860f1c5682d11f215d76ab6a070a331 GIT binary patch literal 1074 zcmeAS@N?(olHy`uVBq!ia0vp^2SAvE2}s`E_d9@rf%&JWi(^Q|oHut2`&5pKwLYx! ze{5XQ+^0=UzaP^KR=yc zzwgtH_x5$)KQW7SoY>ItuQ+O64A1=N-dXbxaxYn^%d;l^kKnR}kB(h?%;%C-d*O@X`EUS^7Pu%#2NzUwv#3>6w*$taja#5M}$$VaYPKloquamiF9Y9?si$MVvqC|FDbI zeMVcJ(SEsdg`jLEBd5cGIPthb>1e(+06r^R(_ zc?*gdGhf^nS+hLDLF(7?SzoX4cEn!cJ;6~MR95H3zGR{L)YmKJCbV^3-|Xlhvfa@k z*L}smKW_vB&bMt_kv_X&m+2O7TWeMqUu)JYDJ^ER=AUJHb>TwX*Eix9Zt~`apP$AU zcxoD>tfiCnx%2B7=U%^X^t;U0)d9?Jr7n2#zKuSAk1_DzJw{mrr|9$NzcIeOcj4XF zIRY2<@yZ6AS7h9HL6I@n+$sIcc}vE(S1%ZTl@Yz5$2&LVd?use{Y*wt`Gtp{JwMQ( zWnL0zo6Z)pd7<)aOEZOKmSzirluKUynIjW0w~cSz^A8PLCM9LI&)JXMJ;$H%kVDqq zUY9BAYCwC{E*6vh3)Q}waCOW!;mWwh@pb0>IL5P?D?)AGGVR*EFz;&#Psc_pwvx*n zrgP>mW4wDY;C$6DHj`}&w|(8h*P$52Zj#bsb>9Db!>6`c*OeEzUz$+G;^O7dzrA6`jx zPse0sc78tnL9;27IVL$MJ^Aq3=eZ5>nU5Az*MF)TxKe)R>&JBm3GSQ8m{fdAsieElIGYf*j$D^$Oo~aLgG*A*6n#5*5v*Dkd?5tjq{~;aFv4@o9ac!gnbx~K#!lAw-P{H41{ z0NdRuFp=Ql9*5r_mP4V||DKLqhyuU)Y$QPnXx5tXU<0>h=m1_X6@Fj7=6vK{6#V7M z-y^ez1G@L|Ysdh;!#kY#I(3NPrwuH{;V7s%Ys7N_tP7K)3b`>JRxRA1H~m!P3s?;n zTX*mr34GNV(j5oAJKx4N#0_v4{?2P0iKM`euE47S9!dEWczL8YvMw5N_r((0kiIM%^z8Oyo5W44VE@TTl>(hJfw?otZUvYt=f~AbcqxZx%J?@ZmGAl}{eiG@ z=gM%E60KVeY5xkg7XloXxa%eo4vz3(v@&x)^yX1ak_#7;mUJ4aOYx8#ay|o{kV2{_ z#BKrII}}sy!h*VnM)EQ|MJDo{De%<~A+`~8`4ppYVH4@o(dcR!ULx`r|B(W3_J`PA zaIt`L3;bHr0yOGq!ATNGEP%%EP$Q?8K&KY)XMOkWN5evU#$!`iLK;mvrGRSQ6fic4;@|4X zHqOtQNJj?kp5tQIk`5g$)RCn`@mW3@qw(O05=N=wH7+)Rbc~`I@U|;OgN|DzmZ=`R zO2W)k@%b%yqlC#-@!z-LePZzk`s4UNarlA)tuaBpBa)y*2TUN$*m}|Z9?k$IHIb?k z7&uDkmhtRu0>5?sT~KIIeJPfS3Nbc#+8=rri(+Z_2QGaclxtB!3%etF`J6@rR8>cY z%X4-~eevGD$*@2r6j3iW|DmfX;9)s9V#?tHHYIwS?m86_9dpOc?++Q^$ZoFtBmPijNgGjZ)6@MX|Fn**Lg z2}-|(=u-AW@%?g-wV^Mwu#kkcUMdWt|4qVOr*cg2I)5|rA1vqzl3ndt2AT0&YD;JZUaAA;Dz`Hr4`*le+=PP zt1L-gXTvLguYp%{_+l|+BI(Fg;jB$y8A*@79IP1~wG&=@5T`+YJ0%ChEwr$VZB)aA zBC7be;VdrdC|vQ&GC99}x(trqKvWr9oD5tZ_1dzqu#uwtl;<+*F28-D96Yzs(K5C> z6}V#RAQO(yiEbPerz0o+5ULzWFtK#ooDn*x=SMfuIoWo;dUPKM-Ht9+?m zrxqf zXrA*m;j-t8XEhC7Dg2;QCqY}yeZrYV5_I0&D*&^2xVd|u4Lo|K0#=U#Jy8 zteDs0w+5r)%u^-n8pirCxX3e-? z3*s%sP!7)4x Z0OPe 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