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