No script by default, prefer none — and the busy rule on Home's forms
Paul, 2026-09-29: remove the scriptless ruling; the default is no script, scripts may be added to progressively enhance and provide interaction, but always prefer no JavaScript. AGENTS.md says so, replacing rulings 21/24's closed list of screens. The sweep now names Home's scripts instead of screens (static same-origin files, never inline): the handoff auto-submit, the passkey ceremonies, and estilo's busy rule, which every non-hop page now loads (every form still works without it).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
8 files changed,
+64
−45
AGENTS.md+13 −16internal/home/calma.go+8 −0internal/home/credits_test.go+1 −1internal/home/directory_catalog_test.go+1 −1internal/home/docs_chooser_test.go+1 −1internal/home/noscript_test.go+37 −26internal/home/origins_test.go+1 −0internal/home/templates/layout.html+2 −0
CI passed — run details
CI log
amadan: .amadan/timeout: this job's budget is 15m0s (this runner allows up to 30m0s) === vet === CGO_ENABLED=0 go vet ./... cd handoff && CGO_ENABLED=0 go vet ./... cd roster && CGO_ENABLED=0 go vet ./... cd connect && CGO_ENABLED=0 go vet ./... === fmt === === build === CGO_ENABLED=0 go build ./... CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build ./cmd/home ./cmd/oficina ./internal/operatorlaunch cd handoff && CGO_ENABLED=0 go build ./... cd roster && CGO_ENABLED=0 go build ./... cd connect && CGO_ENABLED=0 go build ./... === go-test === CGO_ENABLED=0 go test -count=1 ./... ? amadan.net/oficina/home/cmd/home [no test files] ok amadan.net/oficina/home/cmd/home-placeholder 0.575s ? amadan.net/oficina/home/cmd/oficina [no test files] ok amadan.net/oficina/home/internal/guardstest 0.035s ok amadan.net/oficina/home/internal/home 8.511s ok amadan.net/oficina/home/internal/home/directorydata 0.996s ok amadan.net/oficina/home/internal/home/logtest 0.005s ok amadan.net/oficina/home/internal/home/seal 0.005s ok amadan.net/oficina/home/internal/home/sealtest 0.005s ok amadan.net/oficina/home/internal/operator 5.115s ok amadan.net/oficina/home/internal/operatorlaunch 4.149s ok amadan.net/oficina/home/internal/provisioner 38.409s cd handoff && CGO_ENABLED=0 go test -count=1 ./... ok amadan.net/oficina/home/handoff 0.318s ok amadan.net/oficina/home/handoff/seal 0.002s cd roster && CGO_ENABLED=0 go test -count=1 ./... ok amadan.net/oficina/home/roster 5.081s cd connect && CGO_ENABLED=0 go test -count=1 ./... ok amadan.net/oficina/home/connect 0.058s ok amadan.net/oficina/home/connect/linkstore 0.179s === race === go test -race -count=1 ./internal/home/ ./internal/operator/ ./internal/operatorlaunch/ ./internal/provisioner/ ./internal/home/directorydata/ ok amadan.net/oficina/home/internal/home 119.135s ok amadan.net/oficina/home/internal/operator 69.343s ok amadan.net/oficina/home/internal/operatorlaunch 22.404s ok amadan.net/oficina/home/internal/provisioner 342.681s ok amadan.net/oficina/home/internal/home/directorydata 17.204s cd handoff && CGO_ENABLED=1 go test -race -count=1 ./... ok amadan.net/oficina/home/handoff 9.529s ok amadan.net/oficina/home/handoff/seal 1.010s cd roster && CGO_ENABLED=1 go test -race -count=1 ./... ok amadan.net/oficina/home/roster 7.448s cd connect && CGO_ENABLED=1 go test -race -count=1 ./... ok amadan.net/oficina/home/connect 1.673s ok amadan.net/oficina/home/connect/linkstore 3.958s
diff --git a/AGENTS.md b/AGENTS.md| index 8b4bc13..293cab7 100644 |
| --- a/AGENTS.md |
| +++ b/AGENTS.md |
| @@ -308,22 +308,19 @@ quietly ignored). |
| copy of that test. There is no vendored `static/` in the |
| tree today — `internal/home/page.go` links no stylesheet, deliberately — |
| so this is an instruction with no object until the first asset arrives. |
| -- Zero-JS baseline, with named exceptions (rulings21 and24): the passkey |
| - enrolment and ceremony screens **may load script**. rastrillo ships the |
| - hard half — `webauthn.JS()` embeds `js/webauthn.mjs` — but that module |
| - only exports `navigator.credentials` wrappers and makes no network call, |
| - so the page also needs a few lines of app glue to import it, call it and |
| - POST the result. Both are allowed; both are **static same-origin files, |
| - never inline `<script>`**. |
| - That sentence used to read "no surface here has any reason to require |
| - script, and the day one does is the day the spec is wrong" — and the day |
| - came, with passkeys. It was a tripwire and it fired correctly. The assertion handoff alone also loads its static auto-submit enhancement |
| - (ruling24), retaining a visible Continue button when scripts are off. Everything |
| - else stays scriptless: magic-link sign-in, recovery-code redemption and |
| - the springboard, so script-off keeps a working sign-in and loses only an |
| - optional second factor. The no-`<script>` test carries an **explicit |
| - allowlist of the carved-out screens** — never a blanket exemption, because |
| - a rule with a list is checkable and a rule with an escape hatch is not. |
| +- **No script by default; script only to enhance, and always prefer |
| + none** (Paul, 2026-09-29, replacing rulings 21/24's closed list of |
| + screens). Every screen works with scripts off — magic-link sign-in, |
| + recovery codes, the springboard, sign-out — and a script may be added |
| + to a page only to enhance it or to provide interaction HTML and CSS |
| + cannot: the passkey ceremonies (`webauthn.JS()` plus a little glue), |
| + the assertion handoff's auto-submit (its Continue button stays for |
| + script-off), and estilo's busy rule on every submit button (the |
| + spinner, the double-submit guard). Reach for HTML and CSS first; a |
| + script is the exception that has to earn its place, not a tool. |
| + Scripts are **static same-origin files, never inline `<script>`**, and |
| + every one Home serves is named in `noscript_test.go`'s closed list — |
| + a rule with a list is checkable, a rule with an escape hatch is not. |
| **Deploys** |
| - One binary, `CGO_ENABLED=0`, cgo-free SQLite. |
diff --git a/internal/home/calma.go b/internal/home/calma.go| index 916fac6..631387a 100644 |
| --- a/internal/home/calma.go |
| +++ b/internal/home/calma.go |
| @@ -1,6 +1,7 @@ |
| package home |
| import ( |
| + "amadan.net/oficina/estilo.git/busy" |
| _ "embed" |
| "amadan.net/oficina/estilo.git/sky" |
| @@ -63,4 +64,11 @@ func mountCalma(r chi.Router) { |
| r.Handle(sky.Prefix+"*", sky.Handler()) |
| mountCSS(r, "/static/estilo/sky.css", sky.CSS()) |
| mountCSS(r, "/static/calma.css", calmaCSS) |
| + // estilo's busy rule: a submit button's label gives way to a spinner |
| + // for at least 650ms, the button disables, the form refuses a second |
| + // submit, and Back resets it. A progressive enhancement: every form |
| + // works without it. |
| + mountCSS(r, "/static/busy.css", busy.CSS()) |
| + versionAsset("/static/busy.js", busy.JS()) |
| + r.Get("/static/busy.js", serveAsset(busy.JS(), "text/javascript; charset=utf-8")) |
| } |
diff --git a/internal/home/credits_test.go b/internal/home/credits_test.go| index 4a2b08f..28ac200 100644 |
| --- a/internal/home/credits_test.go |
| +++ b/internal/home/credits_test.go |
| @@ -33,7 +33,7 @@ func TestCreditsArePublicOnKnownHostsAndDoNotStartSignin(t *testing.T) { |
| t.Fatalf("%s credits missing %s", name, wanted) |
| } |
| } |
| - if strings.Contains(body, "<script") || strings.Contains(body, "chooser-person") { |
| + if hasOtherScript(body) || strings.Contains(body, "chooser-person") { |
| t.Fatal("credits page includes application state or script") |
| } |
| if name == "directory" { |
diff --git a/internal/home/directory_catalog_test.go b/internal/home/directory_catalog_test.go| index 02c17c8..e103859 100644 |
| --- a/internal/home/directory_catalog_test.go |
| +++ b/internal/home/directory_catalog_test.go |
| @@ -50,7 +50,7 @@ func TestDirectoryCatalogRequiresSigninAndDoesNotInventLaunchDestinations(t *tes |
| t.Fatal("missing app", name) |
| } |
| } |
| - if strings.Count(body, "<a ") != 7 || strings.Count(body, `href="/help/open-source"`) != 1 || !strings.Contains(body, `href="/apps/docs"`) || !strings.Contains(body, "Choose a team") || strings.Contains(body, "<script") { |
| + if strings.Count(body, "<a ") != 7 || strings.Count(body, `href="/help/open-source"`) != 1 || !strings.Contains(body, `href="/apps/docs"`) || !strings.Contains(body, "Choose a team") || hasOtherScript(body) { |
| t.Fatal("catalog advertised unsupported launch", body) |
| } |
| if strings.Count(body, "Not connected to Home yet") != 0 || strings.Count(body, "Coming soon") != 0 || strings.Count(body, "Separate sign-in") != 2 { |
diff --git a/internal/home/docs_chooser_test.go b/internal/home/docs_chooser_test.go| index f9e4b94..b1509bf 100644 |
| --- a/internal/home/docs_chooser_test.go |
| +++ b/internal/home/docs_chooser_test.go |
| @@ -139,7 +139,7 @@ func TestDocsChooserZeroOneManyAndRemovedChoice(t *testing.T) { |
| t.Fatal("missing", want) |
| } |
| } |
| - if r.Code != 200 || strings.Contains(r.Body.String(), "<script") || strings.Contains(r.Body.String(), "Foreign secret label") || strings.Contains(r.Body.String(), chooserPerson) { |
| + if r.Code != 200 || hasOtherScript(r.Body.String()) || strings.Contains(r.Body.String(), "Foreign secret label") || strings.Contains(r.Body.String(), chooserPerson) { |
| t.Fatal(r.Code, r.Body.String()) |
| } |
| r = f.request("POST", docsChoicePath, directoryOrigin, "team=B2", cookie) |
diff --git a/internal/home/noscript_test.go b/internal/home/noscript_test.go| index 5824568..e820142 100644 |
| --- a/internal/home/noscript_test.go |
| +++ b/internal/home/noscript_test.go |
| @@ -20,24 +20,24 @@ import ( |
| "github.com/go-chi/chi/v5" |
| ) |
| -// scriptAllowed is ruling 21's carve-out, by TEMPLATE and by SRC. |
| +// homeScripts is every script Home serves, by stable path. Paul, |
| +// 2026-09-29: no script by default, a script only to enhance a page or |
| +// provide interaction, and always prefer none. So any page may load one of |
| +// these — they are all progressive enhancements over a page that works |
| +// without them — but nothing else, and never inline. |
| // |
| -// Naming the screens is not enough: an allowlist of screens would admit |
| -// any script at all on them, including an inline one. So it names the |
| -// exact files too. |
| -// |
| -// Paths, not fingerprints. rastrillo's asset helper fingerprints |
| -// (/static/passkey.<hash>.js), and a fingerprinted path would never |
| -// match this list — the carve-out screens would fail their own sweep. |
| -// These two are routed on stable paths for exactly that reason: they are |
| -// the only assets that have to be nameable in a security rule, and a |
| -// rule that cannot name its subject is not a rule. |
| -var scriptAllowed = map[string][]string{ |
| - "assertion.html": {"/static/assertion.js"}, |
| - "passkey_confirm.html": {"/static/passkey.js"}, |
| - "passkey_enrol.html": {"/static/passkey.js"}, |
| +// Paths, not fingerprints: a template writes {{v "/static/x.js"}} (a |
| +// content-versioned URL), which the sweep reads back to the bare path. |
| +var homeScripts = []string{ |
| + "/static/assertion.js", // the handoff's auto-submit; Continue stays for script-off |
| + "/static/passkey.js", // the passkey ceremonies: webauthn cannot be done without script |
| + "/static/busy.js", // estilo's busy rule: spinner and double-submit guard |
| } |
| +// templateSrc unwraps {{v "PATH"}} to PATH, so the sweep checks the file, |
| +// not the helper call that versions it. |
| +var templateSrc = regexp.MustCompile(`^\{\{\s*v\s+"([^"]+)"\s*\}\}$`) |
| + |
| // scriptTag matches a complete <script ...>...</script>. |
| // |
| // The closing pattern tolerates whitespace — `</script >` is valid HTML |
| @@ -144,7 +144,10 @@ func sampleData(name string) []any { |
| return []any{common, withError} |
| } |
| -// TestNoScriptTagOutsideTheAllowlist is §11, made mechanical: every |
| +// TestNoScriptTagOutsideTheAllowlist is the script rule, made |
| +// mechanical (it once also named the only screens allowed any script; the |
| +// rule is now no script by default and prefer none, so it names the |
| +// files instead). Historically: every |
| // screen works with scripts off, and ruling 21 carves out only the |
| // passkey screens. |
| // |
| @@ -184,19 +187,16 @@ func TestNoScriptTagOutsideTheAllowlist(t *testing.T) { |
| if len(srcs) == 0 { |
| continue // no script at all: the common case |
| } |
| - permitted, ok := scriptAllowed[name] |
| - if !ok { |
| - t.Errorf("%s carries a <script>; §11 says every screen works with scripts "+ |
| - "off and the allowlist names only passkey screens and the approved assertion handoff", name) |
| - continue |
| - } |
| for _, srcAttr := range srcs { |
| + if m := templateSrc.FindStringSubmatch(srcAttr); m != nil { |
| + srcAttr = m[1] |
| + } |
| switch { |
| case srcAttr == "": |
| - t.Errorf("%s has an INLINE <script>; the carve-out is for static "+ |
| - "same-origin files, and the default CSP forbids inline anyway", name) |
| - case !slices.Contains(permitted, srcAttr): |
| - t.Errorf("%s loads %q, which is not in ruling 21's allowlist", name, srcAttr) |
| + t.Errorf("%s has an INLINE <script>; scripts are static same-origin "+ |
| + "files, and the default CSP forbids inline anyway", name) |
| + case !slices.Contains(homeScripts, srcAttr): |
| + t.Errorf("%s loads %q, which is not one of Home's scripts (homeScripts)", name, srcAttr) |
| } |
| } |
| } |
| @@ -466,3 +466,14 @@ func TestNoHandlerSetsItsOwnCSP(t *testing.T) { |
| t.Fatal("scanned no files; the guard would pass by not looking") |
| } |
| } |
| + |
| +// busyScript is the one script every non-hop page carries: estilo's busy |
| +// rule, a progressive enhancement over forms that work without it. |
| +var busyScript = regexp.MustCompile(`<script defer src=["']/static/busy\.js\?v=[0-9a-f]{16}["']></script>`) |
| + |
| +// hasOtherScript reports whether a rendered page carries any script |
| +// beyond the busy rule — what "no script" means for a page since the |
| +// busy rule arrived. |
| +func hasOtherScript(body string) bool { |
| + return strings.Contains(busyScript.ReplaceAllString(body, ""), "<script") |
| +} |
diff --git a/internal/home/origins_test.go b/internal/home/origins_test.go| index a36335a..7563a87 100644 |
| --- a/internal/home/origins_test.go |
| +++ b/internal/home/origins_test.go |
| @@ -21,6 +21,7 @@ var sharedPublicPaths = []string{ |
| "/static/brand/fichas.svg", "/static/brand/mail.svg", "/static/brand/files.svg", |
| // Calma: Onest, the sky and its stylesheet, and Home's rules for placing it. |
| "/static/estilo/fonts/v1/*", "/static/estilo/sky/v1/*", "/static/estilo/sky.css", "/static/calma.css", |
| + "/static/busy.css", "/static/busy.js", |
| "/static/site/home.css", "/static/site/apps.css", |
| } |
diff --git a/internal/home/templates/layout.html b/internal/home/templates/layout.html| index d536c5d..273382f 100644 |
| --- a/internal/home/templates/layout.html |
| +++ b/internal/home/templates/layout.html |
| @@ -11,6 +11,7 @@ |
| <link rel="stylesheet" href="{{fontsCSS}}"> |
| <link rel="stylesheet" href="{{v "/static/tokens.css"}}"> |
| <link rel="stylesheet" href="{{v "/static/theme.css"}}"> |
| +<link rel="stylesheet" href="{{v "/static/busy.css"}}"> |
| <link rel="stylesheet" href="{{v "/static/estilo/sky.css"}}"> |
| <link rel="stylesheet" href="{{v "/static/calma.css"}}"> |
| {{block "head" .}}{{end}} |
| @@ -24,6 +25,7 @@ |
| {{template "content" .}} |
| </main> |
| </div> |
| +{{if not hop}}<script defer src='{{v "/static/busy.js"}}'></script>{{end}} |
| {{block "scripts" .}}{{end}} |
| </body> |
| </html> |