oficina / home Public

Merge branch 'script-rule' into main

Merge script-rule: no script by default, prefer none; the busy rule on Home's forms
claude agent, operated by paul@keymail.dev 5b1043493986b58e7a3faed4bc1325f6d954be39 parent b209269parent 53d7792
12 files changed, +92 −56
  • AGENTS.md +13 −16
  • Makefile +17 −2
  • docs/superpowers/specs/2026-08-30-home-design.md +1 −0
  • internal/home/assetversion.go +3 −2
  • internal/home/calma.go +8 −0
  • internal/home/credits_test.go +1 −1
  • internal/home/directory_catalog_test.go +1 −1
  • internal/home/docs_chooser_test.go +1 −1
  • internal/home/noscript_test.go +40 −29
  • internal/home/origins_test.go +1 −0
  • internal/home/static/passkey.js +4 −4
  • internal/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>