Merge branch 'script-rule' into main
Merge script-rule: no script by default, prefer none; the busy rule on Home's forms
12 files changed,
+92
−56
AGENTS.md+13 −16Makefile+17 −2docs/superpowers/specs/2026-08-30-home-design.md+1 −0internal/home/assetversion.go+3 −2internal/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+40 −29internal/home/origins_test.go+1 −0internal/home/static/passkey.js+4 −4internal/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.601s ? amadan.net/oficina/home/cmd/oficina [no test files] ok amadan.net/oficina/home/internal/guardstest 0.037s ok amadan.net/oficina/home/internal/home 13.416s ok amadan.net/oficina/home/internal/home/directorydata 1.045s ok amadan.net/oficina/home/internal/home/logtest 0.007s ok amadan.net/oficina/home/internal/home/seal 0.004s ok amadan.net/oficina/home/internal/home/sealtest 0.006s ok amadan.net/oficina/home/internal/operator 6.021s ok amadan.net/oficina/home/internal/operatorlaunch 4.116s ok amadan.net/oficina/home/internal/provisioner 44.044s cd handoff && CGO_ENABLED=0 go test -count=1 ./... ok amadan.net/oficina/home/handoff 0.553s ok amadan.net/oficina/home/handoff/seal 0.007s cd roster && CGO_ENABLED=0 go test -count=1 ./... ok amadan.net/oficina/home/roster 5.329s cd connect && CGO_ENABLED=0 go test -count=1 ./... ok amadan.net/oficina/home/connect 0.068s ok amadan.net/oficina/home/connect/linkstore 0.181s === race === go test -race -count=1 ./internal/home/ ./internal/operator/ ./internal/operatorlaunch/ ./internal/provisioner/ ./internal/home/directorydata/ ok amadan.net/oficina/home/internal/home 109.807s ok amadan.net/oficina/home/internal/operator 66.680s ok amadan.net/oficina/home/internal/operatorlaunch 22.404s ok amadan.net/oficina/home/internal/provisioner 322.470s ok amadan.net/oficina/home/internal/home/directorydata 15.860s cd handoff && CGO_ENABLED=1 go test -race -count=1 ./... ok amadan.net/oficina/home/handoff 9.322s ok amadan.net/oficina/home/handoff/seal 1.029s cd roster && CGO_ENABLED=1 go test -race -count=1 ./... ok amadan.net/oficina/home/roster 7.316s cd connect && CGO_ENABLED=1 go test -race -count=1 ./... ok amadan.net/oficina/home/connect 1.648s ok amadan.net/oficina/home/connect/linkstore 3.900s
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/Makefile b/Makefile| index 325fa46..2f2a794 100644 |
| --- a/Makefile |
| +++ b/Makefile |
| @@ -4,7 +4,7 @@ export GOPRIVATE := $(GOPRIVATE),amadan.net/oficina/estilo.git |
| private_comma := , |
| export GOPRIVATE := $(if $(GOPRIVATE),$(GOPRIVATE)$(private_comma))amadan.net/oficina/estilo.git,amadan.net/carlos/platform.git |
| -.PHONY: build release test test-vet test-fmt test-build test-go test-race |
| +.PHONY: build release deps test test-vet test-fmt test-build test-go test-race |
| # One binary, CGO_ENABLED=0, cgo-free SQLite (AGENTS.md / Deploys). Set |
| # per-target rather than exported globally, because test-race is a |
| @@ -65,7 +65,22 @@ release: |
| # `go test` next, and -race — by far the slowest step — last. |
| test: test-vet test-fmt test-build test-go test-race |
| -test-vet: |
| +# The modules first, one directory at a time and retried. The hub |
| +# rate-limits public git reads per address (5 a second, a burst of 10), |
| +# and a cold module cache fetches estilo, rastrillo, the platform and |
| +# this repo at once — more than the burst — so vet failed every run |
| +# with HTTP 429 however long the runner waited between runs. What one |
| +# attempt fetched stays cached, so each retry asks for less. |
| +deps: |
| + @for dir in . handoff roster connect; do \ |
| + n=0; until (cd $$dir && go mod download); do \ |
| + n=$$((n+1)); [ $$n -lt 6 ] || exit 1; \ |
| + echo "go mod download in $$dir failed (the hub's git rate limit?); retrying in $$((n*5))s"; \ |
| + sleep $$((n*5)); \ |
| + done; \ |
| + done |
| + |
| +test-vet: deps |
| CGO_ENABLED=0 go vet ./... |
| cd handoff && CGO_ENABLED=0 go vet ./... |
| cd roster && CGO_ENABLED=0 go vet ./... |
diff --git a/docs/superpowers/specs/2026-08-30-home-design.md b/docs/superpowers/specs/2026-08-30-home-design.md| index 268b384..429edb4 100644 |
| --- a/docs/superpowers/specs/2026-08-30-home-design.md |
| +++ b/docs/superpowers/specs/2026-08-30-home-design.md |
| @@ -1489,6 +1489,7 @@ The suite sharing doctrine (`oficina/suite`) binds this spec. Read against |
| | 12 | Membership is its own record, written only by an owner. This is ruling 23. | |
| | 13 | A compromised home is three compromises, conceded separately — see below. | |
| | 14 | Custody is bounded at acquisition and retired by the feed. | |
| +| 25 | **Supersedes 21 and 24: no script by default; script only to progressively enhance a page or provide interaction HTML and CSS cannot, and always prefer none.** Every screen works with scripts off; scripts are static same-origin files, never inline, and the no-`<script>` sweep names Home's script files (a closed list) rather than screens — §11 | A closed list of screens allowed script, which the busy rule on every form outgrew | Paul, 2026-09-29, verbatim: "the default [is] no script, but scripts can be added to progressively enhance and provide interaction, but always prefer no JavaScript" | |
| Calls 7 and 8 are added to §9. Call 7 is the activity pull, and it waits on |
| the identity layer (§13 step 10) because it carries a real assertion. Call 8 |
diff --git a/internal/home/assetversion.go b/internal/home/assetversion.go| index bcbc7cd..99feb6a 100644 |
| --- a/internal/home/assetversion.go |
| +++ b/internal/home/assetversion.go |
| @@ -16,8 +16,9 @@ import ( |
| // else, so a returning visitor fetches only the page, and a deploy still |
| // takes effect at once: new bytes, new version, new URL. |
| // |
| -// Scripts are left at their bare paths: the scriptless sweep's allowlist |
| -// names them there (passkey.go), and they are revalidated by ETag. |
| +// Scripts are linked the same way where a template can (busy.js); the |
| +// passkey and handoff scripts keep bare paths their own modules import, |
| +// revalidated by ETag. noscript_test.go's closed list names them all. |
| // contentVersion is the first 16 hex digits of the body's SHA-256. |
| func contentVersion(body []byte) string { |
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..e6b3e80 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,8 +144,11 @@ func sampleData(name string) []any { |
| return []any{common, withError} |
| } |
| -// TestNoScriptTagOutsideTheAllowlist is §11, made mechanical: every |
| -// screen works with scripts off, and ruling 21 carves out only the |
| +// 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 once carved out only the |
| // passkey screens. |
| // |
| // Two stated limits, so nobody reads more into a green run than is |
| @@ -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) |
| } |
| } |
| } |
| @@ -263,7 +263,7 @@ func TestServedCSPAllowsNoInlineScript(t *testing.T) { |
| // register endpoints). None was stopped by csrf; the Origin header |
| // below sees to that. So the sweep reached eighteen of |
| // twenty-three, and one of the five it missed renders the enrolment |
| - // screen, which is one of the two ruling 21 lets load script. |
| + // screen, which is one of the pages that loads a script to enhance it. |
| // |
| // So: a fresh session cookie as well. With it, all twenty-three |
| // targets reach and run their handler — probe-verified rather than |
| @@ -324,7 +324,7 @@ func TestServedCSPAllowsNoInlineScript(t *testing.T) { |
| d := strings.TrimSpace(directive) |
| name, _, _ := strings.Cut(d, " ") |
| // Inline STYLES are allowed on purpose: ui's partials set |
| - // style attributes. Inline SCRIPT is what §11 and ruling 21 |
| + // style attributes. Inline SCRIPT is what §11 and the script rule |
| // are about. |
| if name == "style-src" { |
| continue |
| @@ -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/static/passkey.js b/internal/home/static/passkey.js| index 6598320..74b3fc5 100644 |
| --- a/internal/home/static/passkey.js |
| +++ b/internal/home/static/passkey.js |
| @@ -4,10 +4,10 @@ |
| // the server says to go. |
| // |
| // It is a static, same-origin module, not an inline block: rastrillo's |
| -// default CSP forbids inline script, and §11's carve-out is for these |
| -// two static files and nothing else. Its path is stable and |
| -// unfingerprinted so the scriptless sweep can name it — a rule that |
| -// cannot name its subject is not a rule. |
| +// default CSP forbids inline script, and Home's scripts are a closed list |
| +// of static files (spec ruling superseding 21/24: no script by default, |
| +// enhance only, prefer none). Its path is stable so that list can name |
| +// it — a rule that cannot name its subject is not a rule. |
| import { register, authenticate, available } from "/static/webauthn.mjs"; |
| async function post(path, body) { |
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> |