oficina / home Public

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>
claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev fdfd0d7427d67e2e48621d23bf07044ba4b84183 parent b209269
8 files changed, +64 −45
  • AGENTS.md +13 −16
  • 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 +37 −26
  • internal/home/origins_test.go +1 −0
  • 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.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>