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:
parent
d7d2c7d2b6
commit
dd865d4f27
12 changed files with 1231 additions and 0 deletions
34
internal/core/web/embed.go
Normal file
34
internal/core/web/embed.go
Normal 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
|
||||
26
internal/core/web/layout.html
Normal file
26
internal/core/web/layout.html
Normal 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
45
internal/core/web/pwa.go
Normal 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
186
internal/core/web/shell.go
Normal 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:]
|
||||
}
|
||||
166
internal/core/web/static/app.css
Normal file
166
internal/core/web/static/app.css
Normal 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%; }
|
||||
}
|
||||
21
internal/core/web/static/app.js
Normal file
21
internal/core/web/static/app.js
Normal 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.
|
||||
});
|
||||
});
|
||||
})();
|
||||
BIN
internal/core/web/static/icon-192.png
Normal file
BIN
internal/core/web/static/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1 KiB |
BIN
internal/core/web/static/icon-512.png
Normal file
BIN
internal/core/web/static/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3 KiB |
26
internal/core/web/static/manifest.webmanifest
Normal file
26
internal/core/web/static/manifest.webmanifest
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
79
internal/core/web/static/sw.js
Normal file
79
internal/core/web/static/sw.js
Normal 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;
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
306
internal/core/web/static/webauthn.js
Normal file
306
internal/core/web/static/webauthn.js
Normal 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();
|
||||
}
|
||||
})();
|
||||
342
internal/core/web/web_test.go
Normal file
342
internal/core/web/web_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue