oficina / memoria Public

Memoria moves to rastrillo fb806a2: links confirm before they are spent

Opening a sign-in link shows Memoria's own door with one Sign in button;
the POST spends it, so mail scanners and other browsers no longer burn
links. A refused identity now gets the door through auth's Refused hook
instead of bare text. Standalone keeps rastrillo's strict CSP; only the
native approval page's stylesheet is allowed, by hash, on that route.
Displayed times say UTC. Reviewed by GPT-6-Astra via Codex (#27).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Squashed from chore/rastrillo-confirm into main.
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 81f7fe94e3b2ac6e8fd5b16575b71495c5c9ee36 parent d7777f7
34 files changed, +2078 −138
  • AGENTS.md +14 −1
  • Makefile +4 −2
  • cmd/memoria/main.go +4 −6
  • go.mod +3 −1
  • go.sum +6 −2
  • internal/memoria/app.go +12 −2
  • internal/memoria/auth.go +31 −9
  • internal/memoria/chrome_test.go +1 −1
  • internal/memoria/credits/notices.json +12 −1
  • internal/memoria/handlers.go +71 −7
  • internal/memoria/handlers_connector.go +3 −3
  • internal/memoria/handlers_sources.go +1 −1
  • internal/memoria/home_confirm_test.go +128 −0
  • internal/memoria/home_router_test.go +52 −2
  • internal/memoria/home_security_test.go +19 −0
  • internal/memoria/native.go +54 −0
  • internal/memoria/native_exchange_test.go +2 −2
  • internal/memoria/render.go +18 −0
  • internal/memoria/serving.go +42 −0
  • internal/memoria/stamp_test.go +34 −0
  • internal/memoria/static/tokens.css +653 −65
  • internal/memoria/store.go +1 −1
  • internal/memoria/templates/signin.html +16 −1
  • internal/memoriatest/admission_test.go +41 −12
  • internal/memoriatest/chrome_client_test.go +59 −1
  • internal/memoriatest/chrome_test.go +1 −1
  • internal/memoriatest/csp_browser_test.go +234 −0
  • internal/memoriatest/errors_gate_test.go +1 −1
  • internal/memoriatest/native_csp_test.go +101 −0
  • internal/memoriatest/routes_test.go +7 −1
  • internal/memoriatest/signin_confirm_test.go +325 −0
  • internal/memoriatest/signin_keymail_test.go +124 −11
  • internal/memoriatest/signindial_test.go +1 −1
  • internal/memoriatest/signinoutcome_test.go +3 −3

CI passed — run details

CI log
=== vet ===
go vet ./...
go vet -tags browser ./internal/memoriatest/
=== fmt ===
=== test ===
go test ./...
?   	amadan.net/oficina/memoria/cmd/memoria	[no test files]
ok  	amadan.net/oficina/memoria/internal/memoria	4.172s
ok  	amadan.net/oficina/memoria/internal/memoria/chunk	0.004s
ok  	amadan.net/oficina/memoria/internal/memoria/fact	0.578s
?   	amadan.net/oficina/memoria/internal/memoria/icons	[no test files]
ok  	amadan.net/oficina/memoria/internal/memoria/index	0.036s
ok  	amadan.net/oficina/memoria/internal/memoria/notion	0.020s
ok  	amadan.net/oficina/memoria/internal/memoria/provider	1.130s
ok  	amadan.net/oficina/memoria/internal/memoria/query	0.002s
ok  	amadan.net/oficina/memoria/internal/memoria/seal	0.002s
ok  	amadan.net/oficina/memoria/internal/memoria/sibling	3.036s
ok  	amadan.net/oficina/memoria/internal/memoriatest	2.356s
?   	amadan.net/oficina/memoria/scripts/writeestilo	[no test files]
?   	amadan.net/oficina/memoria/tools/bench	[no test files]
?   	amadan.net/oficina/memoria/tools/drive	[no test files]
?   	amadan.net/oficina/memoria/tools/seed	[no test files]
?   	amadan.net/oficina/memoria/tools/vendorassets	[no test files]
=== migration-check ===
go tool rastrillo migration check
rastrillo migration check: models and migrations agree
diff --git a/AGENTS.md b/AGENTS.md
index 5b81084..4cd8267 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -188,7 +188,20 @@ so; it never renders as a confident zero.
overwrites rastrillo.js with the old shim (the old busy rule inside,
which fights estilo's busy.js) and adds select.js, datetime.js and
calendar.js, which no page links. `TestBusyFilesAreEstilos` and
- `TestNoUnusedRastrilloScriptsOnDisk` fail until both are done.
+ `TestNoUnusedRastrilloScriptsOnDisk` fail until both are done. **At
+ fb806a2 --fix refuses outright** while go.mod still requires the old
+ path (native.go's handle above): copy `ui/tokens.css` from the pinned
+ module by hand, and `TestVendoredAssetsMatchTheLibrary` says when.
+- **The CSP is rastrillo's default, which refuses inline style
+ attributes** (fb806a2). Nothing Memoria serves standalone carries one;
+ `TestNoPageCarriesAStyleAttribute` reads every page's markup and `make
+ browser`'s `TestPagesRenderUnderTheProductionCSP` watches for
+ violations in Chromium. Draw with attributes and memoria.css, never
+ `style=`; `memoria.ServeOptions` says why there is no policy of our own.
+ The one exception is the native kit's approval page, whose inline
+ `<style>` is allowed by hash on `/native/authorize` alone;
+ `TestNativeApprovalStyleIsTheHashedOne` re-hashes it, so a native bump
+ that changes its CSS fails CI rather than shipping it unstyled.
- **A field styled with `font: inherit` under a selector heavier than a
bare `input`** loses tokens.css's 16px-on-touch rule, and iOS zooms on
tap. Restate `font-size: max(1rem, 1em)` for it under `(pointer:
diff --git a/Makefile b/Makefile
index 35bfb6b..8a69a01 100644
--- a/Makefile
+++ b/Makefile
@@ -114,11 +114,13 @@ ci: vet fmt-check test migration-check
# Chromium through chromedp. Deliberately NOT part of `ci`: the gate
# every push runs must not need a browser. Run it before shipping a
# change to the CSS or to rastrillo's pin; TestNoZoomOnTap is the iOS
-# zoom-on-tap check. The Chromium comes from RASTRILLO_CHROME, or on a
+# zoom-on-tap check, and TestPagesRenderUnderTheProductionCSP is the one
+# browser check served through rastrillo's production stack, so it sees
+# the real CSP. The Chromium comes from RASTRILLO_CHROME, or on a
# Mac from the Playwright cache.
RASTRILLO_CHROME ?= $(firstword $(wildcard $(HOME)/Library/Caches/ms-playwright/chromium-*/chrome-mac*/Chromium.app/Contents/MacOS/Chromium))
browser:
- RASTRILLO_CHROME="$(RASTRILLO_CHROME)" CGO_ENABLED=0 go test -tags browser -count=1 -run 'OnTap' ./internal/memoriatest/
+ RASTRILLO_CHROME="$(RASTRILLO_CHROME)" CGO_ENABLED=0 go test -tags browser -count=1 -run 'OnTap|ProductionCSP' ./internal/memoriatest/
.PHONY: credits
credits:
diff --git a/cmd/memoria/main.go b/cmd/memoria/main.go
index 3ed8ce1..c5423d0 100644
--- a/cmd/memoria/main.go
+++ b/cmd/memoria/main.go
@@ -79,7 +79,7 @@ func main() {
Owners: cfg.Owners,
Mailer: mail.FromEnv("CARLOS", logger),
HomeEnabled: cfg.Home != nil,
- }, logger, app.Admit)
+ }, logger, app)
if err != nil {
logger.Error("wire sign-in", "err", err)
os.Exit(1)
@@ -119,11 +119,9 @@ func main() {
os.Exit(1)
}
- opts.Mux = mux
- // rastrillo.Serve's own panic recovery, for a panic outside the router:
- // the same 500 page, with the viewer's chrome, as the router's
- // recoverToErrorPage renders (shared chrome §3.3).
- opts.ErrorPage = app.ErrorPage
+ // The router, the error page, and rastrillo's default CSP
+ // (memoria.ServeOptions says why there is no policy of our own).
+ opts = app.ServeOptions(opts, mux)
opts.DBPath = ""
if err := rastrillo.Serve(opts); err != nil {
logger.Error("serve failed", "err", err)
diff --git a/go.mod b/go.mod
index d8255fc..ac69207 100644
--- a/go.mod
+++ b/go.mod
@@ -22,11 +22,12 @@ require (
)
require (
- amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd
+ amadan.net/rastrillo/rastrillo v0.27.1-0.20261006204347-fb806a287060
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
)
require (
+ amadan.net/rastrillo/rastrillo/money v0.1.0 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
@@ -39,6 +40,7 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ golang.org/x/mod v0.38.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
gorm.io/plugin/dbresolver v1.6.2 // indirect
diff --git a/go.sum b/go.sum
index 281d8a3..55f5156 100644
--- a/go.sum
+++ b/go.sum
@@ -8,8 +8,10 @@ amadan.net/oficina/suite v0.0.0-20260905215529-60b01a8a0d6d h1:x6Bq9+QzpFYAyV+zR
amadan.net/oficina/suite v0.0.0-20260905215529-60b01a8a0d6d/go.mod h1:Xgw0N+DBiDzkhQHm/nIhgumfaiMElDg/KIFD1OxTm5w=
amadan.net/rastrillo/native/server v0.0.0-20260923173546-2a5d07b2c8d3 h1:5EFx7BLJj/+G/rt8bTmeyMWVfF5s5Sns7ajTvX4w/I8=
amadan.net/rastrillo/native/server v0.0.0-20260923173546-2a5d07b2c8d3/go.mod h1:ZaaxeE278dJltdrLwmwpxUjQ5wuKzqDAVSNRet+WupY=
-amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd h1:jlFSt95M1ontOg+VePU+kIJpChtWqDQAbnmlxqCnbgc=
-amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE=
+amadan.net/rastrillo/rastrillo v0.27.1-0.20261006204347-fb806a287060 h1:K2PXcCKfm+AYrqgH1ea6cNsFs4sN7MKLvq+uBY11NZE=
+amadan.net/rastrillo/rastrillo v0.27.1-0.20261006204347-fb806a287060/go.mod h1:WmC8TAIfqjAkSwmD+FCTqr7VuqAO5FaX03HlV49K608=
+amadan.net/rastrillo/rastrillo/money v0.1.0 h1:MxU4oqNrW8abxRTkgZBv24OYNpFnvonajNqeLIzmmaM=
+amadan.net/rastrillo/rastrillo/money v0.1.0/go.mod h1:cmgkjBEIQh1pFSYvQk0UU6oPYS8kSDypdT9+t4Flx0U=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/carlosframework/rastrillo v0.23.0 h1:tKAAlwPt4Pg/rkqk7o6jvV12bbZ0r34oEVpuOlKfX7w=
@@ -58,6 +60,8 @@ github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhA
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
+github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
diff --git a/internal/memoria/app.go b/internal/memoria/app.go
index 319b93b..370732f 100644
--- a/internal/memoria/app.go
+++ b/internal/memoria/app.go
@@ -95,8 +95,10 @@ func App(d *db.DB, origin string, sealer *seal.Sealer, logger *slog.Logger) (*ht
r.Post("/native/request", a.native.ServeHTTP)
r.Post("/native/exchange", a.native.ServeHTTP)
r.Get("/native/start", a.native.ServeHTTP)
- r.Get("/native/authorize", a.native.ServeHTTP)
- r.Post("/native/authorize", a.native.ServeHTTP)
+ // The approval page is the native kit's, styled by one inline
+ // <style>: allowed by its hash on this route alone (native.go).
+ r.Get("/native/authorize", nativeApprovalCSP(a.native.ServeHTTP))
+ r.Post("/native/authorize", nativeApprovalCSP(a.native.ServeHTTP))
// Appearance (estilo chrome §8.3): inside the app-wide CSRF check
// (homeMiddleware) and OUTSIDE the session gate, so a session that
@@ -203,9 +205,16 @@ func (a *app) MountAuth(mux *http.ServeMux, origin string) {
// address's domain before Authorize runs. beginGuard says why. And
// continued, not redirected: signinBegin says why.
r.Post("/auth/begin", a.signinBegin(origin, a.auth.Begin))
+ // Verify answers GET with the confirm door (renderConfirm), which
+ // spends nothing, and POST with the redemption, so it is mounted for
+ // both methods in both modes: without the POST the door's Sign in
+ // button gets a 405 and nobody signs in. The path is baked into every
+ // emailed link. A verified address that is not admitted is answered
+ // by auth's Refused hook (app.refused), inside Callback and Verify.
if a.home == nil {
r.Get("/auth/callback", a.auth.Callback)
r.Get("/auth/verify", a.auth.Verify)
+ r.Post("/auth/verify", a.auth.Verify)
// Where a verified link lands (NewAuth's SignedInPath), and on to
// the page the person was heading for (return.go).
r.Get("/auth/landed", a.landed)
@@ -213,6 +222,7 @@ func (a *app) MountAuth(mux *http.ServeMux, origin string) {
} else {
r.Get("/auth/callback", a.home.routes.LocalGuard(a.auth.Callback))
r.Get("/auth/verify", a.home.routes.LocalGuard(a.auth.Verify))
+ r.Post("/auth/verify", a.home.routes.LocalGuard(a.auth.Verify))
// Chains to id.*'s /signout (a 307), which ends the Oficina
// session too and returns the browser to /signed-out (§9.5).
r.Post("/signout", a.home.routes.Signout)
diff --git a/internal/memoria/auth.go b/internal/memoria/auth.go
index 194242c..3b96656 100644
--- a/internal/memoria/auth.go
+++ b/internal/memoria/auth.go
@@ -194,8 +194,15 @@ func (a *app) Admit(address string) bool {
// NewAuth wires the sign-in flow. Exactly one per process: auth carries
// rate-limiter state on the value, so one per request would get a fresh
// empty budget every time and rate-limit nothing, silently.
-func NewAuth(sqlDB *sql.DB, env AuthEnv, logger *slog.Logger, authorize func(string) bool) (*auth.Auth, error) {
- if authorize == nil {
+//
+// a supplies the three things auth asks of the app at redemption: the
+// admission gate (app.Admit), the door a verified but refused address is
+// shown (app.refused), and the door an emailed link opens on
+// (app.renderConfirm). The app rather than a predicate, for the reason
+// Admit gives: there is one of each, so there is nothing to pass a second
+// version of.
+func NewAuth(sqlDB *sql.DB, env AuthEnv, logger *slog.Logger, a *app) (*auth.Auth, error) {
+ if a == nil {
// A nil Authorize admits every verified address, which on a
// public hostname means anyone who can receive mail. Refusing
// beats defaulting, because a caller that forgot to pass one has
@@ -208,12 +215,27 @@ func NewAuth(sqlDB *sql.DB, env AuthEnv, logger *slog.Logger, authorize func(str
if env.HomeEnabled {
landing = "/auth/local/complete"
}
- a, err := auth.New(auth.Config{
- DB: sqlDB,
- Origin: env.Origin,
- InstanceKey: env.InstanceKey,
- Mailer: env.Mailer,
- Authorize: authorize,
+ au, err := auth.New(auth.Config{
+ DB: sqlDB,
+ Origin: env.Origin,
+ InstanceKey: env.InstanceKey,
+ Mailer: env.Mailer,
+ Authorize: a.Admit,
+ // A verified address Admit refuses gets the door, with the
+ // refusal on it, in auth's own 403 (app.refused) — not
+ // rastrillo's bare-text "verified but not admitted".
+ Refused: a.refused,
+ // GET /auth/verify spends nothing: it opens the door with one
+ // Sign in button that POSTs the link back, so a mail scanner's
+ // fetch cannot spend it (app.renderConfirm).
+ RenderConfirm: a.renderConfirm,
+ // No proof-of-work on the sign-in form. Memoria never had one:
+ // beginGuard refuses every non-owner before Begin (gate 33), and
+ // rastrillo's rate budgets stand behind it. The door is estilo's
+ // form, which renders no pow challenge. ProofOff keeps today's
+ // behaviour; wiring a pow.Guard is a decision of its own
+ // (schema, assets, a form that needs JavaScript).
+ ProofOff: true,
SigninPath: signinPath,
SignedInPath: landing,
Subject: chrome.EmailSubject("Memoria"),
@@ -222,7 +244,7 @@ func NewAuth(sqlDB *sql.DB, env AuthEnv, logger *slog.Logger, authorize func(str
if err != nil {
return nil, err
}
- return a, nil
+ return au, nil
}
// signinBegin is POST /auth/begin's whole handler chain, in one place with
diff --git a/internal/memoria/chrome_test.go b/internal/memoria/chrome_test.go
index 887829d..92bb12a 100644
--- a/internal/memoria/chrome_test.go
+++ b/internal/memoria/chrome_test.go
@@ -248,7 +248,7 @@ func TestHomeLocalRetryKeepsTheReturnFromEitherEntry(t *testing.T) {
if err != nil || link == "" {
t.Fatal("no second link")
}
- dest := homeRedirect(t, f.do("GET", u.RequestURI(), ""))
+ dest := homeRedirect(t, f.openLink(t, u.RequestURI()))
for i := 0; i < 3 && strings.HasPrefix(dest, "/auth/"); i++ {
dest = homeRedirect(t, f.do("GET", dest, ""))
}
diff --git a/internal/memoria/credits/notices.json b/internal/memoria/credits/notices.json
index 751ad94..683e00f 100644
--- a/internal/memoria/credits/notices.json
+++ b/internal/memoria/credits/notices.json
@@ -35,7 +35,7 @@
},
{
"Name": "amadan.net/rastrillo/rastrillo",
- "Version": "v0.26.1-0.20260908075707-29e4491631bd",
+ "Version": "v0.27.1-0.20261006204347-fb806a287060",
"SourceURL": "https://amadan.net/rastrillo/rastrillo",
"Notices": [
{
@@ -48,6 +48,17 @@
}
]
},
+ {
+ "Name": "amadan.net/rastrillo/rastrillo/money",
+ "Version": "v0.1.0",
+ "SourceURL": "https://amadan.net/rastrillo/rastrillo",
+ "Notices": [
+ {
+ "Name": "LICENSE",
+ "Text": "Mozilla Public License Version 2.0\n==================================\n\n1. Definitions\n--------------\n\n1.1. \"Contributor\"\n means each individual or legal entity that creates, contributes to\n the creation of, or owns Covered Software.\n\n1.2. \"Contributor Version\"\n means the combination of the Contributions of others (if any) used\n by a Contributor and that particular Contributor's Contribution.\n\n1.3. \"Contribution\"\n means Covered Software of a particular Contributor.\n\n1.4. \"Covered Software\"\n means Source Code Form to which the initial Contributor has attached\n the notice in Exhibit A, the Executable Form of such Source Code\n Form, and Modifications of such Source Code Form, in each case\n including portions thereof.\n\n1.5. \"Incompatible With Secondary Licenses\"\n means\n\n (a) that the initial Contributor has attached the notice described\n in Exhibit B to the Covered Software; or\n\n (b) that the Covered Software was made available under the terms of\n version 1.1 or earlier of the License, but not also under the\n terms of a Secondary License.\n\n1.6. \"Executable Form\"\n means any form of the work other than Source Code Form.\n\n1.7. \"Larger Work\"\n means a work that combines Covered Software with other material, in\n a separate file or files, that is not Covered Software.\n\n1.8. \"License\"\n means this document.\n\n1.9. \"Licensable\"\n means having the right to grant, to the maximum extent possible,\n whether at the time of the initial grant or subsequently, any and\n all of the rights conveyed by this License.\n\n1.10. \"Modifications\"\n means any of the following:\n\n (a) any file in Source Code Form that results from an addition to,\n deletion from, or modification of the contents of Covered\n Software; or\n\n (b) any new file in Source Code Form that contains any Covered\n Software.\n\n1.11. \"Patent Claims\" of a Contributor\n means any patent claim(s), including without limitation, method,\n process, and apparatus claims, in any patent Licensable by such\n Contributor that would be infringed, but for the grant of the\n License, by the making, using, selling, offering for sale, having\n made, import, or transfer of either its Contributions or its\n Contributor Version.\n\n1.12. \"Secondary License\"\n means either the GNU General Public License, Version 2.0, the GNU\n Lesser General Public License, Version 2.1, the GNU Affero General\n Public License, Version 3.0, or any later versions of those\n licenses.\n\n1.13. \"Source Code Form\"\n means the form of the work preferred for making modifications.\n\n1.14. \"You\" (or \"Your\")\n means an individual or a legal entity exercising rights under this\n License. For legal entities, \"You\" includes any entity that\n controls, is controlled by, or is under common control with You. For\n purposes of this definition, \"control\" means (a) the power, direct\n or indirect, to cause the direction or management of such entity,\n whether by contract or otherwise, or (b) ownership of more than\n fifty percent (50%) of the outstanding shares or beneficial\n ownership of such entity.\n\n2. License Grants and Conditions\n--------------------------------\n\n2.1. Grants\n\nEach Contributor hereby grants You a world-wide, royalty-free,\nnon-exclusive license:\n\n(a) under intellectual property rights (other than patent or trademark)\n Licensable by such Contributor to use, reproduce, make available,\n modify, display, perform, distribute, and otherwise exploit its\n Contributions, either on an unmodified basis, with Modifications, or\n as part of a Larger Work; and\n\n(b) under Patent Claims of such Contributor to make, use, sell, offer\n for sale, have made, import, and otherwise transfer either its\n Contributions or its Contributor Version.\n\n2.2. Effective Date\n\nThe licenses granted in Section 2.1 with respect to any Contribution\nbecome effective for each Contribution on the date the Contributor first\ndistributes such Contribution.\n\n2.3. Limitations on Grant Scope\n\nThe licenses granted in this Section 2 are the only rights granted under\nthis License. No additional rights or licenses will be implied from the\ndistribution or licensing of Covered Software under this License.\nNotwithstanding Section 2.1(b) above, no patent license is granted by a\nContributor:\n\n(a) for any code that a Contributor has removed from Covered Software;\n or\n\n(b) for infringements caused by: (i) Your and any other third party's\n modifications of Covered Software, or (ii) the combination of its\n Contributions with other software (except as part of its Contributor\n Version); or\n\n(c) under Patent Claims infringed by Covered Software in the absence of\n its Contributions.\n\nThis License does not grant any rights in the trademarks, service marks,\nor logos of any Contributor (except as may be necessary to comply with\nthe notice requirements in Section 3.4).\n\n2.4. Subsequent Licenses\n\nNo Contributor makes additional grants as a result of Your choice to\ndistribute the Covered Software under a subsequent version of this\nLicense (see Section 10.2) or under the terms of a Secondary License (if\npermitted under the terms of Section 3.3).\n\n2.5. Representation\n\nEach Contributor represents that the Contributor believes its\nContributions are its original creation(s) or it has sufficient rights\nto grant the rights to its Contributions conveyed by this License.\n\n2.6. Fair Use\n\nThis License is not intended to limit any rights You have under\napplicable copyright doctrines of fair use, fair dealing, or other\nequivalents.\n\n2.7. Conditions\n\nSections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted\nin Section 2.1.\n\n3. Responsibilities\n-------------------\n\n3.1. Distribution of Source Form\n\nAll distribution of Covered Software in Source Code Form, including any\nModifications that You create or to which You contribute, must be under\nthe terms of this License. You must inform recipients that the Source\nCode Form of the Covered Software is governed by the terms of this\nLicense, and how they can obtain a copy of this License. You may not\nattempt to alter or restrict the recipients' rights in the Source Code\nForm.\n\n3.2. Distribution of Executable Form\n\nIf You distribute Covered Software in Executable Form then:\n\n(a) such Covered Software must also be made available in Source Code\n Form, as described in Section 3.1, and You must inform recipients of\n the Executable Form how they can obtain a copy of such Source Code\n Form by reasonable means in a timely manner, at a charge no more\n than the cost of distribution to the recipient; and\n\n(b) You may distribute such Executable Form under the terms of this\n License, or sublicense it under different terms, provided that the\n license for the Executable Form does not attempt to limit or alter\n the recipients' rights in the Source Code Form under this License.\n\n3.3. Distribution of a Larger Work\n\nYou may create and distribute a Larger Work under terms of Your choice,\nprovided that You also comply with the requirements of this License for\nthe Covered Software. If the Larger Work is a combination of Covered\nSoftware with a work governed by one or more Secondary Licenses, and the\nCovered Software is not Incompatible With Secondary Licenses, this\nLicense permits You to additionally distribute such Covered Software\nunder the terms of such Secondary License(s), so that the recipient of\nthe Larger Work may, at their option, further distribute the Covered\nSoftware under the terms of either this License or such Secondary\nLicense(s).\n\n3.4. Notices\n\nYou may not remove or alter the substance of any license notices\n(including copyright notices, patent notices, disclaimers of warranty,\nor limitations of liability) contained within the Source Code Form of\nthe Covered Software, except that You may alter any license notices to\nthe extent required to remedy known factual inaccuracies.\n\n3.5. Application of Additional Terms\n\nYou may choose to offer, and to charge a fee for, warranty, support,\nindemnity or liability obligations to one or more recipients of Covered\nSoftware. However, You may do so only on Your own behalf, and not on\nbehalf of any Contributor. You must make it absolutely clear that any\nsuch warranty, support, indemnity, or liability obligation is offered by\nYou alone, and You hereby agree to indemnify every Contributor for any\nliability incurred by such Contributor as a result of warranty, support,\nindemnity or liability terms You offer. You may include additional\ndisclaimers of warranty and limitations of liability specific to any\njurisdiction.\n\n4. Inability to Comply Due to Statute or Regulation\n---------------------------------------------------\n\nIf it is impossible for You to comply with any of the terms of this\nLicense with respect to some or all of the Covered Software due to\nstatute, judicial order, or regulation then You must: (a) comply with\nthe terms of this License to the maximum extent possible; and (b)\ndescribe the limitations and the code they affect. Such description must\nbe placed in a text file included with all distributions of the Covered\nSoftware under this License. Except to the extent prohibited by statute\nor regulation, such description must be sufficiently detailed for a\nrecipient of ordinary skill to be able to understand it.\n\n5. Termination\n--------------\n\n5.1. The rights granted under this License will terminate automatically\nif You fail to comply with any of its terms. However, if You become\ncompliant, then the rights granted under this License from a particular\nContributor are reinstated (a) provisionally, unless and until such\nContributor explicitly and finally terminates Your grants, and (b) on an\nongoing basis, if such Contributor fails to notify You of the\nnon-compliance by some reasonable means prior to 60 days after You have\ncome back into compliance. Moreover, Your grants from a particular\nContributor are reinstated on an ongoing basis if such Contributor\nnotifies You of the non-compliance by some reasonable means, this is the\nfirst time You have received notice of non-compliance with this License\nfrom such Contributor, and You become compliant prior to 30 days after\nYour receipt of the notice.\n\n5.2. If You initiate litigation against any entity by asserting a patent\ninfringement claim (excluding declaratory judgment actions,\ncounter-claims, and cross-claims) alleging that a Contributor Version\ndirectly or indirectly infringes any patent, then the rights granted to\nYou by any and all Contributors for the Covered Software under Section\n2.1 of this License shall terminate.\n\n5.3. In the event of termination under Sections 5.1 or 5.2 above, all\nend user license agreements (excluding distributors and resellers) which\nhave been validly granted by You or Your distributors under this License\nprior to termination shall survive termination.\n\n************************************************************************\n* *\n* 6. Disclaimer of Warranty *\n* ------------------------- *\n* *\n* Covered Software is provided under this License on an \"as is\" *\n* basis, without warranty of any kind, either expressed, implied, or *\n* statutory, including, without limitation, warranties that the *\n* Covered Software is free of defects, merchantable, fit for a *\n* particular purpose or non-infringing. The entire risk as to the *\n* quality and performance of the Covered Software is with You. *\n* Should any Covered Software prove defective in any respect, You *\n* (not any Contributor) assume the cost of any necessary servicing, *\n* repair, or correction. This disclaimer of warranty constitutes an *\n* essential part of this License. No use of any Covered Software is *\n* authorized under this License except under this disclaimer. *\n* *\n************************************************************************\n\n************************************************************************\n* *\n* 7. Limitation of Liability *\n* -------------------------- *\n* *\n* Under no circumstances and under no legal theory, whether tort *\n* (including negligence), contract, or otherwise, shall any *\n* Contributor, or anyone who distributes Covered Software as *\n* permitted above, be liable to You for any direct, indirect, *\n* special, incidental, or consequential damages of any character *\n* including, without limitation, damages for lost profits, loss of *\n* goodwill, work stoppage, computer failure or malfunction, or any *\n* and all other commercial damages or losses, even if such party *\n* shall have been informed of the possibility of such damages. This *\n* limitation of liability shall not apply to liability for death or *\n* personal injury resulting from such party's negligence to the *\n* extent applicable law prohibits such limitation. Some *\n* jurisdictions do not allow the exclusion or limitation of *\n* incidental or consequential damages, so this exclusion and *\n* limitation may not apply to You. *\n* *\n************************************************************************\n\n8. Litigation\n-------------\n\nAny litigation relating to this License may be brought only in the\ncourts of a jurisdiction where the defendant maintains its principal\nplace of business and such litigation shall be governed by laws of that\njurisdiction, without reference to its conflict-of-law provisions.\nNothing in this Section shall prevent a party's ability to bring\ncross-claims or counter-claims.\n\n9. Miscellaneous\n----------------\n\nThis License represents the complete agreement concerning the subject\nmatter hereof. If any provision of this License is held to be\nunenforceable, such provision shall be reformed only to the extent\nnecessary to make it enforceable. Any law or regulation which provides\nthat the language of a contract shall be construed against the drafter\nshall not be used to construe this License against a Contributor.\n\n10. Versions of the License\n---------------------------\n\n10.1. New Versions\n\nMozilla Foundation is the license steward. Except as provided in Section\n10.3, no one other than the license steward has the right to modify or\npublish new versions of this License. Each version will be given a\ndistinguishing version number.\n\n10.2. Effect of New Versions\n\nYou may distribute the Covered Software under the terms of the version\nof the License under which You originally received the Covered Software,\nor under the terms of any subsequent version published by the license\nsteward.\n\n10.3. Modified Versions\n\nIf you create software not governed by this License, and you want to\ncreate a new license for such software, you may create and use a\nmodified version of this License if you rename the license and remove\nany references to the name of the license steward (except to note that\nsuch modified license differs from this License).\n\n10.4. Distributing Source Code Form that is Incompatible With Secondary\nLicenses\n\nIf You choose to distribute Source Code Form that is Incompatible With\nSecondary Licenses under the terms of this version of the License, the\nnotice described in Exhibit B of this License must be attached.\n\nExhibit A - Source Code Form License Notice\n-------------------------------------------\n\n This Source Code Form is subject to the terms of the Mozilla Public\n License, v. 2.0. If a copy of the MPL was not distributed with this\n file, You can obtain one at https://mozilla.org/MPL/2.0/.\n\nIf it is not possible or desirable to put the notice in a particular\nfile, then You may include the notice in a location (such as a LICENSE\nfile in a relevant directory) where a recipient would be likely to look\nfor such a notice.\n\nYou may add additional accurate notices of copyright ownership.\n\nExhibit B - \"Incompatible With Secondary Licenses\" Notice\n---------------------------------------------------------\n\n This Source Code Form is \"Incompatible With Secondary Licenses\", as\n defined by the Mozilla Public License, v. 2.0.\n"
+ }
+ ]
+ },
{
"Name": "github.com/carlosframework/rastrillo",
"Version": "v0.23.0",
diff --git a/internal/memoria/handlers.go b/internal/memoria/handlers.go
index 3f1eb80..0b53fd2 100644
--- a/internal/memoria/handlers.go
+++ b/internal/memoria/handlers.go
@@ -1161,13 +1161,80 @@ func (a *app) deleteDocument(w http.ResponseWriter, r *http.Request) {
}
// signinPage is the sign-in page: the layout's page around the suite's
-// door. Exactly one of Door, Sent and SignedOut is set — the form, the
-// check-your-email notice that replaces it, or the page sign-out lands on.
+// door. Exactly one of Door, Sent, SignedOut and Confirm is set — the
+// form, the check-your-email notice that replaces it, the page sign-out
+// lands on, or the confirm step an emailed link opens on.
type signinPage struct {
page
Door *chrome.SignIn
Sent *chrome.SignInNotice
SignedOut *chrome.SignInNotice
+ Confirm *signinConfirm
+}
+
+// signinConfirm is the confirm step's door: the chrome, and the form that
+// posts the emailed token back.
+type signinConfirm struct {
+ Door chrome.SignIn
+ Action, Token string
+}
+
+// door is the sign-in form for this instance: Memoria's mark and name on
+// its sky, posting to the guarded Begin. localSignin, refused and
+// renderConfirm all start from it, so the three cannot drift apart.
+func (a *app) door(r *http.Request) chrome.SignIn {
+ return chrome.SignIn{
+ Icon: assets.Path("static/brand/icon.svg"), App: appName, Sky: sky.For(r.Host),
+ Action: "/auth/begin",
+ OpenSource: &chrome.Link{Href: "/help/open-source", Label: "Open source"},
+ }
+}
+
+// refusedCopy is what the door says to an address that has just proved
+// itself and is not admitted here.
+const refusedCopy = "That address can’t sign in to Memoria. Use a different address, or ask whoever runs this team to add it."
+
+// refused is auth's Refused hook: Keymail or an emailed link verified the
+// address and Admit said no. It answers with the sign-in door, carrying
+// the refusal, in a 403 — no session is minted, and auth has already
+// ended the attempt. Before this hook it was rastrillo's bare-text 403,
+// "This address is verified but not admitted here.", which is no page.
+//
+// Rendered, not redirected to ?err=…: a query parameter is something
+// anybody can type, and the sentence means "you proved this address and
+// it is not admitted". Only this hook can say it, and auth calls it only
+// after the address is proved, so saying it is no owners-list oracle —
+// beginGuard's ?sent=1 sameness is about the unproved submit, and is
+// untouched.
+func (a *app) refused(w http.ResponseWriter, r *http.Request, _ auth.Identity) {
+ door := a.door(r)
+ door.Error = refusedCopy
+ w.Header().Set("Cache-Control", "no-store")
+ renderStatus(w, "signin", signinPage{
+ page: page{Frame: a.frameAs(r, viewer{}, frame{parts: []string{door.Page()}, signinPage: true}), ModelOn: a.modelOn()},
+ Door: &door,
+ }, http.StatusForbidden)
+}
+
+// renderConfirm is auth's RenderConfirm: the page an emailed link opens
+// on. GET /auth/verify spends nothing, so a mail-security scanner that
+// fetches every link in an inbound message cannot spend this one; the
+// door's single Sign in button POSTs the token back to d.Action, and that
+// POST signs in.
+//
+// It is the door with the address field taken away — the same mark, sky,
+// heading and Sign in label — so the person who clicked the link sees
+// Memoria, not rastrillo's plain English fallback. auth has already set
+// no-store, Referrer-Policy: strict-origin (the URL is a live credential)
+// and the framing refusals; nothing here replaces them. d.Token is
+// untrusted query input and reaches the page only through html/template.
+func (a *app) renderConfirm(w http.ResponseWriter, r *http.Request, d auth.ConfirmPageData) {
+ c := signinConfirm{Door: a.door(r), Action: d.Action, Token: d.Token}
+ c.Door.Lede = "Select Sign in to finish signing in."
+ render(w, "signin", signinPage{
+ page: page{Frame: a.frameAs(r, viewer{}, frame{parts: []string{c.Door.Page()}, signinPage: true}), ModelOn: a.modelOn()},
+ Confirm: &c,
+ })
}
// localSignin serves the sign-in page: GET /signin standalone, and in Home
@@ -1251,11 +1318,8 @@ func (a *app) localSignin(w http.ResponseWriter, r *http.Request) {
// The door supplies its own heading, label, help and submit; a plain
// visit carries no lede.
- door := &chrome.SignIn{
- Icon: icon, App: appName, Sky: view,
- Action: "/auth/begin",
- OpenSource: &chrome.Link{Href: "/help/open-source", Label: "Open source"},
- }
+ d := a.door(r)
+ door := &d
if hasReturn {
door.Return = ret
}
diff --git a/internal/memoria/handlers_connector.go b/internal/memoria/handlers_connector.go
index 424702d..862b3df 100644
--- a/internal/memoria/handlers_connector.go
+++ b/internal/memoria/handlers_connector.go
@@ -159,7 +159,7 @@ func (a *app) notionConnector(w http.ResponseWriter, r *http.Request) {
}
p.Configured = true
if c.LastVerified != nil {
- p.Verified = c.LastVerified.Format("2 Jan 2006, 15:04")
+ p.Verified = stamp(*c.LastVerified)
}
// Counts. If either fails, CountsKnown stays false and the template
@@ -187,7 +187,7 @@ func viewRun(r *SyncRun) *runView {
Empty: r.Empty, Failed: r.FailedPages, Unread: r.UnreadBlocks,
ErrorCode: r.ErrorCode, Message: runMessage(r), Tone: runTone(r),
Running: r.State == RunEnumerating || r.State == RunFetching,
- Started: r.StartedAt.Format("2 Jan 2006, 15:04"),
+ Started: stamp(r.StartedAt),
}
// Total is -1 until a run completes. Rendering that as 0 would tell
// the reader the workspace is empty while it is being read.
@@ -196,7 +196,7 @@ func viewRun(r *SyncRun) *runView {
}
v.Seen = r.Fetched + r.Skipped + r.Empty + r.FailedPages
if r.FinishedAt != nil {
- v.Finished = r.FinishedAt.Format("2 Jan 2006, 15:04")
+ v.Finished = stamp(*r.FinishedAt)
}
return v
}
diff --git a/internal/memoria/handlers_sources.go b/internal/memoria/handlers_sources.go
index d1205a8..0dd0e63 100644
--- a/internal/memoria/handlers_sources.go
+++ b/internal/memoria/handlers_sources.go
@@ -83,7 +83,7 @@ func (a *app) siblingRows(r *http.Request) []sourceRow {
// that sibling carries its own withholding notice.
row.Current = "Not checked yet since this instance woke."
default:
- row.Current = "Current as of " + at.Format("2 January 2006, 15:04 UTC") + "."
+ row.Current = "Current as of " + at.UTC().Format("2 January 2006, 15:04 UTC") + "."
}
b, err := a.store.RefusalBreaker(r.Context(), app.Name, now)
diff --git a/internal/memoria/home_confirm_test.go b/internal/memoria/home_confirm_test.go
new file mode 100644
index 0000000..8963df0
--- /dev/null
+++ b/internal/memoria/home_confirm_test.go
@@ -0,0 +1,128 @@
+package memoria
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "regexp"
+ "strings"
+ "testing"
+
+ "amadan.net/oficina/home/connect"
+ "amadan.net/rastrillo/rastrillo"
+)
+
+// rastrillo #64 in Home mode: the local form's emailed link lands on the
+// confirm door, and the door's POST /auth/verify is mounted under
+// LocalGuard like the GET. Opening the link spends nothing; a cross-site
+// POST and one with no browser evidence are refused without spending it;
+// the door's own POST signs in to the local landing; the spent token is
+// ?err=expired.
+//
+// MUTATIONS (author-chosen, 2026-10-06; an independent pass is owed per
+// AGENTS.md): dropping the Home POST mount, and mounting it without
+// LocalGuard, each fail here. Reverted.
+func TestHomeLocalLinkOpensTheConfirmDoor(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ homeRedirect(t, f.do("POST", "/auth/begin", "address=alice%40example.test&force=1"))
+ link := regexp.MustCompile(`https://[^\s<>\"]+/auth/verify\?[^\s<>\"]+`).FindString(f.mail.body)
+ u, err := url.Parse(link)
+ if err != nil || link == "" {
+ t.Fatal("no captured local verification link")
+ }
+ session := func(w *httptest.ResponseRecorder) bool {
+ for _, c := range w.Result().Cookies() {
+ if strings.Contains(c.Name, "session") && c.Value != "" && c.MaxAge >= 0 {
+ return true
+ }
+ }
+ return false
+ }
+ var action, token string
+ for i := 0; i < 2; i++ {
+ w := f.do("GET", u.RequestURI(), "")
+ if w.Code != http.StatusOK || session(w) {
+ t.Fatalf("GET the link = %d (session %v), want the door and nothing minted", w.Code, session(w))
+ }
+ action, token = confirmForm(t, w.Body.String())
+ }
+
+ post := func(origin, site string, extra ...*http.Cookie) *httptest.ResponseRecorder {
+ r := httptest.NewRequest("POST", homeTestOrigin+action, strings.NewReader(url.Values{"token": {token}}.Encode()))
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ if origin != "" {
+ r.Header.Set("Origin", origin)
+ }
+ if site != "" {
+ r.Header.Set("Sec-Fetch-Site", site)
+ }
+ for _, c := range extra {
+ r.AddCookie(c)
+ }
+ w := httptest.NewRecorder()
+ f.handler.ServeHTTP(w, r)
+ return w
+ }
+ for name, w := range map[string]*httptest.ResponseRecorder{
+ "cross-site": post("https://evil.example", "cross-site"),
+ "no browser proof": post("", ""),
+ } {
+ if w.Code != http.StatusForbidden || session(w) {
+ t.Errorf("%s POST = %d (session %v), want 403 and no session", name, w.Code, session(w))
+ }
+ }
+ // LocalGuard holds the POST too: a duplicated pending cookie is
+ // connect's refusal (400), before auth is asked anything.
+ v := strings.Repeat("A", 43)
+ dup := post(homeTestOrigin, "same-origin",
+ &http.Cookie{Name: "__Host-rastrillo_pending", Value: v}, &http.Cookie{Name: "__Host-rastrillo_pending", Value: v})
+ if dup.Code != http.StatusBadRequest || session(dup) {
+ t.Errorf("POST with a duplicated pending cookie = %d, want LocalGuard's 400", dup.Code)
+ }
+
+ // None of that spent it.
+ if landing := homeRedirect(t, f.openLink(t, u.RequestURI())); landing != "/auth/local/complete" {
+ t.Fatalf("the door's POST landed on %q, want /auth/local/complete", landing)
+ }
+ if replay := post(homeTestOrigin, "same-origin"); replay.Code != http.StatusSeeOther || replay.Header().Get("Location") != "/signin?err=expired" {
+ t.Errorf("a replayed token = %d %q, want 303 /signin?err=expired", replay.Code, replay.Header().Get("Location"))
+ }
+}
+
+// The confirm door's headers in Home mode, through the production stack:
+// auth's no-store, strict-origin Referrer-Policy and framing refusals
+// survive, and the CSP is connect's FormPolicy followed by auth's added
+// frame-ancestors 'none'. The standalone half is memoriatest's
+// TestTheConfirmDoorKeepsEveryPolicy; this one was missing (Astra on #27:
+// overwriting Referrer-Policy in renderConfirm only when Home is
+// configured passed both suites). Run that mutation: it fails here.
+func TestHomeConfirmDoorKeepsEveryPolicy(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ h, done, err := rastrillo.Handler(rastrillo.Options{Mux: f.handler.(*http.ServeMux)})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if done != nil {
+ defer done()
+ }
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, httptest.NewRequest("GET", homeTestOrigin+"/auth/verify?token=x", nil))
+ if w.Code != http.StatusOK {
+ t.Fatalf("GET /auth/verify = %d", w.Code)
+ }
+ confirmForm(t, w.Body.String())
+ for name, want := range map[string]string{
+ "Cache-Control": "no-store",
+ "Referrer-Policy": "strict-origin",
+ "X-Frame-Options": "DENY",
+ "X-Robots-Tag": "noindex, nofollow",
+ } {
+ if got := w.Header().Get(name); got != want {
+ t.Errorf("%s = %q, want %q", name, got, want)
+ }
+ }
+ got := w.Header().Values("Content-Security-Policy")
+ if want := []string{connect.FormPolicy(homeTestIssuer), "frame-ancestors 'none'"}; strings.Join(got, "\n") != strings.Join(want, "\n") {
+ t.Errorf("CSP values = %q, want %q", got, want)
+ }
+}
diff --git a/internal/memoria/home_router_test.go b/internal/memoria/home_router_test.go
index cce199c..cbde102 100644
--- a/internal/memoria/home_router_test.go
+++ b/internal/memoria/home_router_test.go
@@ -75,7 +75,7 @@ func newHomeRouterFixtureWith(t *testing.T, key *assertioncrypto.Keypair, cfg Co
t.Cleanup(func() { a.store.ix.Close() })
a.SetOwners([]string{"alice@example.test"})
mail := &homeTestMail{}
- auth, err := NewAuth(d.Writer(), AuthEnv{Origin: homeTestOrigin, InstanceKey: "isolated-test-instance-key", Mailer: mail, HomeEnabled: true}, logger, a.Admit)
+ auth, err := NewAuth(d.Writer(), AuthEnv{Origin: homeTestOrigin, InstanceKey: "isolated-test-instance-key", Mailer: mail, HomeEnabled: true}, logger, a)
if err != nil {
t.Fatal(err)
}
@@ -98,6 +98,12 @@ func (f *homeRouterFixture) do(method, path, body string) *httptest.ResponseReco
r.Header.Set("Origin", homeTestIssuer)
}
}
+ return f.send(r)
+}
+
+// send serves r with the fixture's cookies already on it, and keeps the
+// cookies the answer sets.
+func (f *homeRouterFixture) send(r *http.Request) *httptest.ResponseRecorder {
w := httptest.NewRecorder()
f.handler.ServeHTTP(w, r)
for _, c := range w.Result().Cookies() {
@@ -109,6 +115,50 @@ func (f *homeRouterFixture) do(method, path, body string) *httptest.ResponseReco
}
return w
}
+
+// confirmRe is the confirm door's form (templates/signin.html): the one
+// form on the page, posting the token in a hidden field.
+var confirmRe = regexp.MustCompile(`<form rst-form estilo-signin-form method="post" action="([^"]+)">\s*<input type="hidden" name="token" value="([^"]*)">`)
+
+// confirmForm reads the confirm door off a page and returns where its Sign
+// in button posts and the token it carries. It fails the test unless the
+// page is the door with no address field — the shape renderConfirm draws.
+func confirmForm(t *testing.T, page string) (action, token string) {
+ t.Helper()
+ m := confirmRe.FindStringSubmatch(page)
+ if m == nil {
+ t.Fatalf("the link did not open the confirm door:\n%s", page)
+ }
+ if strings.Contains(page, `name="address"`) {
+ t.Error("the confirm door has an address field; it is one Sign in button")
+ }
+ if !strings.Contains(page, ">Sign in</button>") {
+ t.Error("the confirm door has no Sign in button")
+ }
+ return html.UnescapeString(m[1]), html.UnescapeString(m[2])
+}
+
+// openLink follows an emailed sign-in link the way a person does since
+// rastrillo #64: the GET draws the confirm door and spends nothing, and
+// the door's Sign in button POSTs the token back — same-origin, as a
+// browser submitting the door's own form says it is. It returns the
+// POST's answer, which is what the GET used to answer.
+func (f *homeRouterFixture) openLink(t *testing.T, uri string) *httptest.ResponseRecorder {
+ t.Helper()
+ w := f.do("GET", uri, "")
+ if w.Code != http.StatusOK {
+ t.Fatalf("GET %s = %d, want the confirm door", uri, w.Code)
+ }
+ action, token := confirmForm(t, w.Body.String())
+ r := httptest.NewRequest("POST", homeTestOrigin+action, strings.NewReader(url.Values{"token": {token}}.Encode()))
+ for _, c := range f.cookies {
+ r.AddCookie(c)
+ }
+ r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ r.Header.Set("Origin", homeTestOrigin)
+ r.Header.Set("Sec-Fetch-Site", "same-origin")
+ return f.send(r)
+}
func homeRedirect(t *testing.T, w *httptest.ResponseRecorder) string {
t.Helper()
if w.Code != 303 {
@@ -125,7 +175,7 @@ func (f *homeRouterFixture) localAs(t *testing.T, address string) {
if err != nil || link == "" {
t.Fatal("no captured local verification link")
}
- landing := homeRedirect(t, f.do("GET", u.RequestURI(), ""))
+ landing := homeRedirect(t, f.openLink(t, u.RequestURI()))
if landing != "/auth/local/complete" {
t.Fatal("wrong local landing", landing)
}
diff --git a/internal/memoria/home_security_test.go b/internal/memoria/home_security_test.go
index abada07..91b92d1 100644
--- a/internal/memoria/home_security_test.go
+++ b/internal/memoria/home_security_test.go
@@ -3,12 +3,26 @@ package memoria
import (
"net/http"
"net/http/httptest"
+ "regexp"
"strings"
"testing"
"amadan.net/rastrillo/rastrillo"
)
+// With Home configured, connect's middleware serves connect.FormPolicy:
+// rastrillo's default policy with form-action widened to the Home issuer,
+// and style-src kept at 'self' 'unsafe-inline'. Every other directive must
+// be the pinned framework's, so a tightening upstream is not silently
+// undone in Home mode.
+//
+// style-src is the one directive allowed to differ, and only in that one
+// way: rastrillo fb806a2 refuses inline styles by default, connect (at the
+// version pinned here) still allows them, and estilo's account-link page
+// (homeconnect home_link.html, Home-only) draws the address with
+// style="overflow-wrap: anywhere". The policy is connect's to tighten,
+// not this app's to override. No hash or nonce may ride along: either
+// would void 'unsafe-inline'.
func TestHomeCSPPreservesPinnedFrameworkDirectives(t *testing.T) {
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
@@ -26,6 +40,11 @@ func TestHomeCSPPreservesPinnedFrameworkDirectives(t *testing.T) {
if want == baseline {
t.Fatal("framework form-action changed")
}
+ styles := regexp.MustCompile(`style-src [^;]*`)
+ if !styles.MatchString(want) {
+ t.Fatal("framework policy has no style-src; this test's one exception no longer applies")
+ }
+ want = styles.ReplaceAllString(want, "style-src 'self' 'unsafe-inline'")
f := newHomeRouterFixture(t)
wrapped, done, err := rastrillo.Handler(rastrillo.Options{Mux: f.handler.(*http.ServeMux)})
if err != nil {
diff --git a/internal/memoria/native.go b/internal/memoria/native.go
index 04ff662..bc14b23 100644
--- a/internal/memoria/native.go
+++ b/internal/memoria/native.go
@@ -146,3 +146,57 @@ func (a *app) nativeNote(w http.ResponseWriter, r *http.Request) {
}
nativeJSON(w, map[string]any{"id": id, "indexed": err == nil})
}
+
+// nativeApprovalStyle is the CSP source for the one <style> element on
+// rastrillo/native's approval page (/native/authorize, server auth.go's
+// approval template, at the pin in go.mod). That page is the native
+// kit's, not ours: its whole stylesheet is inline, and rastrillo
+// fb806a2's default style-src refuses it, which left the card, spacing
+// and button unstyled (Astra on #27, P2). Its stylesheet holds no
+// template action, so it is one fixed string and one hash.
+//
+// Allowed by hash, on that route only, rather than 'unsafe-inline'
+// anywhere: the hash admits exactly that stylesheet, and every other page
+// keeps rastrillo's strict policy. TestNativeApprovalStyleIsTheHashedOne
+// re-hashes the served <style> on every run, so a native bump that
+// changes the CSS fails in CI instead of shipping an unstyled page. The
+// lasting fix is upstream: an external stylesheet in native/server.
+const nativeApprovalStyle = "'sha256-zliDvO3z4TJ7/8VeNbSfTcTmu729BtmpmXEY89IdRBI='"
+
+// nativeApprovalCSP adds nativeApprovalStyle to the style-src of the
+// policy already on the response. rastrillo's security headers run first
+// and set it, so this edits the policy production really sends, and a
+// tightening of rastrillo's default reaches this route too. A policy that
+// already allows inline styles is left alone: adding a hash to it would
+// void its 'unsafe-inline' (Home mode's connect.FormPolicy). No policy at
+// all (the bare router in tests) is left alone too.
+func nativeApprovalCSP(next http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ h := w.Header()
+ if p := h.Get("Content-Security-Policy"); p != "" {
+ h.Set("Content-Security-Policy", withStyleSource(p, nativeApprovalStyle))
+ }
+ next(w, r)
+ }
+}
+
+// withStyleSource appends source to policy's style-src, or adds a
+// style-src of 'self' and source when it has none. A style-src that
+// allows 'unsafe-inline' is returned unchanged.
+func withStyleSource(policy, source string) string {
+ parts := strings.Split(policy, ";")
+ for i, d := range parts {
+ f := strings.Fields(d)
+ if len(f) == 0 || f[0] != "style-src" {
+ continue
+ }
+ for _, s := range f[1:] {
+ if s == "'unsafe-inline'" {
+ return policy
+ }
+ }
+ parts[i] = strings.TrimRight(d, " ") + " " + source
+ return strings.Join(parts, ";")
+ }
+ return policy + "; style-src 'self' " + source
+}
diff --git a/internal/memoria/native_exchange_test.go b/internal/memoria/native_exchange_test.go
index 22a3d83..c3ed409 100644
--- a/internal/memoria/native_exchange_test.go
+++ b/internal/memoria/native_exchange_test.go
@@ -65,7 +65,7 @@ func newLocalOnlyFixture(t *testing.T) *homeRouterFixture {
t.Cleanup(func() { a.store.ix.Close() })
a.SetOwners([]string{"alice@example.test"})
mail := &homeTestMail{}
- auth, err := NewAuth(d.Writer(), AuthEnv{Origin: homeTestOrigin, InstanceKey: "isolated-test-instance-key", Mailer: mail}, logger, a.Admit)
+ auth, err := NewAuth(d.Writer(), AuthEnv{Origin: homeTestOrigin, InstanceKey: "isolated-test-instance-key", Mailer: mail}, logger, a)
if err != nil {
t.Fatal(err)
}
@@ -94,7 +94,7 @@ func nativeRigs() map[string]func(*testing.T) *nativeRig {
if err != nil || link == "" {
t.Fatal("no captured verification link")
}
- homeRedirect(t, f.do("GET", u.RequestURI(), ""))
+ homeRedirect(t, f.openLink(t, u.RequestURI()))
return &nativeRig{f, session(t, f)}
},
"local, Home configured": func(t *testing.T) *nativeRig {
diff --git a/internal/memoria/render.go b/internal/memoria/render.go
index 37e026c..6b19436 100644
--- a/internal/memoria/render.go
+++ b/internal/memoria/render.go
@@ -6,6 +6,7 @@ import (
"html/template"
"net/http"
"strings"
+ "time"
"amadan.net/oficina/estilo.git/chrome"
"amadan.net/oficina/estilo.git/credits"
@@ -94,11 +95,21 @@ func (a *app) pageFor(r *http.Request, f frame) page {
// appended to a half-written page, and headers (flash cookies, say)
// stay settable until the status line is written.
func render(w http.ResponseWriter, name string, data any) {
+ renderStatus(w, name, data, http.StatusOK)
+}
+
+// renderStatus is render with a status other than 200: the refused door
+// is a 403 (app.refused).
+func renderStatus(w http.ResponseWriter, name string, data any, status int) {
var buf bytes.Buffer
if err := pages[name].ExecuteTemplate(&buf, "layout", data); err != nil {
http.Error(w, "something went wrong", http.StatusInternalServerError)
return
}
+ if status != http.StatusOK {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ }
buf.WriteTo(w)
}
@@ -168,3 +179,10 @@ func tone(kind string) string {
return "negative"
}
}
+
+// stamp is how a page prints a stored time: converted to UTC and saying
+// so. Since rastrillo fb806a2 the driver hands every stored time back in
+// UTC, where before it came back in the host's zone, so an unlabelled
+// clock reading changed meaning on any host not in UTC. The sources page
+// already said UTC; now every stored time does (TestStampSaysUTC).
+func stamp(t time.Time) string { return t.UTC().Format("2 Jan 2006, 15:04 UTC") }
diff --git a/internal/memoria/serving.go b/internal/memoria/serving.go
new file mode 100644
index 0000000..0c095b3
--- /dev/null
+++ b/internal/memoria/serving.go
@@ -0,0 +1,42 @@
+package memoria
+
+import (
+ "net/http"
+
+ "amadan.net/rastrillo/rastrillo"
+)
+
+// ServeOptions completes opts with what Memoria serves: mux (App's, with
+// MountAuth's routes on it) and the error page. main passes the result to
+// rastrillo.Serve, and the CSP tests pass it to rastrillo.Handler, so the
+// stack they check is the one production runs.
+//
+// It sets no CSP, deliberately. rastrillo fb806a2's default refuses every
+// inline style attribute (style-src 'self' 'unsafe-hashes' plus pow's
+// honeypot hash), and nothing Memoria serves standalone carries one: its
+// templates, the estilo and rastrillo partials they draw, and the scripts
+// it ships (rastrillo.js, busy.js, chrome.js, drop.js, search.js) write no
+// style attribute — drop.js and search.js set attributes, hidden and a
+// <progress> value, never style. So the default holds as shipped and there was nothing to
+// migrate. TestNoPageCarriesAStyleAttribute and
+// TestStandaloneServesRastrillosDefaultCSP keep it that way, and the
+// browser check TestPagesRenderUnderTheProductionCSP watches for
+// violations in Chromium.
+//
+// One page is not ours: rastrillo/native's approval page
+// (/native/authorize) draws itself with one inline <style>, admitted by
+// its hash on that route alone (nativeApprovalCSP, native.go). Every other
+// route serves the default exactly.
+//
+// With Home configured, connect's middleware replaces the policy with
+// connect.FormPolicy, whose style-src keeps 'unsafe-inline': estilo's
+// account-link page (homeconnect home_link.html) draws the address with
+// style="overflow-wrap: anywhere". That page exists only in Home mode.
+func (a *app) ServeOptions(opts rastrillo.Options, mux *http.ServeMux) rastrillo.Options {
+ opts.Mux = mux
+ // rastrillo.Serve's own panic recovery, for a panic outside the
+ // router: the same 500 page, with the viewer's chrome, as the
+ // router's recoverToErrorPage renders (shared chrome §3.3).
+ opts.ErrorPage = a.ErrorPage
+ return opts
+}
diff --git a/internal/memoria/stamp_test.go b/internal/memoria/stamp_test.go
new file mode 100644
index 0000000..75a772f
--- /dev/null
+++ b/internal/memoria/stamp_test.go
@@ -0,0 +1,34 @@
+package memoria
+
+import (
+ "testing"
+ "time"
+)
+
+// Since rastrillo fb806a2 every stored time scans back in time.UTC, where
+// before it came back in time.Local. The pages that printed a stored time
+// with no zone (a document's Updated, the Notion connector's Verified,
+// Started and Finished) therefore changed meaning on any host not in UTC:
+// the same clock reading, now a different zone, and nothing on the page
+// saying which. stamp says UTC out loud, as the sources page already did,
+// and converts first, so a time in any zone prints the same instant.
+func TestStampSaysUTC(t *testing.T) {
+ at := time.Date(2026, 10, 6, 21, 5, 0, 0, time.UTC)
+ lisbonSummer := time.FixedZone("WEST", 3600)
+ for _, in := range []time.Time{at, at.In(lisbonSummer)} {
+ if got, want := stamp(in), "6 Oct 2026, 21:05 UTC"; got != want {
+ t.Errorf("stamp(%v) = %q, want %q", in, got, want)
+ }
+ }
+}
+
+// Every unlabelled display goes through stamp: viewRun's Started and
+// Finished are the ones a fixture can reach without a database.
+func TestRunTimesSayUTC(t *testing.T) {
+ start := time.Date(2026, 10, 6, 21, 5, 0, 0, time.FixedZone("WEST", 3600))
+ end := start.Add(time.Minute)
+ v := viewRun(&SyncRun{StartedAt: start, FinishedAt: &end, Total: -1})
+ if v.Started != "6 Oct 2026, 20:05 UTC" || v.Finished != "6 Oct 2026, 20:06 UTC" {
+ t.Errorf("run times = %q, %q; want them in UTC and labelled", v.Started, v.Finished)
+ }
+}
diff --git a/internal/memoria/static/tokens.css b/internal/memoria/static/tokens.css
index 10f6da5..6886854 100644
--- a/internal/memoria/static/tokens.css
+++ b/internal/memoria/static/tokens.css
@@ -87,6 +87,20 @@
--rst-sp-5: 1.5rem;
--rst-sp-6: 2.5rem;
+ /* The tap floor on small or touch screens: 44px at the default root,
+ and it tracks a reader who raised their default size. Declared here
+ so an app's own CSS can use it; only rules inside the touch query
+ below read it, so desktop density never sees it. */
+ --rst-tap: 2.75rem;
+
+ /* The list grid's kebab column. A variable rather than the literal
+ 32px the samples used to write, so the column can grow to a 44px
+ kebab on touch screens (the touch block redefines it). An app that
+ keeps a literal 32px still works: the kebab is justify-self: end,
+ so it overflows 12px toward the inline start, into the grid's
+ 0.85rem gap, without reaching the cell beside it. */
+ --rst-col-menu: 32px;
+
/* The scrollbar's gutter, reserved whether or not there is a
scrollbar in it, so a short page and a long one are the same width
and clicking between them does not slide the layout sideways.
@@ -114,6 +128,34 @@
--rst-scrollbar-gutter: stable;
}
+/* ── Small or touch screens: the type scale moves up one step ───────
+ One query decides "a phone, a tablet, or a window too narrow to read
+ at desktop density", and every touch-only rule in this file sits in
+ it. pointer: coarse is the PRIMARY pointer, so a touchscreen laptop
+ driven by its trackpad keeps desktop density; any-pointer would
+ switch a mouse user's whole interface because a touchscreen exists.
+ max-width: 40rem catches a narrow desktop window and the design
+ gallery's 390px Mobile tab, a fine pointer showing what a phone
+ shows (a rem in a media query is the browser's initial size, not the
+ root's). The shells' 800px is a different axis: between 640 and
+ 800px under a mouse is a split window, which gets the narrow LAYOUT
+ and keeps mouse DENSITY.
+
+ The four tokens move and nothing else: the root size, the rem
+ spacing and every px geometry stay, and controls grow because their
+ text grows and because the touch block at the end of this file gives
+ them a minimum box. Display sizes (page titles, stat numbers, avatar
+ initials) stay literal on purpose, so a 22px title sits over 16px
+ body text. ─────────────────────────────────────────────────────── */
+@media (pointer: coarse), (max-width: 40rem) {
+ :root {
+ --rst-fs-lg: 1.1875rem; /* 19px */
+ --rst-fs-base: 1rem; /* 16px */
+ --rst-fs-sm: 0.875rem; /* 14px */
+ --rst-fs-xs: 0.8125rem; /* 13px */
+ }
+}
+
/* ── Base. Three rules, so a freshly scaffolded app looks finished
before it has any CSS of its own. Delete them if your app owns
its own base styles. ──────────────────────────────────────────── */
@@ -166,11 +208,16 @@ body {
/* ── Buttons: page-header's primary action, empty-state's CTA. ────── */
+/* border-box because rst-btn goes on <a> as often as on <button>, and
+ only <button> is border-box by the browser's own stylesheet: a block
+ link-button's width: 100% came out its padding and border wider than
+ its column, and poked past the sign-in card on a phone. */
.rst-btn, [rst-btn] {
align-items: center;
background: var(--rst-surface);
border: 1px solid var(--rst-line-strong);
border-radius: var(--rst-radius-sm);
+ box-sizing: border-box;
color: var(--rst-text);
cursor: pointer;
display: inline-flex;
@@ -297,6 +344,12 @@ body {
width: 100%;
}
+/* The UA's [hidden] { display: none } loses to the display this file
+ gives every button, so a button marked hidden would show anyway. The
+ sign-in screen's passkey door ships hidden and is revealed only where
+ a ceremony can work; without this it would show everywhere. */
+.rst-btn[hidden], [rst-btn][hidden] { display: none; }
+
/* busy — the loading state rastrillo.js gives every submit button on
its way out, and the one state of a button that is not a variant:
any .rst-btn can wear it. The spinner is a real child element
@@ -330,6 +383,48 @@ body {
align-self: center;
flex: none;
}
+/* With no busy label there are no words to show while it works, so the
+ spinner takes the label's place instead of joining it: joining widens
+ the button mid-click and shoves whatever sits beside it. The shim
+ marks a button that HAS a busy label with data-idle-label, which is
+ what the :not() keys on — those words were asked for, so they stay.
+
+ :has(> spinner) because aria-busy is ordinary ARIA an app may set on
+ its own fetch button with no shim and no spinner; keyed on aria-busy
+ alone, that button would lose its label and show nothing at all.
+
+ The label is unpainted, never removed: aria-busy plus the untouched
+ text is what a screen reader announces. -webkit-text-fill-color
+ rather than color, because the ring is drawn in currentColor — a
+ transparent color would take the spinner with it. Element children
+ (an icon) are not reached by the text fill and take opacity 0, which
+ unlike visibility keeps them in the accessibility tree.
+
+ The media query stands the swap down for two readers a lone ring
+ fails. Under reduced motion the ring is still and dimmed, and on its
+ own reads as a stray "C". Under forced colours the engine repaints
+ the text fill, so the label paints anyway and a centred ring would
+ land on top of it. Both keep the older spinner-beside-label look. */
+@media (prefers-reduced-motion: no-preference) and (forced-colors: none) {
+ .rst-btn[aria-busy="true"]:not([data-idle-label]):has(> .rst-spin), [rst-btn][aria-busy="true"]:not([data-idle-label]):has(> [rst-spin]) {
+ -webkit-text-fill-color: transparent;
+ }
+ .rst-btn[aria-busy="true"]:not([data-idle-label]):has(> .rst-spin) > :not(.rst-spin), [rst-btn][aria-busy="true"]:not([data-idle-label]):has(> [rst-spin]) > :not([rst-spin]) {
+ opacity: 0;
+ }
+ .rst-btn[aria-busy="true"]:not([data-idle-label]) > .rst-spin, [rst-btn][aria-busy="true"]:not([data-idle-label]) > [rst-spin] {
+ inset: 0;
+ margin: auto;
+ position: absolute;
+ }
+ /* The spinner's containing block. Zero specificity, so an app's own
+ position: fixed or sticky on a floating Save still wins — either
+ of those contains the spinner just as well, and losing it would
+ drop the button back into the page flow mid-click. */
+ :where(.rst-btn[aria-busy="true"]:not([data-idle-label])), :where([rst-btn][aria-busy="true"]:not([data-idle-label])) {
+ position: relative;
+ }
+}
/* ── page-header ──────────────────────────────────────────────────── */
.rst-page-header, [rst-page-header] {
@@ -518,6 +613,15 @@ body {
min-inline-size: 0;
padding: 0.2rem 0;
}
+/* The zoom floor for the search input. Mobile Safari zooms a page that
+ focuses a text control under 16px, and this input is set to
+ --rst-fs-sm, which is 14px even after the touch step: this is the one
+ floor that fires inside the rastrillo idioms. It sits directly after
+ the rule above because at equal weight the later rule wins; see the
+ input floor below for the rest of the reasoning. */
+@media (pointer: coarse), (max-width: 40rem) {
+ .rst-search input[type="search"], [rst-search] input[type="search"] { font-size: max(1rem, 1em); }
+}
.rst-search input[type="search"]::placeholder, [rst-search] input[type="search"]::placeholder {
color: var(--rst-text-faint);
}
@@ -580,7 +684,9 @@ body {
.rst-lbar + .rst-row, [rst-lbar] + [rst-row] {
border-top: 0;
}
-.rst-row:hover, [rst-row]:hover {
+/* Only a row that is a link looks like one: the fill follows the
+ primary link, so a hand-written row with nothing to open stays flat. */
+.rst-row:has(> .rst-row__main > a):hover, [rst-row]:has(> [rst-row-main] > a):hover {
background: var(--rst-surface-2);
}
.rst-row__lead, [rst-row-lead] {
@@ -616,8 +722,13 @@ body {
flex: 1;
min-inline-size: 0;
}
-/* The name link's overlay makes the whole row clickable without nesting
- one anchor inside another. The action pill sits above it. */
+/* A row with a destination has ONE primary link, and its ::after covers
+ the row, so the row is the target on every screen: the
+ cursor, the status-bar URL, middle-click and "open in new tab" all
+ work across the whole width. Everything else in the row that can be
+ operated is lifted above the overlay by the :where() rule under the
+ list grid. The row is not a link, it contains one, so nested anchors
+ stay impossible. */
.rst-row__main > a, [rst-row-main] > a {
color: var(--rst-text);
font-weight: 600;
@@ -628,6 +739,13 @@ body {
inset: 0;
position: absolute;
}
+/* Focus draws the ring on the overlay, inset 2px so a card's corner
+ cannot cut it, so a keyboard user sees the whole row outlined. The
+ link also underlines: a check that reads the focused element and its
+ ancestors (the gallery's keyboard walk) never sees a pseudo-element,
+ and forced-colors mode keeps text decoration. */
+.rst-row__main > a:focus-visible, [rst-row-main] > a:focus-visible, .rst-lrow > a.rst-nm:focus-visible, [rst-lrow] > a.rst-nm:focus-visible, .rst-lrow > a.rst-person:focus-visible, [rst-lrow] > a[rst-person]:focus-visible { outline: none; text-decoration: underline; }
+.rst-row__main > a:focus-visible::after, [rst-row-main] > a:focus-visible::after, .rst-lrow > a.rst-nm:focus-visible::after, [rst-lrow] > a.rst-nm:focus-visible::after, .rst-lrow > a.rst-person:focus-visible::after, [rst-lrow] > a[rst-person]:focus-visible::after { border-radius: var(--rst-radius-sm); outline: 2px solid var(--rst-accent); outline-offset: -2px; }
.rst-row__main > a:hover, [rst-row-main] > a:hover {
text-decoration: underline;
}
@@ -654,12 +772,11 @@ body {
color: var(--rst-accent);
}
-/* A status pill inside a row sits above the name link's overlay, like
- the action pill does. */
+/* A status pill inside a row is part of the row's target, not a
+ control: a dead spot inside a row you can tap is a mis-tap on a
+ phone. It is not lifted, so a tap on it opens the row. */
.rst-row .rst-status, [rst-row] [rst-status] {
flex: none;
- position: relative;
- z-index: 1;
}
/* ── status-pill. The dot is decoration; the label is the state. ──── */
@@ -979,16 +1096,23 @@ body {
.rst-box > .rst-detail, [rst-box] > [rst-detail] { margin: 0; }
/* The list grid — the real data-table vocabulary. The card sets the
- columns once (--rst-cols, inline, trailing 32px for the kebab);
- rows only choose cells. Hover fill only on rows that contain the
- identity link — a display-only row must never look clickable.
+ columns once (--rst-cols, from a class in the app's stylesheet —
+ the default CSP blocks inline styles — trailing
+ var(--rst-col-menu) for the kebab); rows only choose cells. Hover
+ fill only on rows that contain the identity link — a display-only
+ row must never look clickable.
Like .rst-list, .rst-card has no padding: rows only. Anything that
is not a row belongs in .rst-box. */
.rst-card, [rst-card] { background: var(--rst-surface); border: 1px solid var(--rst-line); border-radius: var(--rst-radius); }
.rst-lrow, [rst-lrow] { align-items: center; border-bottom: 1px solid var(--rst-line); display: grid; gap: 0.85rem; grid-template-columns: var(--rst-cols, 1fr); padding: 0.68rem 1rem; position: relative; }
.rst-lrow:last-child, [rst-lrow]:last-child { border-bottom: 0; }
-.rst-lrow--head, [rst-lrow~="head"] { color: var(--rst-text-faint); font-size: 0.71875rem; font-weight: 550; letter-spacing: 0.05em; padding: 0.55rem 1rem; text-transform: uppercase; }
-.rst-lrow:not(.rst-lrow--head):has(> .rst-nm, > .rst-person):hover, [rst-lrow]:not([rst-lrow~="head"]):has(> .rst-nm, > [rst-person]):hover { background: var(--rst-accent-soft); }
+.rst-lrow--head, [rst-lrow~="head"] { color: var(--rst-text-faint); font-size: var(--rst-fs-xs); font-weight: 550; letter-spacing: 0.05em; padding: 0.55rem 1rem; text-transform: uppercase; }
+.rst-lrow:not(.rst-lrow--head):has(> a.rst-nm, > a.rst-person):hover, [rst-lrow]:not([rst-lrow~="head"]):has(> a.rst-nm, > a[rst-person]):hover { background: var(--rst-accent-soft); }
+/* The list grid's primary link is its identity cell: the rst-nm link,
+ or a person link when a person is who the row is. Its ::after covers
+ the row, as list-row-action's does. One identity cell per row: with
+ both, the later overlay wins and the earlier link is unreachable. */
+.rst-lrow > a.rst-nm::after, [rst-lrow] > a.rst-nm::after, .rst-lrow > a.rst-person::after, [rst-lrow] > a[rst-person]::after { content: ""; inset: 0; position: absolute; }
.rst-nm { border-radius: var(--rst-radius-sm); color: inherit; font-weight: 550; min-width: 0; text-align: start; text-decoration: none; }
a.rst-nm:hover { color: var(--rst-accent); }
.rst-nm small { color: var(--rst-text-muted); display: block; font-size: var(--rst-fs-xs); font-weight: 400; }
@@ -996,9 +1120,35 @@ a.rst-nm:hover { color: var(--rst-accent); }
.rst-no-match, [rst-no-match] { color: var(--rst-text-muted); font-size: var(--rst-fs-sm); padding: 1.6rem 1rem; text-align: center; }
.rst-no-match a, [rst-no-match] a { color: var(--rst-accent); }
.rst-count-line, [rst-count-line] { color: var(--rst-text-faint); font-size: var(--rst-fs-xs); margin: 0.7rem 0 0; }
+/* Controls above the overlay. A positioned overlay paints above every
+ element that is not positioned, whatever the DOM order, so without
+ this a checkbox before the name link would be covered and a tap on it
+ would open the item.
+
+ It lifts the CONTROLS, never their containers: lifting a row menu's
+ <details> would make it a stacking context and trap its open panel
+ under the next row's lifted controls. The :not() is attached to the
+ :is() with no space, so it filters the controls themselves; with a
+ space it is a descendant combinator and lifts things inside them.
+ The primary link is excluded, or its ::after would measure against
+ the link instead of the row.
+
+ The whole selector is inside :where(), so it weighs nothing: its job
+ is to give an otherwise static control a position and a z-index, and
+ never to replace a position a component chose. The switch's input,
+ the date field's pick button and select.js's hidden native select are
+ all absolutely placed, and at zero weight their own rules win
+ position while z-index still applies; at any weight they would be
+ pulled back into flow, out of the boxes they were drawn for.
+
+ Each lifted control is a stacking context at z-index 1, so a popup
+ it draws itself (a tooltip's [rst-tip]::after) paints under the next
+ row's lifted controls. Do not put such a control in a row without
+ raising it. */
+:where(:is(.rst-row, .rst-lrow) :is(a[href], button, summary, label, input, select, textarea):not(.rst-row__main > a, .rst-lrow > a.rst-nm, .rst-lrow > a.rst-person)), :where(:is([rst-row], [rst-lrow]) :is(a[href], button, summary, label, input, select, textarea):not([rst-row-main] > a, [rst-lrow] > a.rst-nm, [rst-lrow] > a[rst-person])) { position: relative; z-index: 1; }
@media (max-width: 800px) {
.rst-m-hide { display: none; }
- .rst-lrow, [rst-lrow], .rst-lrow--head, [rst-lrow~="head"] { grid-template-columns: minmax(0, 1fr) auto 32px; }
+ .rst-lrow, [rst-lrow], .rst-lrow--head, [rst-lrow~="head"] { grid-template-columns: minmax(0, 1fr) auto var(--rst-col-menu); }
}
/* row-menu — the per-row kebab: native details/summary, no JS. The
@@ -1025,6 +1175,15 @@ a.rst-nm:hover { color: var(--rst-accent); }
[rst-locale] hr { border: 0; border-top: 1px solid var(--rst-line); margin: 0.25rem 0; }
.rst-danger { color: var(--rst-tone-negative-fg); }
.rst-row-menu__panel .rst-danger:hover, [rst-row-menu-panel] .rst-danger:hover { background: var(--rst-tone-negative-bg); }
+/* A POST item is a one-button form: no margin, so it lines up with a
+ link item. busy.js puts a spinner in front of a submit button's label,
+ and a menu item is not an [rst-btn], so the spinner sits BESIDE the
+ label; this reserves its slot at the inline end whether or not the
+ item is busy, so a long translated label can never widen the panel or
+ wrap mid-submit. The cost is 1.5rem of padding on POST items. */
+.rst-row-menu__panel form, [rst-row-menu-panel] form { margin: 0; }
+.rst-row-menu__panel button, [rst-row-menu-panel] button { padding-inline-end: calc(0.65rem + 1rem + var(--rst-sp-2)); position: relative; }
+.rst-row-menu__panel button > .rst-spin, [rst-row-menu-panel] button > [rst-spin] { inset-block: 0; inset-inline-end: 0.65rem; margin-block: auto; position: absolute; }
/* dropdown — the details/summary menu vocabulary (header overflow,
list-bar Filter/Sort). Exclusivity between siblings is the native
@@ -1206,6 +1365,19 @@ details[open] > summary > .rst-caret, details[open] > summary > [rst-caret] { tr
out of its card, and a picker button anchored to the wrapper landing
47px short of the input's visible end. */
.rst-input, [rst-input] { background: var(--rst-surface); border: 1px solid var(--rst-line-strong); border-radius: var(--rst-radius-sm); box-sizing: border-box; color: inherit; font: inherit; padding: 0.45rem 0.6rem; width: 100%; }
+/* The zoom floor for the input, and why it is HERE. `font: inherit` in
+ the rule above resets font-size, so a floor placed before it would be
+ reset with it; the primary rule below sizes the field on purpose, so
+ the floor must come before that or it wins the tie and pulls a 19px
+ field down to 16. max(1rem, 1em) lifts a field whose parent is small
+ (a bulk bar, a menu panel) to 16px; 1em in font-size is the PARENT's
+ size, which is exactly why it cannot also keep a large field large —
+ the bug the old single floor had. A :where() spelling was rejected: at
+ zero weight it loses to `font: inherit` above and switches itself off.
+ ui/sizing_test.go holds all three floors to their positions. */
+@media (pointer: coarse), (max-width: 40rem) {
+ .rst-input, [rst-input] { font-size: max(1rem, 1em); }
+}
.rst-input:focus-visible, [rst-input]:focus-visible { outline: 2px solid var(--rst-accent); outline-offset: 2px; }
/* short is compact, not tiny: 8rem is a border-box width, so it renders
within a pixel or two of the old 6.5rem content box plus its padding,
@@ -1229,6 +1401,12 @@ textarea.rst-input, textarea[rst-input] { min-height: 5rem; resize: vertical; }
class; styled here so both field families render finished. */
.rst-field__required, [rst-field-required] { color: var(--rst-tone-negative-fg); }
.rst-textarea, [rst-textarea] { background: var(--rst-surface); border: 1px solid var(--rst-line-strong); border-radius: var(--rst-radius-sm); box-sizing: border-box; color: inherit; font: inherit; line-height: 1.6; min-height: 5rem; padding: 0.45rem 0.6rem; resize: vertical; width: 100%; }
+/* The textarea's zoom floor, directly after the rule whose `font:
+ inherit` would reset it: a floor above that line is dead, undone by
+ the reset. Reasoning as for the input floor above. */
+@media (pointer: coarse), (max-width: 40rem) {
+ .rst-textarea, [rst-textarea] { font-size: max(1rem, 1em); }
+}
.rst-textarea:focus-visible, [rst-textarea]:focus-visible { outline: 2px solid var(--rst-accent); outline-offset: 2px; }
/* The three message lines under a control. display:block is load-bearing
rather than cosmetic: the field partial emits them as <p>, and the
@@ -1429,7 +1607,7 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher
.rst-help, [rst-help] { align-items: center; border: 1px solid var(--rst-line-strong); border-radius: 7px; color: var(--rst-text-muted); display: inline-flex; height: 28px; justify-content: center; position: relative; width: 28px; }
.rst-help:hover, [rst-help]:hover { border-color: var(--rst-text-faint); color: var(--rst-text); }
.rst-help svg, [rst-help] svg, .rst-help .icon, [rst-help] .icon { height: 15px; width: 15px; }
-.rst-tip::after, [rst-tip]::after { background: var(--rst-text); border-radius: var(--rst-radius-sm); color: var(--rst-bg); content: attr(data-tip); font-size: 0.71875rem; font-weight: 500; inset-inline-end: 0; opacity: 0; padding: 3px 8px; pointer-events: none; position: absolute; top: calc(100% + 6px); transition: opacity 0.12s; white-space: nowrap; z-index: 35; }
+.rst-tip::after, [rst-tip]::after { background: var(--rst-text); border-radius: var(--rst-radius-sm); color: var(--rst-bg); content: attr(data-tip); font-size: var(--rst-fs-xs); font-weight: 500; inset-inline-end: 0; opacity: 0; padding: 3px 8px; pointer-events: none; position: absolute; top: calc(100% + 6px); transition: opacity 0.12s; white-space: nowrap; z-index: 35; }
.rst-tip:hover::after, [rst-tip]:hover::after, .rst-tip:focus-visible::after, [rst-tip]:focus-visible::after { opacity: 1; }
/* bulk-bar + selbox — select mode as server-rendered state. */
@@ -1442,7 +1620,15 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher
.rst-bulkbar__escalate:hover, [rst-bulkbar-escalate]:hover { text-decoration: underline; }
.rst-bulkbar .rst-dropdown, [rst-bulkbar] [rst-dropdown] { margin-inline-start: auto; }
.rst-bulkbar .rst-dropdown > summary, [rst-bulkbar] [rst-dropdown] > summary { background: var(--rst-surface); border: 1px solid var(--rst-line-strong); border-radius: 7px; font-size: var(--rst-fs-xs); font-weight: 550; padding: 0.22rem 0.6rem; }
-.rst-selbox, [rst-selbox] { align-items: center; display: flex; margin: 0; }
+/* The row checkbox's target is its LABEL, padded so the unchanged 16px
+ box sits in a 24×24 target (WCAG 2.5.8) on every screen. Inside a row
+ whose whole width is a link, WCAG's spacing exception cannot rescue a
+ 16px target, since the row's link covers the space around it, so
+ the padding applies on a desktop too, not only on touch. inline-flex
+ and justify-self: start keep the label its own size in a grid cell
+ and in a line, where display: flex made it as wide as either. A
+ larger invisible hit area was rejected: it would overlap the row's own overlay and take clicks meant for it. */
+.rst-selbox, [rst-selbox] { align-items: center; display: inline-flex; justify-self: start; margin: 0; padding: 4px; }
.rst-selbox input, [rst-selbox] input { accent-color: var(--rst-accent); cursor: pointer; height: 16px; margin: 0; width: 16px; }
.rst-selbox input:focus-visible, [rst-selbox] input:focus-visible { outline: 2px solid var(--rst-accent); outline-offset: 2px; }
@@ -1507,6 +1693,27 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher
it names a run of rows without competing with them. */
.rst-combo__rows, [rst-combo-rows] { list-style: none; margin: 0; padding: 0; }
.rst-select__group, [rst-select-group] { color: var(--rst-text-faint); font-size: var(--rst-fs-xs); font-weight: 550; letter-spacing: 0.05em; padding-block: 0.4rem 0.2rem; padding-inline: 0.65rem; text-transform: uppercase; }
+/* The rest of the combobox's furniture. A divider the page drew between
+ two options (an <hr>, pinned rows above the rest) and the row saying
+ nothing matches are never stops: select.js leaves both out of the
+ keyboard order, and the divider hides while a search re-ranks. */
+.rst-combo__sep, [rst-combo-sep] { border-top: 1px solid var(--rst-line); list-style: none; margin-block: 0.25rem; }
+.rst-combo__empty, [rst-combo-empty] { color: var(--rst-text-faint); font-size: var(--rst-fs-sm); list-style: none; padding: 0.4rem 0.65rem; }
+/* An option with a description reads as one line: the name, then the
+ description quieter — the brackets the native option carries are for a
+ reader without JavaScript, and colour does that job here. */
+.rst-combo__desc, [rst-combo-desc] { color: var(--rst-text-faint); margin-inline-start: 0.5em; }
+/* A decorative glyph (a country's flag) before a row, and before the
+ closed box once that row is picked. aria-hidden everywhere; the text
+ still says which option it is. The box makes room only while there is
+ a glyph to show. */
+.rst-combo__option > .rst-combo__lead, [rst-combo-option] > [rst-combo-lead] { margin-inline-end: 0.5em; }
+.rst-combo > .rst-combo__lead, [rst-combo] > [rst-combo-lead] { align-items: center; display: flex; inset-block: 0; inset-inline-start: 0.65rem; pointer-events: none; position: absolute; }
+.rst-combo > .rst-combo__lead[hidden], [rst-combo] > [rst-combo-lead][hidden] { display: none; }
+.rst-combo.rst-combo__has-lead > .rst-input, [rst-combo][rst-combo-has-lead] > [rst-input] { padding-inline-start: 2.2rem; }
+/* The list opens upward when there is no room below it (select.js
+ measures the visual viewport, which a phone's keyboard shrinks). */
+.rst-combo.rst-combo__up > .rst-combo__list, [rst-combo][rst-combo-up] > [rst-combo-list] { bottom: 100%; margin-bottom: 4px; margin-top: 0; top: auto; }
/* The date fields' combobox, built by datetime.js on any input carrying
data-rst-date or data-rst-time. Same shape as .rst-combo above, plus
@@ -1526,19 +1733,19 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher
against one font size. .rst-dtp__input's padding-inline-end is the
room this reserves inside the control, so text never runs under it,
with a value or without one. */
-.rst-dtp__pick, [rst-dtp-pick] { align-items: center; background: none; border: 0; border-radius: var(--rst-radius-sm); block-size: 1.75rem; color: var(--rst-muted); cursor: pointer; display: flex; inline-size: 1.75rem; inset-block: 0; inset-inline-end: 0.35rem; justify-content: center; margin-block: auto; padding: 0; position: absolute; }
+.rst-dtp__pick, [rst-dtp-pick] { align-items: center; background: none; border: 0; border-radius: var(--rst-radius-sm); block-size: 1.75rem; color: var(--rst-text-muted); cursor: pointer; display: flex; inline-size: 1.75rem; inset-block: 0; inset-inline-end: 0.35rem; justify-content: center; margin-block: auto; padding: 0; position: absolute; }
.rst-dtp__pick:hover, [rst-dtp-pick]:hover { color: var(--rst-text); }
.rst-dtp__pick svg, [rst-dtp-pick] svg { block-size: 1rem; inline-size: 1rem; }
.rst-dtp__list, [rst-dtp-list] { background: var(--rst-surface); border: 1px solid var(--rst-line); border-radius: 9px; box-shadow: var(--rst-shadow-pop); inset-inline: 0; list-style: none; margin-block-start: 4px; max-block-size: 17rem; overflow-y: auto; padding: 0.25rem; position: absolute; top: 100%; z-index: 40; }
.rst-dtp__row, [rst-dtp-row] { align-items: baseline; border-radius: var(--rst-radius-sm); color: inherit; cursor: pointer; display: flex; font-size: var(--rst-fs-sm); gap: 0.5rem; justify-content: space-between; padding: 0.4rem 0.65rem; }
.rst-dtp__row:hover, [rst-dtp-row]:hover, .rst-dtp__row.is-active, [rst-dtp-row].is-active { background: var(--rst-accent-soft); }
.rst-dtp__row--set .rst-dtp__label, [rst-dtp-row~="set"] [rst-dtp-label] { font-weight: 600; }
-.rst-dtp__quick .rst-dtp__set, [rst-dtp-quick] [rst-dtp-set] { color: var(--rst-muted); }
+.rst-dtp__quick .rst-dtp__set, [rst-dtp-quick] [rst-dtp-set] { color: var(--rst-text-muted); }
/* The committed value is marked in the accessibility tree by
aria-selected; this is the matching visual, never the only signal. */
.rst-dtp__row[aria-selected="true"] .rst-dtp__label, [rst-dtp-row][aria-selected="true"] [rst-dtp-label] { font-weight: 600; }
-.rst-dtp__set, [rst-dtp-set] { flex: none; font-size: var(--rst-fs-xs); color: var(--rst-muted); }
-.rst-dtp__hint, [rst-dtp-hint] { color: var(--rst-muted); font-size: var(--rst-fs-xs); padding: 0.35rem 0.65rem 0.15rem; }
+.rst-dtp__set, [rst-dtp-set] { flex: none; font-size: var(--rst-fs-xs); color: var(--rst-text-muted); }
+.rst-dtp__hint, [rst-dtp-hint] { color: var(--rst-text-muted); font-size: var(--rst-fs-xs); padding: 0.35rem 0.65rem 0.15rem; }
/* The popup opens above the field where there is no room below it —
datetime.js measures and sets .is-above, because a panel hanging off
@@ -1568,7 +1775,7 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher
.rst-cal, [rst-cal] { background: var(--rst-surface); border: 1px solid var(--rst-line); border-radius: 9px; box-shadow: var(--rst-shadow-pop); inline-size: 18rem; inset-inline-start: 0; margin-block-start: 4px; padding: var(--rst-sp-2); position: absolute; top: 100%; z-index: 40; }
.rst-cal__head, [rst-cal-head] { align-items: center; display: flex; gap: var(--rst-sp-1); justify-content: space-between; padding-block-end: var(--rst-sp-2); }
.rst-cal__title, [rst-cal-title] { flex: 1; font-size: var(--rst-fs-sm); font-weight: 600; text-align: center; }
-.rst-cal__nav, [rst-cal-nav] { align-items: center; background: none; block-size: 1.75rem; border: 0; border-radius: var(--rst-radius-sm); color: var(--rst-muted); cursor: pointer; display: flex; inline-size: 1.75rem; justify-content: center; padding: 0; }
+.rst-cal__nav, [rst-cal-nav] { align-items: center; background: none; block-size: 1.75rem; border: 0; border-radius: var(--rst-radius-sm); color: var(--rst-text-muted); cursor: pointer; display: flex; inline-size: 1.75rem; justify-content: center; padding: 0; }
.rst-cal__nav:hover:not(:disabled), [rst-cal-nav]:hover:not(:disabled) { background: var(--rst-accent-soft); color: var(--rst-text); }
/* A month with nothing pickable in it — past the field's min, or past
its max. Dimmed and inert rather than removed, so the header keeps
@@ -1581,7 +1788,7 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher
panel's own direction rather than a second rule. */
[rst-cal-nav~="next"] svg, .rst-cal__nav--next svg { transform: scaleX(-1); }
.rst-cal__grid, [rst-cal-grid] { border-collapse: collapse; inline-size: 100%; table-layout: fixed; }
-.rst-cal__grid th, [rst-cal-grid] th { color: var(--rst-muted); font-size: var(--rst-fs-xs); font-weight: 500; padding-block: var(--rst-sp-1); text-align: center; }
+.rst-cal__grid th, [rst-cal-grid] th { color: var(--rst-text-muted); font-size: var(--rst-fs-xs); font-weight: 500; padding-block: var(--rst-sp-1); text-align: center; }
.rst-cal__day, [rst-cal-day] { border-radius: var(--rst-radius-sm); cursor: pointer; font-size: var(--rst-fs-sm); font-variant-numeric: tabular-nums; line-height: 2.15; text-align: center; }
.rst-cal__day:hover, [rst-cal-day]:hover { background: var(--rst-accent-soft); }
/* The days either side of the month. Still pickable — reaching for the
@@ -1696,13 +1903,19 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher
.rst-shell-topbar .rst-shell__tail > .rst-shell__nav a, [rst-shell-topbar] [rst-shell-tail] > [rst-shell-nav] a { padding-inline: 0; }
}
-/* sidebar shell — a rail beside the page. Below 800px the rail is
- hidden behind a <details> chrome strip and, opened, becomes an
- full-width column above the content: the open state is the
- native element's own, so the shell stays zero-JS like every idiom
- above it. The main column carries min-inline-size: 0 so a wide child
- (a list grid, a <pre>) scrolls itself instead of stretching the grid
- track. */
+/* sidebar shell — a rail beside the page. Below 800px the rail is not
+ shown on a content page and IS the page on the index (the index/back
+ block after the console's). The main column carries min-inline-size:
+ 0 so a wide child (a list grid, a <pre>) scrolls itself instead of
+ stretching the grid track.
+
+ LEGACY, kept for layouts written before the phone index: the
+ <details rst-shell-chrome> drawer, which opened the rail into the
+ page flow. Its rules are the ones naming rst-shell-chrome, below and
+ in the wide block. None of the index/back rules match an old
+ layout's root (it has no view), so an app that re-vendors tokens.css
+ but keeps its old layout still has its drawer. Delete these rules
+ together once no layout and no gallery page writes rst-shell-chrome. */
.rst-shell__main, [rst-shell-main] { min-inline-size: 0; }
/* box-sizing on the rail, per this file's convention (see .rst-input
above): set on the component that needs it, never in a `*` reset.
@@ -1715,8 +1928,11 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher
was ever owed. Anything sized against the viewport has to be measured
the way the viewport measures it. */
.rst-shell-sidebar > .rst-shell__rail, [rst-shell-sidebar] > [rst-shell-rail] { background: var(--rst-surface); border-block-end: 1px solid var(--rst-line); box-sizing: border-box; display: none; flex-direction: column; gap: var(--rst-sp-3); padding: var(--rst-sp-4); }
+/* LEGACY: the old drawer, see above. */
.rst-shell-sidebar > .rst-shell__chrome > summary, [rst-shell-sidebar] > [rst-shell-chrome] > summary { align-items: center; border-block-end: 1px solid var(--rst-line); cursor: pointer; display: flex; font-size: var(--rst-fs-sm); font-weight: 550; gap: 0.3rem; list-style: none; padding-block: 0.55rem; padding-inline: var(--rst-sp-4); }
+/* LEGACY: the old drawer, see above. */
.rst-shell-sidebar > .rst-shell__chrome > summary::-webkit-details-marker, [rst-shell-sidebar] > [rst-shell-chrome] > summary::-webkit-details-marker { display: none; }
+/* LEGACY: the old drawer, see above. */
.rst-shell-sidebar > .rst-shell__chrome[open] + .rst-shell__rail, [rst-shell-sidebar] > [rst-shell-chrome][open] + [rst-shell-rail] { display: flex; }
.rst-shell__group, [rst-shell-group] { color: var(--rst-text-faint); font-size: var(--rst-fs-xs); font-weight: 550; letter-spacing: 0.05em; margin-block: var(--rst-sp-3) 0.2rem; text-transform: uppercase; }
.rst-shell-sidebar .rst-shell__nav, [rst-shell-sidebar] [rst-shell-nav] { flex-direction: column; gap: 2px; }
@@ -1728,21 +1944,20 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher
whole mechanism: the rail is a column flex box, so the auto margin
eats whatever height is left between the nav and the bottom.
- Below 800px the disclosed rail is STILL a flex column — the media
- query below only adds block-size: 100dvh, it does not change the
- display — so the reason the foot stays put down there is not that the
- layout changed but that the column is content-height and the auto
- margin has no free space to distribute. That is a dependency, so name
- it: give the disclosed rail a min-block-size or a height and the foot
- floats back down to the bottom of it, silently. The 390px leg of
+ Below 800px the rail on the phone index is STILL a flex column, and
+ it has a min-block-size of the whole screen there, so this auto
+ margin would float the foot to the bottom of the screen, where the
+ language menu would open off it. The index rules replace it with a
+ fixed gap; the 390px leg of
TestTheSidebarRailPutsThePersonAtItsFootAndTheLanguageMenuOpensUpward
- asserts the rail's own height for exactly that reason. */
+ measures that gap for exactly that reason. */
.rst-shell__rail-foot, [rst-shell-rail-foot] { display: flex; flex-direction: column; gap: var(--rst-sp-3); margin-block-start: auto; }
/* An un-overridden shell renders the wrapper with nothing in it; draw
no gap for it, the same way .rst-shell__foot handles its own. */
.rst-shell__rail-foot:empty, [rst-shell-rail-foot]:empty { display: none; }
@media (min-width: 800px) {
.rst-shell-sidebar, [rst-shell-sidebar] { display: grid; grid-template-columns: 15rem 1fr; }
+ /* LEGACY: the old drawer, see above. */
.rst-shell-sidebar > .rst-shell__chrome, [rst-shell-sidebar] > [rst-shell-chrome] { display: none; }
.rst-shell-sidebar > .rst-shell__rail, [rst-shell-sidebar] > [rst-shell-rail] { block-size: 100dvh; border-block-end: 0; border-inline-end: 1px solid var(--rst-line); display: flex; inset-block-start: 0; overflow-y: auto; position: sticky; }
/* A menu at the foot of a full-height rail is a DROPUP: the same
@@ -1776,18 +1991,18 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher
sidebar without the bar is sidebar; this is the admin shape both of
them were half of, and the one an app used to hand-write.
- TWO CHROMES, ONE CONTROL. Below 800px the bar's tail and the rail
- both have to go away, and two disclosures would be two things to
- learn. So one <details rst-shell-menu> gates both: its own next
- sibling with +, and the rail from the shell root with :has(). One
- [open], two reveals, one summary on screen at 390px.
-
- Both reveals are written as HIDE-WHEN-CLOSED rather than
- show-when-open. In an engine without :has() the rail's hide rule
- never matches and the rail renders as a column of links under the
- bar — longer page, navigation intact. Spelled the other way round,
- the same missing selector is a phone that cannot navigate. Choose
- the failure. */
+ TWO CHROMES, ONE PATTERN EACH. Below 800px the bar's tail (the account
+ and language menus) folds behind the one <details rst-shell-menu> and
+ opens as a card over the page (the card block after this one); the
+ rail is an index page, like the sidebar's (the index/back block after
+ that). The :has() pair below is LEGACY: the rail gated on the Menu's
+ [open], kept for console layouts written before the phone index,
+ whose root has no view.
+
+ The legacy gate is written HIDE-WHEN-CLOSED rather than
+ show-when-open: in an engine without :has() its hide rule never
+ matches and the rail renders as a column of links under the bar,
+ longer page, navigation intact. */
.rst-shell-console .rst-shell__bar, [rst-shell-console] [rst-shell-bar] { align-items: center; background: var(--rst-surface); border-block-end: 1px solid var(--rst-line); display: flex; flex-wrap: wrap; gap: var(--rst-sp-4); padding-block: 0.5rem; padding-inline: var(--rst-sp-4); }
.rst-shell-console .rst-shell__menu, [rst-shell-console] [rst-shell-menu] { margin-inline-start: auto; }
.rst-shell-console .rst-shell__menu > summary, [rst-shell-console] [rst-shell-menu] > summary { align-items: center; border: 1px solid var(--rst-line); border-radius: var(--rst-radius-sm); cursor: pointer; display: flex; font-size: var(--rst-fs-sm); font-weight: 550; gap: 0.35rem; list-style: none; min-block-size: 24px; padding-block: 0.3rem; padding-inline: 0.55rem; }
@@ -1803,9 +2018,12 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher
.rst-shell-console .rst-shell__nav a, [rst-shell-console] [rst-shell-nav] a { border-block-end: 0; border-radius: var(--rst-radius-sm); display: block; padding-block: 0.35rem; padding-inline: 0.55rem; }
.rst-shell-console .rst-shell__nav a:hover, [rst-shell-console] [rst-shell-nav] a:hover { background: var(--rst-accent-soft); color: var(--rst-text); }
.rst-shell-console .rst-shell__nav a[aria-current], [rst-shell-console] [rst-shell-nav] a[aria-current] { background: var(--rst-accent-soft); color: var(--rst-accent); font-weight: 600; }
-/* The two hide rules. Same [open], two combinators. */
.rst-shell-console .rst-shell__menu:not([open]) + .rst-shell__tail, [rst-shell-console] [rst-shell-menu]:not([open]) + [rst-shell-tail] { display: none; }
-.rst-shell-console:has(.rst-shell__menu:not([open])) > .rst-shell__rail, [rst-shell-console]:has([rst-shell-menu]:not([open])) > [rst-shell-rail] { display: none; }
+/* LEGACY: the rail gated on the Menu's [open], for console layouts
+ written before the phone index. A root with a view is new markup,
+ where the rail follows the view and no :has() decides it; the :not()
+ keeps this rule out of that fight entirely. */
+.rst-shell-console:not(.rst-shell-console--page, .rst-shell-console--index):has(.rst-shell__menu:not([open])) > .rst-shell__rail, [rst-shell-console]:not([rst-shell-console~="page"], [rst-shell-console~="index"]):has([rst-shell-menu]:not([open])) > [rst-shell-rail] { display: none; }
/* Disclosed, the tail is a column across the full width of the wrapped
bar. The account's margin-inline-start: auto has to be undone by
hand and stretch does not do it: in a column flex box the inline
@@ -1850,8 +2068,8 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher
one declaration the narrow hide rule contests — rides on :has().
That is the failure direction this shell chose, actually chosen
rather than nearly chosen. */
- .rst-shell-console:has(.rst-shell__menu:not([open])) > .rst-shell__rail,
- [rst-shell-console]:has([rst-shell-menu]:not([open])) > [rst-shell-rail] { display: flex; }
+ .rst-shell-console:not(.rst-shell-console--page, .rst-shell-console--index):has(.rst-shell__menu:not([open])) > .rst-shell__rail,
+ [rst-shell-console]:not([rst-shell-console~="page"], [rst-shell-console~="index"]):has([rst-shell-menu]:not([open])) > [rst-shell-rail] { display: flex; }
/* THE VIEWPORT. The rail itself is a grid item stretched to the row,
which can be taller than the window — that is what makes the
background reach the foot of a long page. So the thing that has to
@@ -1869,6 +2087,261 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher
.rst-shell-console > .rst-shell__rail > .rst-shell__nav, [rst-shell-console] > [rst-shell-rail] > [rst-shell-nav] { box-sizing: border-box; inset-block-start: var(--rst-sp-4); max-block-size: calc(100dvh - 2 * var(--rst-sp-4)); overflow-y: auto; position: sticky; }
}
+/* The topbar's narrow menu is a floating card: it overlays the page and
+ never pushes it down. The open tail is placed against the bar, at its
+ inline end (logical insets, so an RTL page anchors it left), scrolling
+ itself if a long nav does not fit. The account and language menus
+ inside it expand in place: a popover inside a popover has nowhere good
+ to go on a phone. It is the menu panels' surface (9px, their border
+ and shadow), so every menu reads as one system. The current item is a
+ fill and weight, never a border or an inset bar.
+
+ EVERY rule here is scoped below 800px rather than written narrow and
+ undone at 800: the wide tail's display: contents removes the tail's
+ box and nothing else, so the account panel would stay static, the nav
+ would lose its underline, and the layer below would still swallow
+ clicks on a desktop someone widened with the menu open. These rules
+ come after both shells' narrow rules above and win at equal weight.
+
+ CLOSING ON AN OUTSIDE TAP, with and without scripts: while open, the
+ summary grows an invisible ::before over the viewport, under the card
+ and over the page. A tap anywhere outside the card lands on it, which
+ is a click on the summary, which closes the <details> natively, and
+ the tap never reaches the page. It is position: fixed inside the bar,
+ so it breaks if an app gives the bar or an ancestor a transform,
+ filter or backdrop-filter (each makes a containing block for fixed
+ descendants). While the card is open the bar's z-index 45 is above
+ the in-page panels (dropdown 30, row menu 40), so a menu left open
+ without scripts cannot paint over the card, and below the skip link
+ (60). Only while open: lifted for good, the bar painted over a modal's
+ scrim (z-index 10), undimmed and still tappable on a phone. Closed, it
+ is positioned at z-index auto, which the scrim covers. The card is
+ sized from the bar (100%), not the viewport, which counts a classic
+ scrollbar's gutter as room and overhung the start edge at 320px.
+ rastrillo.js adds Escape.
+
+ The console's bar folds the same way; its nav is in the rail, so its
+ card holds the account and language menus only. Only a console whose
+ root names its view: an old console layout gates its rail on the
+ same [open], and the tap-to-close layer would cover that rail, so a
+ tap on any rail link would close the navigation instead of following
+ it. An old layout keeps its push-down tail. */
+@media (max-width: 799.98px) {
+ .rst-shell-topbar .rst-shell__bar, [rst-shell-topbar] [rst-shell-bar], .rst-shell-console--page .rst-shell__bar, .rst-shell-console--index .rst-shell__bar, [rst-shell-console~="page"] [rst-shell-bar], [rst-shell-console~="index"] [rst-shell-bar] { position: relative; }
+ .rst-shell-topbar .rst-shell__bar:has(> .rst-shell__menu[open]), [rst-shell-topbar] [rst-shell-bar]:has(> [rst-shell-menu][open]), .rst-shell-console--page .rst-shell__bar:has(> .rst-shell__menu[open]), .rst-shell-console--index .rst-shell__bar:has(> .rst-shell__menu[open]), [rst-shell-console~="page"] [rst-shell-bar]:has(> [rst-shell-menu][open]), [rst-shell-console~="index"] [rst-shell-bar]:has(> [rst-shell-menu][open]) { z-index: 45; }
+ .rst-shell-topbar .rst-shell__menu[open] > summary, [rst-shell-topbar] [rst-shell-menu][open] > summary, .rst-shell-console--page .rst-shell__menu[open] > summary, .rst-shell-console--index .rst-shell__menu[open] > summary, [rst-shell-console~="page"] [rst-shell-menu][open] > summary, [rst-shell-console~="index"] [rst-shell-menu][open] > summary { background: var(--rst-accent-soft); border-color: var(--rst-accent); }
+ .rst-shell-topbar .rst-shell__menu[open] > summary::before, [rst-shell-topbar] [rst-shell-menu][open] > summary::before, .rst-shell-console--page .rst-shell__menu[open] > summary::before, .rst-shell-console--index .rst-shell__menu[open] > summary::before, [rst-shell-console~="page"] [rst-shell-menu][open] > summary::before, [rst-shell-console~="index"] [rst-shell-menu][open] > summary::before { content: ""; cursor: default; inset: 0; position: fixed; z-index: 1; }
+ .rst-shell-topbar .rst-shell__menu[open] + .rst-shell__tail, [rst-shell-topbar] [rst-shell-menu][open] + [rst-shell-tail], .rst-shell-console--page .rst-shell__menu[open] + .rst-shell__tail, .rst-shell-console--index .rst-shell__menu[open] + .rst-shell__tail, [rst-shell-console~="page"] [rst-shell-menu][open] + [rst-shell-tail], [rst-shell-console~="index"] [rst-shell-menu][open] + [rst-shell-tail] { animation: rst-shell-drop 0.14s ease-out; background: var(--rst-surface); border: 1px solid var(--rst-line); border-radius: 9px; box-shadow: var(--rst-shadow-pop); box-sizing: border-box; flex-basis: auto; gap: 0; inline-size: min(20rem, calc(100% - 1.5rem)); inset-block-start: calc(100% + 6px); inset-inline-end: 0.75rem; max-block-size: calc(100dvh - 5rem); overflow-y: auto; overscroll-behavior: contain; padding: 0.35rem; position: absolute; z-index: 2; }
+ .rst-shell-topbar .rst-shell__tail > .rst-shell__nav, [rst-shell-topbar] [rst-shell-tail] > [rst-shell-nav] { align-items: stretch; gap: 0; }
+ .rst-shell-topbar .rst-shell__tail > .rst-shell__nav a, [rst-shell-topbar] [rst-shell-tail] > [rst-shell-nav] a { border-block-end: 0; border-radius: var(--rst-radius-sm); color: var(--rst-text); padding-block: 0.55rem; padding-inline: 0.75rem; }
+ .rst-shell-topbar .rst-shell__tail > .rst-shell__nav a[aria-current], [rst-shell-topbar] [rst-shell-tail] > [rst-shell-nav] a[aria-current] { background: var(--rst-accent-soft); box-shadow: none; color: var(--rst-accent); font-weight: 600; }
+ /* Topbar only: the rule separates the account from the nav above it,
+ and in the console's card the account is first, under nothing. */
+ .rst-shell-topbar .rst-shell__tail > .rst-shell__account, [rst-shell-topbar] [rst-shell-tail] > [rst-shell-account] { border-block-start: 1px solid var(--rst-line); margin-block-start: 0.35rem; padding-block-start: 0.35rem; }
+ .rst-shell-topbar .rst-shell__tail > .rst-shell__account > summary, [rst-shell-topbar] [rst-shell-tail] > [rst-shell-account] > summary, .rst-shell-console--page .rst-shell__tail > .rst-shell__account > summary, .rst-shell-console--index .rst-shell__tail > .rst-shell__account > summary, [rst-shell-console~="page"] [rst-shell-tail] > [rst-shell-account] > summary, [rst-shell-console~="index"] [rst-shell-tail] > [rst-shell-account] > summary { justify-content: space-between; padding-inline: 0.75rem; }
+ .rst-shell-topbar .rst-shell__tail .rst-dropdown__menu, [rst-shell-topbar] [rst-shell-tail] [rst-dropdown-menu], .rst-shell-console--page .rst-shell__tail .rst-dropdown__menu, .rst-shell-console--index .rst-shell__tail .rst-dropdown__menu, [rst-shell-console~="page"] [rst-shell-tail] [rst-dropdown-menu], [rst-shell-console~="index"] [rst-shell-tail] [rst-dropdown-menu] { border: 0; box-shadow: none; inset: auto; margin: 0; max-block-size: none; min-width: 0; padding-block: 0; padding-inline: 0.5rem 0; position: static; }
+}
+@keyframes rst-shell-drop { from { opacity: 0; transform: translateY(-4px); } }
+@media (prefers-reduced-motion: reduce) {
+ .rst-shell-topbar .rst-shell__menu[open] + .rst-shell__tail, [rst-shell-topbar] [rst-shell-menu][open] + [rst-shell-tail], .rst-shell-console--page .rst-shell__menu[open] + .rst-shell__tail, .rst-shell-console--index .rst-shell__menu[open] + .rst-shell__tail, [rst-shell-console~="page"] [rst-shell-menu][open] + [rst-shell-tail], [rst-shell-console~="index"] [rst-shell-menu][open] + [rst-shell-tail] { animation: none; }
+}
+
+/* ── Sidebar and console on a phone: the index and the back control ──
+ Below 800px these two shells are two views of every URL, and the page
+ says which with the view block the layout writes into the root:
+ [rst-shell-sidebar~="index"] is the rail as a full-screen page of
+ sections at a real URL; [rst-shell-sidebar~="page"] is the content,
+ with a back control at the top inline-start whose href is the page's
+ up block. No hamburger. The console is the same for its rail, under
+ its bar.
+
+ An old layout's root has no view, so none of these rules match it and
+ it keeps its drawer (the legacy rules above). Scoped below 800px, like
+ the card: at 800 and up both views are the rail beside the page, the
+ back strip and the title are hidden by the rule just below, and
+ nothing here needs undoing. ─────────────────────────────────────── */
+.rst-shell__back, [rst-shell-back], .rst-shell__title, [rst-shell-title] { display: none; }
+@media (max-width: 799.98px) {
+ /* The content view: a sticky strip of its own at the top, first in
+ the reading order after the skip link. Its z-index 9 sits above the
+ sticky form bar (5), so a long form scrolled under the strip does
+ not paint over Back, and below a modal's scrim (10): above it, Back
+ stayed undimmed and live under an open modal on a phone. */
+ .rst-shell-sidebar--page > .rst-shell__back, [rst-shell-sidebar~="page"] > [rst-shell-back], .rst-shell-console--page > .rst-shell__back, [rst-shell-console~="page"] > [rst-shell-back] { align-items: center; background: var(--rst-surface); border-block-end: 1px solid var(--rst-line); display: flex; inset-block-start: 0; min-block-size: 2.75rem; padding-inline: var(--rst-sp-1); position: sticky; z-index: 9; }
+ /* The strip is sticky, not in the flow a plain scroll-to-fragment
+ measures against, so a #fragment link's native landing scroll parks
+ the target's top edge at the root's scroll origin — under the
+ strip, the one thing on a content page actually sitting there.
+ Reserving the strip's own height, border included, as scroll
+ padding is what makes that landing spot the strip's bottom edge
+ instead. */
+ :root:has(.rst-shell-sidebar--page), :root:has([rst-shell-sidebar~="page"]), :root:has(.rst-shell-console--page), :root:has([rst-shell-console~="page"]) { scroll-padding-block-start: calc(2.75rem + 1px); }
+ .rst-shell__back a, [rst-shell-back] a { align-items: center; border-radius: var(--rst-radius-sm); color: var(--rst-accent); display: inline-flex; font-size: 1rem; font-weight: 500; gap: 0.5rem; min-block-size: 2.75rem; padding-inline: 0.75rem; text-decoration: none; }
+ /* The chevron is drawn, not an icon: there is no left chevron in the
+ set and no icon mirroring, and a drawn one stays out of the
+ accessible name. Two borders of a small square, turned; physical on
+ purpose, like the switch's knob, because it is a drawing, and
+ mirrored by the rule after it. */
+ .rst-shell__back a::before, [rst-shell-back] a::before { block-size: 0.55rem; border-left: 2px solid currentColor; border-top: 2px solid currentColor; content: ""; flex: none; inline-size: 0.55rem; transform: rotate(-45deg); }
+ [dir="rtl"] .rst-shell__back a::before, [dir="rtl"] [rst-shell-back] a::before { transform: rotate(135deg); }
+ .rst-shell__back a:focus-visible, [rst-shell-back] a:focus-visible { outline: 2px solid var(--rst-accent); outline-offset: -2px; }
+ .rst-shell-console--page > .rst-shell__rail, [rst-shell-console~="page"] > [rst-shell-rail] { display: none; }
+
+ /* The index view: the rail is the page. main and the skip link go with
+ it, so the skip link never points at nothing; the sidebar's brand,
+ which would link to this very page, gives way to the title, the one
+ h1 here. On the index a page has no main landmark, which is the
+ point: the page is navigation. */
+ .rst-shell-sidebar--index > .rst-shell__main, [rst-shell-sidebar~="index"] > [rst-shell-main], .rst-shell-sidebar--index > .rst-skip, [rst-shell-sidebar~="index"] > [rst-skip], .rst-shell-console--index > .rst-shell__main, [rst-shell-console~="index"] > [rst-shell-main], .rst-shell-console--index > .rst-skip, [rst-shell-console~="index"] > [rst-skip], .rst-shell-sidebar--index .rst-shell__brand, [rst-shell-sidebar~="index"] [rst-shell-brand] { display: none; }
+ .rst-shell-sidebar--index > .rst-shell__rail, [rst-shell-sidebar~="index"] > [rst-shell-rail] { background: var(--rst-bg); border-block-end: 0; display: flex; gap: 0; min-block-size: 100dvh; padding: var(--rst-sp-5) var(--rst-sp-4); }
+ /* The console's index is the bar, then the rail, then the foot. The
+ three share the window: the root is a column at least the window
+ tall and the rail takes what the bar and the foot leave, so a short
+ nav does not scroll at all. A rail a whole window tall made every
+ index scroll by the bar's and the foot's height, however short its
+ nav; a long nav still grows the rail and the page scrolls. The
+ console rail is already border-box. */
+ .rst-shell-console--index, [rst-shell-console~="index"] { display: flex; flex-direction: column; min-block-size: 100dvh; }
+ .rst-shell-console--index > .rst-shell__rail, [rst-shell-console~="index"] > [rst-shell-rail] { background: var(--rst-bg); border-block-end: 0; display: flex; flex: 1 0 auto; gap: 0; padding: var(--rst-sp-5) var(--rst-sp-4); }
+ .rst-shell-sidebar--index .rst-shell__title, [rst-shell-sidebar~="index"] [rst-shell-title], .rst-shell-console--index .rst-shell__title, [rst-shell-console~="index"] [rst-shell-title] { display: block; font-size: 1.75rem; font-weight: 700; letter-spacing: -0.02em; line-height: 1.2; margin: 0 0 var(--rst-sp-2); padding-inline: 0.25rem; }
+ /* The rows: every link a full-width row, at least 3rem, 1rem text, a
+ chevron at the end. The grouping is the markup apps already write,
+ a group label and then its links: a run of rows is rounded at its
+ first link (the nav's first, or the one after a label) and its last
+ (the nav's last, or the one before a label). The :has() half is a
+ rule of its own, so an engine without :has() drops only it and a
+ run's last corner is square, which is cosmetic. aria-current is not
+ marked here: the index is its own page, where "current" is not
+ true. */
+ .rst-shell-sidebar--index .rst-shell__nav, [rst-shell-sidebar~="index"] [rst-shell-nav], .rst-shell-console--index .rst-shell__nav, [rst-shell-console~="index"] [rst-shell-nav] { gap: 0; }
+ .rst-shell-sidebar--index .rst-shell__nav > a, [rst-shell-sidebar~="index"] [rst-shell-nav] > a, .rst-shell-sidebar--index .rst-shell__nav > a[aria-current], [rst-shell-sidebar~="index"] [rst-shell-nav] > a[aria-current], .rst-shell-console--index .rst-shell__nav > a, [rst-shell-console~="index"] [rst-shell-nav] > a, .rst-shell-console--index .rst-shell__nav > a[aria-current], [rst-shell-console~="index"] [rst-shell-nav] > a[aria-current] { align-items: center; background: var(--rst-surface); border: 1px solid var(--rst-line); border-block-start-width: 0; border-radius: 0; box-sizing: border-box; color: var(--rst-text); display: flex; font-size: 1rem; font-weight: 450; gap: var(--rst-sp-3); min-block-size: 3rem; padding-block: 0; padding-inline: 1rem 0.75rem; }
+ .rst-shell-sidebar--index .rst-shell__nav > a:first-child, [rst-shell-sidebar~="index"] [rst-shell-nav] > a:first-child, .rst-shell-sidebar--index .rst-shell__nav > .rst-shell__group + a, [rst-shell-sidebar~="index"] [rst-shell-nav] > [rst-shell-group] + a, .rst-shell-console--index .rst-shell__nav > a:first-child, [rst-shell-console~="index"] [rst-shell-nav] > a:first-child, .rst-shell-console--index .rst-shell__nav > .rst-shell__group + a, [rst-shell-console~="index"] [rst-shell-nav] > [rst-shell-group] + a { border-block-start-width: 1px; border-start-end-radius: var(--rst-radius); border-start-start-radius: var(--rst-radius); }
+ .rst-shell-sidebar--index .rst-shell__nav > a:last-child, [rst-shell-sidebar~="index"] [rst-shell-nav] > a:last-child, .rst-shell-console--index .rst-shell__nav > a:last-child, [rst-shell-console~="index"] [rst-shell-nav] > a:last-child { border-end-end-radius: var(--rst-radius); border-end-start-radius: var(--rst-radius); }
+ .rst-shell-sidebar--index .rst-shell__nav > a:has(+ .rst-shell__group), [rst-shell-sidebar~="index"] [rst-shell-nav] > a:has(+ [rst-shell-group]), .rst-shell-console--index .rst-shell__nav > a:has(+ .rst-shell__group), [rst-shell-console~="index"] [rst-shell-nav] > a:has(+ [rst-shell-group]) { border-end-end-radius: var(--rst-radius); border-end-start-radius: var(--rst-radius); }
+ .rst-shell-sidebar--index .rst-shell__nav > a::after, [rst-shell-sidebar~="index"] [rst-shell-nav] > a::after, .rst-shell-console--index .rst-shell__nav > a::after, [rst-shell-console~="index"] [rst-shell-nav] > a::after { block-size: 0.5rem; border-right: 2px solid var(--rst-text-faint); border-top: 2px solid var(--rst-text-faint); content: ""; flex: none; inline-size: 0.5rem; margin-inline-start: auto; transform: rotate(45deg); }
+ [dir="rtl"] .rst-shell-sidebar--index .rst-shell__nav > a::after, [dir="rtl"] [rst-shell-sidebar~="index"] [rst-shell-nav] > a::after, [dir="rtl"] .rst-shell-console--index .rst-shell__nav > a::after, [dir="rtl"] [rst-shell-console~="index"] [rst-shell-nav] > a::after { transform: rotate(-135deg); }
+ .rst-shell-sidebar--index .rst-shell__nav > a:hover, [rst-shell-sidebar~="index"] [rst-shell-nav] > a:hover, .rst-shell-sidebar--index .rst-shell__nav > a:active, [rst-shell-sidebar~="index"] [rst-shell-nav] > a:active, .rst-shell-console--index .rst-shell__nav > a:hover, [rst-shell-console~="index"] [rst-shell-nav] > a:hover, .rst-shell-console--index .rst-shell__nav > a:active, [rst-shell-console~="index"] [rst-shell-nav] > a:active { background: var(--rst-accent-soft); }
+ .rst-shell-sidebar--index .rst-shell__nav > a:focus-visible, [rst-shell-sidebar~="index"] [rst-shell-nav] > a:focus-visible, .rst-shell-console--index .rst-shell__nav > a:focus-visible, [rst-shell-console~="index"] [rst-shell-nav] > a:focus-visible { outline: 2px solid var(--rst-accent); outline-offset: -2px; }
+ /* "You came from here": the back link points at #nav-<section>, and
+ that row flashes once as the index comes back. */
+ .rst-shell-sidebar--index .rst-shell__nav > a:target, [rst-shell-sidebar~="index"] [rst-shell-nav] > a:target, .rst-shell-console--index .rst-shell__nav > a:target, [rst-shell-console~="index"] [rst-shell-nav] > a:target { animation: rst-shell-flash 1.4s ease-out; }
+ .rst-shell-sidebar--index .rst-shell__group, [rst-shell-sidebar~="index"] [rst-shell-group], .rst-shell-console--index .rst-shell__group, [rst-shell-console~="index"] [rst-shell-group] { margin-block: var(--rst-sp-5) var(--rst-sp-2); padding-inline: 1rem; }
+ /* The foot FOLLOWS the nav on the index, by a fixed gap, never auto:
+ the rail has a min-height here, and an auto margin would float the
+ foot to the bottom of the screen, where the language menu would open
+ off it (the rail-foot comment above has the long version). */
+ .rst-shell-sidebar--index .rst-shell__rail-foot, [rst-shell-sidebar~="index"] [rst-shell-rail-foot] { margin-block-start: var(--rst-sp-6); }
+}
+@keyframes rst-shell-flash { from { background: var(--rst-accent-soft); } }
+@media (prefers-reduced-motion: reduce) {
+ .rst-shell-sidebar--index .rst-shell__nav > a:target, [rst-shell-sidebar~="index"] [rst-shell-nav] > a:target, .rst-shell-console--index .rst-shell__nav > a:target, [rst-shell-console~="index"] [rst-shell-nav] > a:target { animation: none; }
+}
+
+/* ── rst-signin: the shipped sign-in card (partials/signin.html) ─────
+ Brand at the start and the way in at the end, side by side from 48rem
+ and stacked below it, so a phone reads the brand and then the door.
+ The card paints its own surface and border because it usually sits
+ on the stage shell's backdrop: whatever picture an app puts there,
+ the controls stay on a known ground and their contrast is the
+ theme's, not the picture's. The border is --rst-line-strong because
+ it is the card's only edge against that picture, and
+ ui/contrast_test.go holds it to 3:1 against the default art.
+ overflow-wrap on the door is for the one long string it shows back —
+ an address, which has no spaces to break at and would otherwise push
+ a 320px screen sideways. */
+.rst-signin, [rst-signin] { background: var(--rst-surface); border: 1px solid var(--rst-line-strong); border-radius: var(--rst-radius); box-shadow: var(--rst-shadow-pop); box-sizing: border-box; display: grid; gap: var(--rst-sp-5); inline-size: 100%; max-inline-size: 56rem; padding: var(--rst-sp-5); }
+@media (min-width: 48rem) {
+ .rst-signin, [rst-signin] { align-items: center; gap: var(--rst-sp-6); grid-template-columns: minmax(0, 1fr) minmax(0, 1.25fr); padding: var(--rst-sp-6); }
+}
+/* The brand column reads mark, name, pitch. The pitch is the line the
+ column is for, so it is the largest type on the card, bigger than the
+ door's heading; the name above it is a label. A brand with no pitch
+ still has its name at label size: the door's heading already says
+ "Sign in to" that name. The mark takes the accent through
+ currentColor, so an inline SVG drawn in currentColor follows the
+ theme without colours of its own. */
+.rst-signin__brand, [rst-signin-brand] { display: flex; flex-direction: column; gap: var(--rst-sp-3); min-inline-size: 0; }
+.rst-signin__mark, [rst-signin-mark] { block-size: 3rem; color: var(--rst-accent); inline-size: 3rem; }
+.rst-signin__mark > *, [rst-signin-mark] > * { block-size: 100%; display: block; inline-size: 100%; }
+.rst-signin__name, [rst-signin-name] { color: var(--rst-text-muted); font-size: var(--rst-fs-lg); font-weight: 600; margin: 0; }
+.rst-signin__pitch, [rst-signin-pitch] { color: var(--rst-text); font-size: clamp(1.5rem, 1.2rem + 1vw, 2rem); font-weight: 650; letter-spacing: -0.02em; line-height: 1.2; margin: 0; text-wrap: balance; }
+.rst-signin__door, [rst-signin-door] { display: flex; flex-direction: column; gap: var(--rst-sp-4); min-inline-size: 0; overflow-wrap: anywhere; }
+.rst-signin__door h1, [rst-signin-door] h1 { color: var(--rst-text); font-size: 1.375rem; font-weight: 600; letter-spacing: -0.015em; line-height: 1.25; margin: 0; }
+.rst-signin__door > p, [rst-signin-door] > p { margin: 0; }
+/* The door spaces its children with gap; a callout's and a field's own
+ block margins would double it. */
+.rst-signin__door > .rst-callout, [rst-signin-door] > [rst-callout] { margin: 0; }
+.rst-signin__form, [rst-signin-form] { display: flex; flex-direction: column; gap: var(--rst-sp-3); margin: 0; }
+.rst-signin__form > .rst-field, [rst-signin-form] > [rst-field] { margin: 0; }
+.rst-signin__remembered, [rst-signin-remembered] { color: var(--rst-text-muted); margin: 0; }
+/* The passkey button and its message are one child of the door. The
+ message is a live region and must stay rendered, so it is never
+ display: none; it is only spaced from the button once it has
+ something to say. While the button is hidden — before the door's
+ script has found a working WebAuthn, or on a browser without one —
+ the whole pair is, so the door keeps no gap for a control nobody can
+ see; the region is back in the page before the button can be
+ pressed, which is all an announcement needs. The "or" between the
+ pair and the email form goes with them: beside a hidden button the
+ page has one way in, and an "or" would point at nothing. It is the
+ wrapper's immediate neighbour, before it on Ask and after it on a
+ remembered passkey, so one sibling selector each way reaches it. */
+.rst-signin__passkey, [rst-signin-passkey] { display: flex; flex-direction: column; }
+.rst-signin__passkey:has(> .rst-btn[hidden]), [rst-signin-passkey]:has(> [rst-btn][hidden]), .rst-signin__passkey:has(> .rst-btn[hidden]) + .rst-signin__or, [rst-signin-passkey]:has(> [rst-btn][hidden]) + [rst-signin-or], .rst-signin__or:has(+ .rst-signin__passkey > .rst-btn[hidden]), [rst-signin-or]:has(+ [rst-signin-passkey] > [rst-btn][hidden]) { display: none; }
+.rst-signin__passkey-msg, [rst-signin-passkey-msg] { color: var(--rst-text-muted); margin: 0; }
+.rst-signin__passkey-msg:not(:empty), [rst-signin-passkey-msg]:not(:empty) { margin-block-start: var(--rst-sp-2); }
+/* The word sits on a hairline drawn by its own ::before and ::after,
+ which carry no text, so a screen reader hears "or" in reading order
+ between the two ways in and nothing else. */
+.rst-signin__or, [rst-signin-or] { align-items: center; color: var(--rst-text-muted); display: flex; font-size: var(--rst-fs-sm); gap: var(--rst-sp-3); }
+.rst-signin__or::before, .rst-signin__or::after, [rst-signin-or]::before, [rst-signin-or]::after { border-block-start: 1px solid var(--rst-line); content: ""; flex: 1; }
+/* Provider buttons an app adds itself (Google, Apple, …), one form per
+ provider because each posts to its own route. Sibling forms carry no
+ spacing of their own, so without a wrapper the buttons touch and read
+ as one control split in half; the gap is the card form's, so the two
+ stacks read alike. The 44rem cap is rst-form's measure: stretched
+ past it a block button is a bar with a word in the middle. The forms'
+ margins are zeroed because an app's own `form { margin-bottom }` would
+ be added to the gap, the double spacing rst-form's rule exists to
+ stop. */
+.rst-signin__providers, [rst-signin-providers] { display: flex; flex-direction: column; gap: var(--rst-sp-3); max-inline-size: 44rem; }
+.rst-signin__providers > form, [rst-signin-providers] > form { margin: 0; }
+
+/* ── rst-stage: the stage shell (layouts/stage.html) ─────────────────
+ One card centred on a full-page backdrop. [rst-stage] is <body> in
+ the shell and a plain wrapper in the design-system gallery's frames,
+ so nothing here assumes which element carries it. The scene is
+ absolutely positioned inside it rather than fixed, so a page taller
+ than the window scrolls the picture with it instead of sliding the
+ card over a still image. The page column stacks its children and
+ centres each one, rather than laying them in a row: the card is
+ usually alone, but a line of text above or below it must not end up
+ beside it. inline-size: 100% because [rst-page]'s auto margins make
+ a grid item shrink to its content, and a card sized in percent would
+ shrink with it. */
+.rst-stage, [rst-stage] { box-sizing: border-box; display: grid; grid-template-rows: 1fr auto; isolation: isolate; min-block-size: 100dvh; position: relative; }
+.rst-stage > .rst-page, [rst-stage] > [rst-page] { align-items: center; align-self: center; display: flex; flex-direction: column; inline-size: 100%; position: relative; z-index: 1; }
+.rst-stage__scene, [rst-stage-scene] { inset: 0; overflow: hidden; pointer-events: none; position: absolute; z-index: 0; }
+.rst-stage__scene > *, [rst-stage-scene] > * { block-size: 100%; display: block; inline-size: 100%; object-fit: cover; }
+.rst-stage__foot, [rst-stage-foot] { color: var(--rst-text-muted); padding: var(--rst-sp-4); position: relative; text-align: center; z-index: 1; }
+/* The art's two colours: the line colour and a little accent, each
+ mixed into the page background, faint enough that the sign-in card's
+ border still clears 3:1 against them in every theme and scheme —
+ ui/contrast_test.go reads these two color-mix() calls and checks
+ exactly that. Lower the percentages rather than loosen the test.
+ non-scaling-stroke keeps the lines fine on a wide screen, where the
+ viewBox is scaled up to cover it. */
+.rst-stage-art__lines, [rst-stage-art-lines] { fill: none; stroke: color-mix(in srgb, var(--rst-line) 70%, var(--rst-bg)); stroke-width: 1.5; vector-effect: non-scaling-stroke; }
+.rst-stage-art__glow, [rst-stage-art-glow] { fill: color-mix(in srgb, var(--rst-accent) 8%, var(--rst-bg)); fill-opacity: 0.2; }
+/* Forced colours would paint the lines in the system's text colour at
+ full strength, and a printout does not need a picture of waves. */
+@media (forced-colors: active) {
+ .rst-stage-art, [rst-stage-art] { display: none; }
+}
+@media print {
+ .rst-stage-art, [rst-stage-art] { display: none; }
+}
+
/* error page — one centred column, whatever shell it lands in: the
status as a quiet label above the sentence, one or two ways out, and
the reference last, where an operator asked to quote it will find it.
@@ -1885,14 +2358,27 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher
Touch: the two zooms nobody asked for.
Mobile Safari zooms the whole page when it focuses a form control
- whose text is under 16px, and this file's base type is 14px on
- purpose. So every text-entry control comes back up to 16px where the
- pointer is coarse, and stays 14px under a mouse. max() rather than a
- flat 1rem, so a control deliberately set larger keeps its size; rem
- rather than px, so a reader who raised the browser default keeps the
- increase. This is the fix. The other one — user-scalable=no in the
- viewport tag — buys the same quiet by taking pinch-zoom away from
- everyone, which is a WCAG 1.4.4 failure and not on offer here.
+ whose text is under 16px. The touch step at the top of this file
+ makes body text 16px, but controls inherit their size and some sit in
+ a smaller parent, so every text-entry control still has a floor. It
+ is written twice, because one spelling cannot do both jobs:
+
+ - the component floors (.rst-input, .rst-textarea, the search input)
+ sit directly after the rule that resets each one's font, at that
+ rule's weight, and before any rule that sizes the field on purpose.
+ max(1rem, 1em) is the floor; 1em in font-size is the PARENT's size,
+ so a floor that came after the primary field's rule would shrink
+ it. That was a bug here once: a single floor sat
+ here, after [rst-input~="primary"] at equal weight, and turned the
+ 17px primary field into 16px.
+ - the floor below, for an app's own input with no rst- attribute,
+ is wholly inside :where(): the UA stylesheet is what sizes such a
+ field, any author rule beats it, and any rule the app writes to size
+ its own field still wins.
+
+ rem rather than px so a reader who raised the browser default keeps
+ the increase. user-scalable=no in the viewport tag would buy the same
+ quiet by taking pinch-zoom from everyone, which fails WCAG 1.4.4.
Double-tap is the second zoom: tap a button or a row twice in quick
succession and the second tap lands as a zoom gesture rather than a
@@ -1902,11 +2388,8 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher
narrow column. :where() weighs nothing, so any rule can override it.
--------------------------------------------------------------------- */
-@media (pointer: coarse) {
- input, select, textarea,
- .rst-input, [rst-input],
- .rst-textarea, [rst-textarea],
- .rst-search input[type="search"], [rst-search] input[type="search"] {
+@media (pointer: coarse), (max-width: 40rem) {
+ :where(input:not([type=checkbox]):not([type=radio]):not([type=range]), select, textarea) {
font-size: max(1rem, 1em);
}
}
@@ -1915,6 +2398,111 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher
touch-action: manipulation;
}
+/* ── Tap targets on small or touch screens ──────────────────────────
+ Every interactive idiom is at least --rst-tap (44px) on both axes,
+ measured as the box a tap ACTIVATES: the label of a label-backed
+ control, the row of a stretched link. Anything that is display:
+ inline today becomes inline-flex or flex here, because min sizes do
+ nothing on an inline box. Each selector repeats the weight of the
+ rule it raises: a floor lighter than the size it raises loses
+ wherever it sits (the topbar's Menu summary is (0,2,1)).
+
+ The block is last before the utilities so it wins ties with every
+ component above it. ui/sizing_test.go fails when a rule styling an
+ a, button, summary or label arrives without a row in the inventory
+ this block implements, and ui/sizing_browser_test.go measures every
+ control at 390 and 1024 with a coarse pointer and at 600 with a
+ mouse. ─────────────────────────────────────────────────────────── */
+@media (pointer: coarse), (max-width: 40rem) {
+ :root { --rst-col-menu: var(--rst-tap); }
+
+ .rst-btn, [rst-btn] { justify-content: center; min-block-size: var(--rst-tap); min-inline-size: var(--rst-tap); }
+ /* min-inline-size for a field in a flex row (a bulk bar), which
+ shrinks it past the floor; a stretched field is far wider anyway. */
+ .rst-input, [rst-input], .rst-textarea, [rst-textarea] { min-block-size: var(--rst-tap); min-inline-size: var(--rst-tap); }
+ /* The box is the target, and so is the input inside it: a tap focuses
+ the input, so the input stretches to the box's full height. */
+ .rst-search, [rst-search] { min-block-size: var(--rst-tap); padding-block: 0; }
+ .rst-search input[type="search"], [rst-search] input[type="search"] { align-self: stretch; }
+ .rst-search__clear, [rst-search-clear] { min-block-size: var(--rst-tap); min-inline-size: var(--rst-tap); }
+ .rst-ftok a, [rst-ftok] a { block-size: var(--rst-tap); inline-size: var(--rst-tap); }
+ .rst-help, [rst-help] { block-size: var(--rst-tap); inline-size: var(--rst-tap); }
+
+ .rst-dropdown > summary, [rst-dropdown] > summary { align-items: center; min-block-size: var(--rst-tap); min-inline-size: var(--rst-tap); }
+ .rst-dropdown__menu .rst-menu-group > summary, [rst-dropdown-menu] [rst-menu-group] > summary { align-items: center; display: flex; min-block-size: var(--rst-tap); }
+ .rst-dropdown__menu a, [rst-dropdown-menu] a, .rst-dropdown__menu button, [rst-dropdown-menu] button, .rst-row-menu__panel a, [rst-row-menu-panel] a, .rst-row-menu__panel button, [rst-row-menu-panel] button, .rst-locale button, [rst-locale] button { align-items: center; box-sizing: border-box; display: flex; min-block-size: var(--rst-tap); }
+ /* The menu cap's 8rem floor exists so a three-item menu opened in a
+ short frame shows whole; three 44px rows are taller than 8rem, so
+ here the floor is three taps plus the panel's padding and border
+ (ui's TestAMenuOpenedInsideAShortFrameIsStillUsable). border-box
+ above for the same reason: the dropdown's links were content-box,
+ and their padding made each row 57px instead of 44. */
+ .rst-dropdown__menu, [rst-dropdown-menu], .rst-row-menu__panel, [rst-row-menu-panel] { max-block-size: min(20rem, max(calc(100dvh - 6rem), calc(3 * var(--rst-tap) + 1rem))); }
+ .rst-combo__option, [rst-combo-option], .rst-dtp__row, [rst-dtp-row] { align-items: center; box-sizing: border-box; display: flex; min-block-size: var(--rst-tap); }
+
+ /* The date field: a 44px pick button, and room in the input so typed
+ text never runs under it. */
+ .rst-dtp__input, [rst-dtp-input] { padding-inline-end: 3rem; }
+ .rst-dtp__pick, [rst-dtp-pick] { block-size: var(--rst-tap); inline-size: var(--rst-tap); inset-inline-end: 0; }
+ .rst-cal__nav, [rst-cal-nav] { block-size: var(--rst-tap); inline-size: var(--rst-tap); }
+ .rst-cal__day, [rst-cal-day] { block-size: var(--rst-tap); }
+ /* The calendar docks to the bottom of the viewport on every small or
+ touch screen. Anchored, it is placed from the field's inline start,
+ and a date field can sit at the trailing end of a field row at any
+ width; docked, it is always inside the viewport. The grid asks for
+ seven taps and the panel is fit-content around it, with the
+ scrollbar gutter reserved, so a classic scrollbar widens the PANEL
+ and never narrows the DAYS. Both max sizes clamp to the viewport:
+ below a 342px viewport the days narrow to about 41px and stay 44px
+ tall, a deliberate exception to the 44px target, because seven full
+ days and the panel's padding do not fit a narrower screen. The
+ height cap is why the panel scrolls itself: six weeks of 44px rows
+ are taller than a phone in landscape, and a fixed panel's clipped
+ top cannot be reached by scrolling the page. top: auto because the
+ base rule's physical top: 100% would otherwise be measured against
+ the viewport; the block-start inset and the margins reset because
+ .is-above sets them (the :is() weighs (0,3,0) and beats it). */
+ :is(.rst-cal, .rst-dtp .rst-cal, .rst-dtp.is-above .rst-cal), :is([rst-cal], [rst-dtp] [rst-cal], [rst-dtp].is-above [rst-cal]) { box-sizing: border-box; inline-size: fit-content; inset-block-end: var(--rst-sp-2); inset-block-start: auto; inset-inline: 0; margin: 0 auto; max-block-size: calc(100dvh - 2 * var(--rst-sp-2)); max-inline-size: calc(100vw - 2 * var(--rst-sp-2)); overflow-y: auto; overscroll-behavior: contain; position: fixed; scrollbar-gutter: stable; top: auto; }
+ :is(.rst-cal, .rst-dtp .rst-cal) .rst-cal__grid, :is([rst-cal], [rst-dtp] [rst-cal]) [rst-cal-grid] { inline-size: calc(7 * var(--rst-tap)); max-inline-size: 100%; }
+
+ /* The row's own controls. The checkbox's label grows by
+ padding, so the 16px box stays 16px and the label is 44×44. */
+ .rst-row__action, [rst-row-action] { align-items: center; box-sizing: border-box; display: inline-flex; justify-content: center; min-block-size: var(--rst-tap); min-inline-size: var(--rst-tap); padding-inline: 1rem; }
+ .rst-row-menu > summary, [rst-row-menu] > summary { block-size: var(--rst-tap); inline-size: var(--rst-tap); }
+ .rst-selbox, [rst-selbox] { box-sizing: border-box; justify-content: center; min-block-size: var(--rst-tap); min-inline-size: var(--rst-tap); padding: 0; }
+ .rst-lrow > .rst-nm, [rst-lrow] > .rst-nm { align-content: center; display: grid; min-block-size: var(--rst-tap); }
+ a.rst-person, a[rst-person] { min-block-size: var(--rst-tap); min-inline-size: var(--rst-tap); }
+
+ .rst-pagination a, [rst-pagination] a, .rst-pagination span, [rst-pagination] span { align-items: center; box-sizing: border-box; display: inline-flex; justify-content: center; min-block-size: var(--rst-tap); min-inline-size: var(--rst-tap); }
+ .rst-seg-tabs a, [rst-seg-tabs] a { align-items: center; box-sizing: border-box; display: inline-flex; justify-content: center; min-block-size: var(--rst-tap); min-inline-size: var(--rst-tap); }
+ .rst-switch, [rst-switch], .rst-choice__cards label, [rst-choice-cards] label, .rst-tblock > .rst-tblock__head, [rst-tblock] > [rst-tblock-head] { min-block-size: var(--rst-tap); }
+
+ /* flex: none because the bar is a flex row that, at 390, shrank the
+ close link to 26px wide to make room for the escalate link's text. */
+ .rst-bulkbar__close, [rst-bulkbar-close] { block-size: var(--rst-tap); flex: none; inline-size: var(--rst-tap); }
+ .rst-bulkbar__escalate, [rst-bulkbar-escalate] { align-items: center; display: inline-flex; justify-content: center; min-block-size: var(--rst-tap); min-inline-size: var(--rst-tap); }
+ .rst-modal-close, [rst-modal-close] { align-items: center; display: inline-flex; justify-content: center; min-block-size: var(--rst-tap); min-inline-size: var(--rst-tap); }
+ .rst-modal-panel > nav a, [rst-modal-panel] > nav a { align-items: center; display: flex; min-block-size: var(--rst-tap); }
+ .rst-back-nav a, [rst-back-nav] a { align-items: center; display: inline-flex; min-block-size: var(--rst-tap); min-inline-size: var(--rst-tap); }
+
+ .rst-shell__brand, [rst-shell-brand] { align-items: center; display: inline-flex; min-block-size: var(--rst-tap); min-inline-size: var(--rst-tap); }
+ .rst-shell-topbar .rst-shell__nav a, [rst-shell-topbar] [rst-shell-nav] a, .rst-shell-sidebar .rst-shell__nav a, [rst-shell-sidebar] [rst-shell-nav] a, .rst-shell-console .rst-shell__nav a, [rst-shell-console] [rst-shell-nav] a { align-items: center; box-sizing: border-box; display: flex; min-block-size: var(--rst-tap); }
+ .rst-shell-topbar .rst-shell__menu > summary, [rst-shell-topbar] [rst-shell-menu] > summary, .rst-shell-console .rst-shell__menu > summary, [rst-shell-console] [rst-shell-menu] > summary { min-block-size: var(--rst-tap); min-inline-size: var(--rst-tap); }
+ /* LEGACY: the old drawer's summary. */
+ .rst-shell-sidebar > .rst-shell__chrome > summary, [rst-shell-sidebar] > [rst-shell-chrome] > summary { min-block-size: var(--rst-tap); }
+ /* The card's own summaries: the open Menu (its pressed state is a
+ (0,3,1) rule) and the account menu inside it. */
+ .rst-shell-topbar .rst-shell__menu[open] > summary, [rst-shell-topbar] [rst-shell-menu][open] > summary, .rst-shell-console--page .rst-shell__menu[open] > summary, .rst-shell-console--index .rst-shell__menu[open] > summary, [rst-shell-console~="page"] [rst-shell-menu][open] > summary, [rst-shell-console~="index"] [rst-shell-menu][open] > summary { min-block-size: var(--rst-tap); min-inline-size: var(--rst-tap); }
+ .rst-shell-topbar .rst-shell__tail > .rst-shell__account > summary, [rst-shell-topbar] [rst-shell-tail] > [rst-shell-account] > summary, .rst-shell-console--page .rst-shell__tail > .rst-shell__account > summary, .rst-shell-console--index .rst-shell__tail > .rst-shell__account > summary, [rst-shell-console~="page"] [rst-shell-tail] > [rst-shell-account] > summary, [rst-shell-console~="index"] [rst-shell-tail] > [rst-shell-account] > summary { min-block-size: var(--rst-tap); }
+ /* The back control (its strip is 2.75rem already; the link is the
+ target) and the index rows, which are 3rem: the nav-link floor above
+ weighs the same as the index's row rule and comes later, so without
+ this it would shrink the rows to 44px on exactly the screens the
+ index is for. */
+ .rst-shell__back a, [rst-shell-back] a { min-inline-size: var(--rst-tap); }
+ .rst-shell-sidebar--index .rst-shell__nav > a, [rst-shell-sidebar~="index"] [rst-shell-nav] > a, .rst-shell-console--index .rst-shell__nav > a, [rst-shell-console~="index"] [rst-shell-nav] > a { min-block-size: 3rem; }
+}
+
/* ---------------------------------------------------------------------
Utilities, last on purpose.
diff --git a/internal/memoria/store.go b/internal/memoria/store.go
index 3b29cb2..9dd7016 100644
--- a/internal/memoria/store.go
+++ b/internal/memoria/store.go
@@ -888,7 +888,7 @@ func (s *Store) Summaries(ctx context.Context) ([]Summary, error) {
Path: path,
Bytes: d.Bytes,
Chunks: int(n),
- Updated: d.UpdatedAt.Format("2 Jan 2006, 15:04"),
+ Updated: stamp(d.UpdatedAt),
})
}
return out, nil
diff --git a/internal/memoria/templates/signin.html b/internal/memoria/templates/signin.html
index 49a53e2..2280307 100644
--- a/internal/memoria/templates/signin.html
+++ b/internal/memoria/templates/signin.html
@@ -3,4 +3,19 @@
the sky; both are vendored from estilo (go run ./scripts/writeestilo)
and linked by the layout. The bar above it carries the brand only:
the corner is empty on the pages that are themselves sign-in. */}}
-{{define "content"}}{{with .Sent}}{{template "estilo-signin-sent" .}}{{else with .SignedOut}}{{template "estilo-signed-out" .}}{{else}}{{template "estilo-signin" .Door}}{{end}}{{end}}
+{{/* The confirm step an emailed link opens on (app.renderConfirm): the
+ door with its address field taken away, and one Sign in button that
+ posts the link's token back. GET /auth/verify spends nothing; this
+ POST does. estilo has no confirm variant, so the form is drawn here
+ between estilo's own door-open and door-close. The token is the
+ link's own query value, escaped here by html/template. */}}
+{{define "content"}}{{with .Confirm}}{{template "estilo-door-open" .Door}}
+ <h1>{{.Door.Heading}}</h1>
+ {{- with .Door.Lede}}
+ <p estilo-signin-lede>{{.}}</p>
+ {{- end}}
+ <form rst-form estilo-signin-form method="post" action="{{.Action}}">
+ <input type="hidden" name="token" value="{{.Token}}">
+ <div rst-form-foot><button rst-btn="primary" type="submit" autofocus>{{.Door.Submit}}</button></div>
+ </form>
+{{template "estilo-door-close"}}{{else with .Sent}}{{template "estilo-signin-sent" .}}{{else with .SignedOut}}{{template "estilo-signed-out" .}}{{else}}{{template "estilo-signin" .Door}}{{end}}{{end}}
diff --git a/internal/memoriatest/admission_test.go b/internal/memoriatest/admission_test.go
index db54944..2d598f3 100644
--- a/internal/memoriatest/admission_test.go
+++ b/internal/memoriatest/admission_test.go
@@ -2,6 +2,7 @@ package memoriatest
import (
"context"
+ "html"
"io"
"log/slog"
"net"
@@ -69,7 +70,7 @@ func admissionApp(t *testing.T, dbPath string, owners []string) (http.Handler, *
InstanceKey: "admission-gate-instance-key",
Owners: owners,
Mailer: mailer,
- }, logger, a.Admit)
+ }, logger, a)
if err != nil {
t.Fatalf("NewAuth: %v", err)
}
@@ -77,9 +78,16 @@ func admissionApp(t *testing.T, dbPath string, owners []string) (http.Handler, *
a.SetOwners(owners)
a.MountAuth(mux, testOrigin)
- // No test may reach the network. The classifier resolves the submitted
- // domain, and a resolver error is what makes it choose the magic-link
- // path rather than keymail — which is the path this gate needs.
+ offlineResolver(t)
+ return mux, mailer
+}
+
+// offlineResolver keeps the test off the network. The classifier resolves
+// the submitted domain, and a resolver error is what makes it choose the
+// magic-link path rather than keymail — which is the path these gates
+// need.
+func offlineResolver(t *testing.T) {
+ t.Helper()
prevResolver := net.DefaultResolver
net.DefaultResolver = &net.Resolver{
PreferGo: true,
@@ -88,7 +96,6 @@ func admissionApp(t *testing.T, dbPath string, owners []string) (http.Handler, *
},
}
t.Cleanup(func() { net.DefaultResolver = prevResolver })
- return mux, mailer
}
// Gate 21, spec §4.3 and §10: admission is read, not held — the lone-shape
@@ -139,10 +146,9 @@ func TestAdmissionRefusesANonOwnerAtVerification(t *testing.T) {
if err != nil {
t.Fatalf("parse magic link: %v", err)
}
- req := httptest.NewRequest(http.MethodGet, u.RequestURI(), nil)
- rr := httptest.NewRecorder()
- h.ServeHTTP(rr, req)
- return rr
+ // Since rastrillo #64 the GET is the confirm door and decides
+ // nothing; admission runs at the POST its Sign in button makes.
+ return newClient(t, h).openLink(u.RequestURI())
}
// The address is an owner when the link is sent, and is not one when
@@ -168,6 +174,22 @@ func TestAdmissionRefusesANonOwnerAtVerification(t *testing.T) {
t.Errorf("a session cookie %q was set for a non-owner", c.Name)
}
}
+ // The refusal is the door (auth's Refused hook, app.refused), not
+ // rastrillo's bare text: Memoria's door with the refusal in its
+ // negative callout, and the address form as the way on.
+ body := html.UnescapeString(rr.Body.String())
+ if !strings.Contains(body, "That address can’t sign in to Memoria.") ||
+ !strings.Contains(body, `rst-tone="negative"`) ||
+ !strings.Contains(body, `name="address"`) ||
+ !strings.Contains(body, "estilo-signin") {
+ t.Errorf("the refusal is not the door with the refusal on it:\n%s", body)
+ }
+ if strings.Contains(body, "verified but not admitted") {
+ t.Error("the refusal is still rastrillo's bare text")
+ }
+ if cc := rr.Header().Get("Cache-Control"); cc != "no-store" {
+ t.Errorf("the refusal door's Cache-Control = %q, want no-store", cc)
+ }
// The other side: the same link under the owners list it was sent
// under must actually mint a session. Without this, every assertion
@@ -181,8 +203,15 @@ func TestAdmissionRefusesANonOwnerAtVerification(t *testing.T) {
}
h4, _ := admissionApp(t, dbPath2, []string{addr})
rr2 := follow(t, h4, link2)
- if rr2.Code == http.StatusForbidden {
- t.Error("an owner was refused at verification: the refusal above proves " +
- "nothing, because this flow refuses everybody")
+ minted := false
+ for _, c := range rr2.Result().Cookies() {
+ if strings.Contains(strings.ToLower(c.Name), "session") && c.Value != "" {
+ minted = true
+ }
+ }
+ if rr2.Code != http.StatusSeeOther || !minted {
+ t.Errorf("an owner was not signed in at verification (status %d, session %v): "+
+ "the refusal above proves nothing, because this flow refuses everybody",
+ rr2.Code, minted)
}
}
diff --git a/internal/memoriatest/chrome_client_test.go b/internal/memoriatest/chrome_client_test.go
index d58edbb..b4ed432 100644
--- a/internal/memoriatest/chrome_client_test.go
+++ b/internal/memoriatest/chrome_client_test.go
@@ -1,12 +1,14 @@
package memoriatest
import (
+ "html"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
+ "regexp"
"strings"
"testing"
@@ -42,6 +44,12 @@ func (c *client) do(method, target string, form url.Values) *httptest.ResponseRe
if method != http.MethodGet && method != http.MethodHead {
req.Header.Set("Origin", testOrigin)
}
+ return c.serve(req)
+}
+
+// serve sends req with the client's Accept and cookies, and keeps the
+// cookies the answer sets.
+func (c *client) serve(req *http.Request) *httptest.ResponseRecorder {
req.Header.Set("Accept", c.accept)
for _, ck := range c.cookies {
req.AddCookie(&http.Cookie{Name: ck.Name, Value: ck.Value})
@@ -63,6 +71,56 @@ func (c *client) get(target string) *httptest.ResponseRecorder {
return c.do(http.MethodGet, target, nil)
}
+// openLink follows an emailed sign-in link the way a person does since
+// rastrillo #64: the GET draws the confirm door and spends nothing, and the
+// door's one Sign in button POSTs the token back. It reads the action and
+// the token off the page (confirmForm) rather than off the link, so a door
+// that lost its form fails here, and it sends what a browser submitting
+// that form sends: Origin and Sec-Fetch-Site: same-origin. It returns the
+// POST's answer — what the GET used to answer.
+func (c *client) openLink(target string) *httptest.ResponseRecorder {
+ c.t.Helper()
+ rec := c.get(target)
+ if rec.Code != http.StatusOK {
+ c.t.Fatalf("GET %s = %d, want the confirm door; body %.300q", target, rec.Code, rec.Body.String())
+ }
+ action, token := confirmForm(c.t, rec.Body.String())
+ return c.postSameOrigin(action, url.Values{"token": {token}})
+}
+
+// postSameOrigin is a form POST carrying the evidence a browser attaches
+// to a same-origin form submission.
+func (c *client) postSameOrigin(target string, form url.Values) *httptest.ResponseRecorder {
+ c.t.Helper()
+ req := httptest.NewRequest(http.MethodPost, target, strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Origin", testOrigin)
+ req.Header.Set("Sec-Fetch-Site", "same-origin")
+ return c.serve(req)
+}
+
+// confirmRe is the confirm door's form (templates/signin.html): the one
+// form on the page, posting the token in a hidden field.
+var confirmRe = regexp.MustCompile(`<form rst-form estilo-signin-form method="post" action="([^"]+)">\s*<input type="hidden" name="token" value="([^"]*)">`)
+
+// confirmForm reads the confirm door off a page and returns where its Sign
+// in button posts and the token it carries. It fails the test unless the
+// page is the door with no address field — the shape renderConfirm draws.
+func confirmForm(t *testing.T, page string) (action, token string) {
+ t.Helper()
+ m := confirmRe.FindStringSubmatch(page)
+ if m == nil {
+ t.Fatalf("the link did not open the confirm door:\n%s", page)
+ }
+ if strings.Contains(page, `name="address"`) {
+ t.Error("the confirm door has an address field; it is one Sign in button")
+ }
+ if !strings.Contains(page, ">Sign in</button>") {
+ t.Error("the confirm door has no Sign in button")
+ }
+ return html.UnescapeString(m[1]), html.UnescapeString(m[2])
+}
+
// location is the redirect a response gave, failing the test if it gave
// none.
func location(t *testing.T, rec *httptest.ResponseRecorder) string {
@@ -95,7 +153,7 @@ func signedInClient(t *testing.T, dbPath string) (*client, http.Handler) {
if err != nil {
t.Fatal(err)
}
- landing := location(t, c.get(u.RequestURI()))
+ landing := location(t, c.openLink(u.RequestURI()))
for i := 0; i < 3 && landing != "/"; i++ {
// The standalone landing (/auth/landed) goes on to the index when
// nothing was remembered.
diff --git a/internal/memoriatest/chrome_test.go b/internal/memoriatest/chrome_test.go
index 933856a..99724ec 100644
--- a/internal/memoriatest/chrome_test.go
+++ b/internal/memoriatest/chrome_test.go
@@ -322,7 +322,7 @@ func TestASignedOutDeepLinkComesBackAfterSignIn(t *testing.T) {
t.Errorf("\"Use a different address\" does not keep the return:\n%s", body)
}
u, _ := url.Parse(mailer.link())
- dest := location(t, c.get(u.RequestURI()))
+ dest := location(t, c.openLink(u.RequestURI()))
for i := 0; i < 3 && dest != deep && strings.HasPrefix(dest, "/auth/"); i++ {
dest = location(t, c.get(dest))
}
diff --git a/internal/memoriatest/csp_browser_test.go b/internal/memoriatest/csp_browser_test.go
new file mode 100644
index 0000000..bb2b28d
--- /dev/null
+++ b/internal/memoriatest/csp_browser_test.go
@@ -0,0 +1,234 @@
+//go:build browser
+
+package memoriatest
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "io"
+ "log/slog"
+ "mime/multipart"
+ "net/http"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "testing"
+ "time"
+
+ "amadan.net/rastrillo/rastrillo"
+ "amadan.net/rastrillo/rastrillo/db"
+ "amadan.net/rastrillo/rastrillo/harness"
+ "github.com/chromedp/cdproto/page"
+ "github.com/chromedp/cdproto/runtime"
+ "github.com/chromedp/chromedp"
+
+ memoria "amadan.net/oficina/memoria/internal/memoria"
+)
+
+// cspWatch records every securitypolicyviolation a page raises, from
+// before its first byte is parsed, into window.__csp.
+const cspWatch = `window.__csp = [];
+document.addEventListener("securitypolicyviolation", e =>
+ window.__csp.push(e.violatedDirective + " " + (e.blockedURI || "inline") + " " + (e.sample || "")));`
+
+// cspProbe is the presence half. It writes one inline style attribute the
+// way a page or script would, and reports whether the browser refused it.
+// A refusal proves the listener is live and the page really runs under a
+// policy that blocks inline styles. Without it, "no violations" would pass
+// against a policy that allows inline styles, or a listener that never
+// attached.
+const cspProbe = `new Promise(done => {
+ const before = window.__csp.length;
+ const probe = document.createElement("i");
+ probe.setAttribute("style", "outline: 1px solid");
+ document.body.appendChild(probe);
+ setTimeout(() => {
+ const refused = window.__csp.length > before;
+ window.__csp.length = before;
+ probe.remove();
+ done(refused);
+ }, 200);
+})`
+
+// TestPagesRenderUnderTheProductionCSP: every page Memoria serves
+// standalone, in Chromium, through rastrillo's production stack
+// (memoria.ServeOptions under rastrillo.Handler, so the real CSP), raises
+// no Content-Security-Policy violation. That includes the sign-in door,
+// the confirm door an emailed link opens on, the signed-in pages, and
+// search.js's live results swapped in by script. rastrillo fb806a2 refuses
+// inline style attributes by default, and this is the check that nothing
+// Memoria draws or scripts relies on one.
+//
+// The plain-Go TestNoPageCarriesAStyleAttribute reads the served markup in
+// CI; this one also sees what scripts do after load. The other browser
+// checks serve the bare router and get no CSP at all, so they could not
+// catch this.
+//
+// MUTATIONS (author-chosen, 2026-10-06; an independent pass is owed per
+// AGENTS.md): style="color:red" on the confirm form in signin.html fails
+// here; ServeOptions setting the pre-fb806a2 policy (style-src 'self'
+// 'unsafe-inline') fails the probe; the native approval route without its
+// hash (651f370) fails on style-src-elem; search.js assigning
+// main.style.outline fails the [style] check. Reverted.
+func TestPagesRenderUnderTheProductionCSP(t *testing.T) {
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ served := func(t *testing.T, test bool) func(origin string) http.Handler {
+ return func(origin string) http.Handler {
+ d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger)
+ if err != nil {
+ t.Fatalf("db.Open: %v", err)
+ }
+ t.Cleanup(func() { d.Close() })
+ build := memoria.App
+ if test {
+ build = memoria.AppForTest
+ }
+ mux, a, err := build(d, origin, testDEK(t), logger)
+ if err != nil {
+ t.Fatalf("App: %v", err)
+ }
+ if !test {
+ a.SetOwners([]string{chromeOwner})
+ au, err := memoria.NewAuth(d.Writer(), memoria.AuthEnv{
+ Origin: origin, InstanceKey: "csp-browser-instance-key",
+ Owners: []string{chromeOwner}, Mailer: &capturingMailer{},
+ }, logger, a)
+ if err != nil {
+ t.Fatalf("NewAuth: %v", err)
+ }
+ a.UseAuth(au)
+ a.MountAuth(mux, origin)
+ }
+ h, done, err := rastrillo.Handler(a.ServeOptions(rastrillo.Options{Logger: logger}, mux))
+ if err != nil {
+ t.Fatalf("rastrillo.Handler: %v", err)
+ }
+ if done != nil {
+ t.Cleanup(func() { _ = done() })
+ }
+ return h
+ }
+ }
+
+ visit := func(t *testing.T, rig *harness.Rig, path, ready string, then ...chromedp.Action) {
+ t.Helper()
+ ctx, cancel := context.WithTimeout(rig.Context(), 20*time.Second)
+ defer cancel()
+ var violations, styled []string
+ var refused bool
+ actions := []chromedp.Action{
+ chromedp.Navigate(rig.Origin + path),
+ chromedp.WaitVisible(ready, chromedp.ByQuery),
+ }
+ actions = append(actions, then...)
+ actions = append(actions,
+ chromedp.Sleep(150*time.Millisecond),
+ chromedp.Evaluate(`window.__csp`, &violations),
+ // A script assigning element.style.x raises no violation (the
+ // CSSOM is not policed), so look at the DOM itself too (Astra
+ // on #27): no element may carry a style attribute.
+ chromedp.Evaluate(`[...document.querySelectorAll("[style]")].map(e => e.tagName.toLowerCase() + '[style="' + e.getAttribute("style") + '"]')`, &styled),
+ chromedp.Evaluate(cspProbe, &refused, func(p *runtime.EvaluateParams) *runtime.EvaluateParams { return p.WithAwaitPromise(true) }),
+ )
+ if err := chromedp.Run(ctx, actions...); err != nil {
+ t.Fatalf("%s: %v", path, err)
+ }
+ if len(violations) > 0 {
+ t.Errorf("%s: the production CSP refused: %s", path, strings.Join(violations, "; "))
+ }
+ if len(styled) > 0 {
+ t.Errorf("%s: elements carry inline styles after load: %s", path, strings.Join(styled, "; "))
+ }
+ if !refused {
+ t.Errorf("%s: an inline style attribute was NOT refused: this page is not under rastrillo's strict policy, or the watch never attached", path)
+ }
+ }
+ watch := func(t *testing.T, rig *harness.Rig) {
+ t.Helper()
+ if err := chromedp.Run(rig.Context(), chromedp.ActionFunc(func(ctx context.Context) error {
+ _, err := page.AddScriptToEvaluateOnNewDocument(cspWatch).Do(ctx)
+ return err
+ })); err != nil {
+ t.Fatal(err)
+ }
+ }
+
+ t.Run("signed out", func(t *testing.T) {
+ rig := harness.New(t, served(t, false))
+ watch(t, rig)
+ visit(t, rig, "/signin", `input[name="address"]`)
+ visit(t, rig, "/signin?err=expired", `[rst-callout]`)
+ visit(t, rig, "/signin?sent=1", `[estilo-door]`)
+ visit(t, rig, "/signed-out", `[estilo-door]`)
+ visit(t, rig, "/auth/verify?token=not-a-real-token", `[estilo-signin-form] button[type="submit"]`)
+ visit(t, rig, "/help/open-source", `main`)
+ })
+
+ t.Run("signed in", func(t *testing.T) {
+ rig := harness.New(t, served(t, true))
+ watch(t, rig)
+ var buf bytes.Buffer
+ mw := multipart.NewWriter(&buf)
+ _ = mw.WriteField("title", "Office handbook")
+ _ = mw.WriteField("body", "The Lisbon office is at Rua Augusta 100.")
+ mw.Close()
+ req, _ := http.NewRequest(http.MethodPost, rig.Origin+"/documents", &buf)
+ req.Header.Set("Content-Type", mw.FormDataContentType())
+ req.Header.Set("Origin", rig.Origin)
+ req.Header.Set("Sec-Fetch-Site", "same-origin")
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("store a document: %v", err)
+ }
+ res.Body.Close()
+
+ var at string
+ visit(t, rig, "/", `a[href^="/documents/"]`,
+ chromedp.AttributeValue(`a[href^="/documents/"]`, "href", &at, nil, chromedp.ByQuery))
+ doc := regexp.MustCompile(`^/documents/\d+$`).FindString(at)
+ if doc == "" {
+ t.Fatalf("the index links no document (got %q)", at)
+ }
+ // search.js: typing swaps the results in by script.
+ visit(t, rig, "/", `form[rst-search] input[type="search"]`,
+ chromedp.SendKeys(`form[rst-search] input[type="search"]`, "Lisbon", chromedp.ByQuery),
+ chromedp.WaitVisible(`[rst-search-results-for="Lisbon"]`, chromedp.ByQuery))
+ visit(t, rig, "/?q=Lisbon", `main`)
+ visit(t, rig, "/add", `textarea[name="body"]`)
+ visit(t, rig, doc, `main`)
+ visit(t, rig, doc+"/delete", `main`)
+ visit(t, rig, "/connectors", `main`)
+ visit(t, rig, "/connectors/notion", `input[name="token"]`)
+
+ // The native kit's approval page (Astra on #27, P2): its whole
+ // stylesheet is one inline <style>, admitted by hash on that route
+ // alone (memoria native.go). On 651f370 this was refused
+ // (style-src-elem), and the card and button went unstyled — so
+ // also check the stylesheet applied: the button is the kit's
+ // purple, not the browser's grey.
+ sum := sha256.Sum256([]byte(strings.Repeat("v", 43)))
+ nreq, _ := http.NewRequest(http.MethodPost, rig.Origin+"/native/request",
+ strings.NewReader(`{"challenge":"`+base64.RawURLEncoding.EncodeToString(sum[:])+`"}`))
+ nreq.Header.Set("Content-Type", "application/json")
+ nreq.Header.Set("Origin", rig.Origin)
+ nres, err := http.DefaultClient.Do(nreq)
+ if err != nil {
+ t.Fatalf("POST /native/request: %v", err)
+ }
+ var native struct{ ID string }
+ _ = json.NewDecoder(nres.Body).Decode(&native)
+ nres.Body.Close()
+ if native.ID == "" {
+ t.Fatalf("POST /native/request = %d with no id", nres.StatusCode)
+ }
+ var bg string
+ visit(t, rig, "/native/authorize?id="+native.ID, `main form button`,
+ chromedp.Evaluate(`getComputedStyle(document.querySelector("main form button")).backgroundColor`, &bg))
+ if bg != "rgb(112, 82, 162)" {
+ t.Errorf("/native/authorize: the approval button's background is %q, want the kit's #7052a2 — its stylesheet did not apply", bg)
+ }
+ })
+}
diff --git a/internal/memoriatest/errors_gate_test.go b/internal/memoriatest/errors_gate_test.go
index 965014c..90a8357 100644
--- a/internal/memoriatest/errors_gate_test.go
+++ b/internal/memoriatest/errors_gate_test.go
@@ -30,7 +30,7 @@ var plainTextErrors = map[string]struct {
"handlers.go:writeOutcome": {1, "the upload tray's JSON path: a bare non-200 is what the tray reads as \"no answer\" (spec §3.3 keeps it)"},
"native.go:nativeSearch": {4, "the native client's JSON API; it shows these strings itself"},
"native.go:nativeNote": {3, "the native client's JSON API; it shows these strings itself"},
- "render.go:render": {1, "a page template failed to execute: the error page is built from the same layout, so the plainest answer is the one left"},
+ "render.go:renderStatus": {1, "a page template failed to execute: the error page is built from the same layout, so the plainest answer is the one left"},
"render.go:renderFragment": {1, "search.js's results fragment: a fetch, not a navigation, and search.js reads a non-200 as a search that did not run"},
}
diff --git a/internal/memoriatest/native_csp_test.go b/internal/memoriatest/native_csp_test.go
new file mode 100644
index 0000000..34423c8
--- /dev/null
+++ b/internal/memoriatest/native_csp_test.go
@@ -0,0 +1,101 @@
+package memoriatest
+
+import (
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "regexp"
+ "strings"
+ "testing"
+)
+
+// nativeApprovalPage starts a native sign-in on h and opens its approval
+// page as c, the signed-in browser, returning the response.
+func nativeApprovalPage(t *testing.T, h http.Handler, c *client) *httptest.ResponseRecorder {
+ t.Helper()
+ sum := sha256.Sum256([]byte(strings.Repeat("v", 43)))
+ req := httptest.NewRequest(http.MethodPost, testOrigin+"/native/request",
+ strings.NewReader(`{"challenge":"`+base64.RawURLEncoding.EncodeToString(sum[:])+`"}`))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Origin", testOrigin)
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ var out struct{ ID string }
+ if rec.Code != http.StatusOK || json.Unmarshal(rec.Body.Bytes(), &out) != nil || out.ID == "" {
+ t.Fatalf("POST /native/request = %d %q", rec.Code, rec.Body.String())
+ }
+ page := c.get(testOrigin + "/native/authorize?id=" + out.ID)
+ if page.Code != http.StatusOK || !strings.Contains(page.Body.String(), "Sign in to Memoria app") {
+ t.Fatalf("GET /native/authorize = %d, not the approval page:\n%s", page.Code, page.Body.String())
+ }
+ return page
+}
+
+var styleElement = regexp.MustCompile(`(?s)<style>(.*?)</style>`)
+
+// Astra on #27, P2: the native kit's approval page (/native/authorize) is
+// styled by one inline <style> element, and rastrillo fb806a2's strict
+// default refused it, so the page lost its card, spacing and button. The
+// fix allows that stylesheet by its hash, on that route only.
+//
+// Read through the production stack: the served stylesheet's own hash
+// must be in the route's style-src (so a native bump that changes the CSS
+// fails here, not on a phone), and with that one source taken out the
+// policy is rastrillo's default exactly, so nothing else is relaxed. No
+// other page carries the hash.
+//
+// Fails on 651f370 (no hash on the route). MUTATIONS (author-chosen,
+// 2026-10-06): dropping the wrapper from the GET route; 'unsafe-inline'
+// in place of the hash; the wrapper on every route. Each fails here.
+// Reverted.
+func TestNativeApprovalStyleIsTheHashedOne(t *testing.T) {
+ c, h := signedInServedClient(t)
+ page := nativeApprovalPage(t, h, c)
+ m := styleElement.FindStringSubmatch(page.Body.String())
+ if m == nil {
+ t.Fatal("the approval page has no <style> element; if native/server moved its CSS out, drop the hash (memoria native.go)")
+ }
+ sum := sha256.Sum256([]byte(m[1]))
+ source := "'sha256-" + base64.StdEncoding.EncodeToString(sum[:]) + "'"
+
+ policies := page.Header().Values("Content-Security-Policy")
+ if len(policies) != 1 {
+ t.Fatalf("CSP values = %q, want one", policies)
+ }
+ if !strings.Contains(policies[0], "style-src ") || !strings.Contains(policies[0], " "+source) {
+ t.Fatalf("the approval page's CSP %q does not admit its own stylesheet %s", policies[0], source)
+ }
+ if strings.Contains(policies[0], "'unsafe-inline'") {
+ t.Errorf("the approval page allows every inline style: %q", policies[0])
+ }
+ if got, want := strings.Replace(policies[0], " "+source, "", 1), defaultCSP(t); got != want {
+ t.Errorf("with the native hash taken out, the policy is %q, want rastrillo's default %q", got, want)
+ }
+
+ for _, path := range []string{"/", "/signin", "/auth/verify?token=x"} {
+ if got := strings.Join(c.get(testOrigin+path).Header().Values("Content-Security-Policy"), "\n"); strings.Contains(got, source) {
+ t.Errorf("%s carries the native approval page's hash: %q", path, got)
+ }
+ }
+}
+
+// signedInServedClient is a browser signed in through the real magic link
+// on servedApp, the production stack.
+func signedInServedClient(t *testing.T) (*client, http.Handler) {
+ t.Helper()
+ h, mailer := servedApp(t, tempDB(t), []string{chromeOwner})
+ c := newClient(t, h)
+ if rec := c.postSameOrigin(testOrigin+"/auth/begin", map[string][]string{"address": {chromeOwner}}); rec.Code != http.StatusSeeOther {
+ t.Fatalf("POST /auth/begin = %d", rec.Code)
+ }
+ link := regexp.MustCompile(`/auth/verify\?[^\s<>"]+`).FindString(mailer.text())
+ if link == "" {
+ t.Fatalf("no magic link: %q", mailer.text())
+ }
+ if rec := c.openLink(testOrigin + link); rec.Code != http.StatusSeeOther {
+ t.Fatalf("redeem = %d", rec.Code)
+ }
+ return c, h
+}
diff --git a/internal/memoriatest/routes_test.go b/internal/memoriatest/routes_test.go
index 7da4663..1501722 100644
--- a/internal/memoriatest/routes_test.go
+++ b/internal/memoriatest/routes_test.go
@@ -296,7 +296,13 @@ var declaredChiRoutes = map[string]bool{
"POST /auth/begin": true,
"GET /auth/callback": true,
"GET /auth/verify": true,
- "POST /signout": true,
+ // rastrillo #64 (pin fb806a2, 2026-10-06): GET /auth/verify is the
+ // confirm door and spends nothing; this POST, from its Sign in
+ // button, redeems the link. auth's own same-origin check runs before
+ // the token is touched; in Home mode it sits under LocalGuard like
+ // the GET.
+ "POST /auth/verify": true,
+ "POST /signout": true,
// The connector surface (§5.1), added by the Notion work and
// declared here on 2026-09-06 when this gate caught it — which is
diff --git a/internal/memoriatest/signin_confirm_test.go b/internal/memoriatest/signin_confirm_test.go
new file mode 100644
index 0000000..0bae402
--- /dev/null
+++ b/internal/memoriatest/signin_confirm_test.go
@@ -0,0 +1,325 @@
+package memoriatest
+
+import (
+ "bytes"
+ "html"
+ "io"
+ "log/slog"
+ "mime/multipart"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "regexp"
+ "strings"
+ "testing"
+
+ "amadan.net/rastrillo/rastrillo"
+ "amadan.net/rastrillo/rastrillo/db"
+
+ memoria "amadan.net/oficina/memoria/internal/memoria"
+)
+
+// servedApp is the production app behind rastrillo's production stack:
+// memoria.App with real sign-in, mounted as main.go mounts it, completed
+// by memoria.ServeOptions and wrapped by rastrillo.Handler — so the CSP,
+// the framing headers and the framework's own same-origin checks are the
+// ones a deployed instance sends. owners is the admission list; mail is
+// captured.
+func servedApp(t *testing.T, dbPath string, owners []string) (http.Handler, *capturingMailer) {
+ t.Helper()
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ d, err := db.Open(dbPath, logger)
+ if err != nil {
+ t.Fatalf("db.Open: %v", err)
+ }
+ t.Cleanup(func() { d.Close() })
+ mux, a, err := memoria.App(d, testOrigin, testDEK(t), logger)
+ if err != nil {
+ t.Fatalf("App: %v", err)
+ }
+ mailer := &capturingMailer{}
+ a.SetOwners(owners)
+ au, err := memoria.NewAuth(d.Writer(), memoria.AuthEnv{
+ Origin: testOrigin, InstanceKey: "admission-gate-instance-key",
+ Owners: owners, Mailer: mailer,
+ }, logger, a)
+ if err != nil {
+ t.Fatalf("NewAuth: %v", err)
+ }
+ a.UseAuth(au)
+ a.MountAuth(mux, testOrigin)
+ h := production(t, a.ServeOptions(rastrillo.Options{Logger: logger}, mux))
+ offlineResolver(t)
+ return h, mailer
+}
+
+// production wraps opts in rastrillo.Handler, the stack Serve runs.
+func production(t *testing.T, opts rastrillo.Options) http.Handler {
+ t.Helper()
+ h, done, err := rastrillo.Handler(opts)
+ if err != nil {
+ t.Fatalf("rastrillo.Handler: %v", err)
+ }
+ if done != nil {
+ t.Cleanup(func() { _ = done() })
+ }
+ return h
+}
+
+// defaultCSP is rastrillo's own policy at the pinned version, read off a
+// bare handler rather than copied, so a tightening upstream is what the
+// tests below expect next time without anybody editing them.
+func defaultCSP(t *testing.T) string {
+ t.Helper()
+ mux := http.NewServeMux()
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {})
+ rec := httptest.NewRecorder()
+ production(t, rastrillo.Options{Mux: mux}).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, testOrigin+"/", nil))
+ csp := rec.Header().Get("Content-Security-Policy")
+ if csp == "" {
+ t.Fatal("a bare rastrillo handler sends no CSP; this measures nothing")
+ }
+ return csp
+}
+
+// sessionSet reports whether rec set a live session cookie.
+func sessionSet(rec *httptest.ResponseRecorder) bool {
+ for _, c := range rec.Result().Cookies() {
+ if strings.Contains(strings.ToLower(c.Name), "session") && c.Value != "" && c.MaxAge >= 0 {
+ return true
+ }
+ }
+ return false
+}
+
+// rastrillo #64, end to end through the production stack: opening an
+// emailed link spends nothing; the confirm door's Sign in button does.
+//
+// begin → mail → the link opened twice (a mail scanner's fetch, then the
+// person's: both the door, neither a session) → a cross-site POST and a
+// POST with no browser evidence, both refused WITHOUT spending the link →
+// the door's own POST signs in and the session opens the index → the same
+// token again is ?err=expired.
+func TestEmailedLinkOpensTheConfirmDoor(t *testing.T) {
+ h, mailer := servedApp(t, tempDB(t), []string{chromeOwner})
+ c := newClient(t, h)
+ if rec := c.do(http.MethodPost, testOrigin+"/auth/begin", url.Values{"address": {chromeOwner}}); rec.Code != http.StatusSeeOther {
+ t.Fatalf("POST /auth/begin = %d", rec.Code)
+ }
+ link := mailer.link()
+ if link == "" {
+ t.Fatalf("no magic link was sent: %q", mailer.text())
+ }
+ u, err := url.Parse(link)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Twice: a scanner, then the person. A GET mints nothing.
+ var action, token string
+ for _, who := range []*client{newClient(t, h), c} {
+ rec := who.get(testOrigin + u.RequestURI())
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET the link = %d, want the confirm door", rec.Code)
+ }
+ if sessionSet(rec) {
+ t.Fatal("opening the link minted a session; the GET must spend nothing")
+ }
+ action, token = confirmForm(t, rec.Body.String())
+ }
+ if action != "/auth/verify" || token != u.Query().Get("token") {
+ t.Fatalf("the door posts %q with token %q, want /auth/verify and the link's own token", action, token)
+ }
+
+ post := func(origin, site string) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(http.MethodPost, testOrigin+action, strings.NewReader(url.Values{"token": {token}}.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ if origin != "" {
+ req.Header.Set("Origin", origin)
+ }
+ if site != "" {
+ req.Header.Set("Sec-Fetch-Site", site)
+ }
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ return rec
+ }
+ for name, rec := range map[string]*httptest.ResponseRecorder{
+ "cross-site": post("https://evil.example", "cross-site"),
+ "no browser proof": post("", ""),
+ } {
+ if rec.Code != http.StatusForbidden || sessionSet(rec) {
+ t.Errorf("%s POST = %d (session %v), want 403 and no session", name, rec.Code, sessionSet(rec))
+ }
+ }
+
+ // The link survived both: the door's own POST signs in.
+ rec := c.postSameOrigin(testOrigin+action, url.Values{"token": {token}})
+ if rec.Code != http.StatusSeeOther || !sessionSet(rec) {
+ t.Fatalf("the door's POST = %d (session %v), want 303 and a session — the refused POSTs above spent the link", rec.Code, sessionSet(rec))
+ }
+ dest := rec.Header().Get("Location")
+ for i := 0; i < 3 && dest != "/"; i++ {
+ dest = location(t, c.get(testOrigin+dest))
+ }
+ if dest != "/" {
+ t.Fatalf("sign-in landed on %q, want /", dest)
+ }
+ if got := c.get(testOrigin + "/"); got.Code != http.StatusOK || !strings.Contains(got.Body.String(), "estilo-account") {
+ t.Fatalf("GET / with the new session = %d, or it is not signed in", got.Code)
+ }
+
+ // Spent: the same token again is ?err=expired, and no session.
+ replay := newClient(t, h).postSameOrigin(testOrigin+action, url.Values{"token": {token}})
+ if replay.Code != http.StatusSeeOther || replay.Header().Get("Location") != "/signin?err=expired" || sessionSet(replay) {
+ t.Errorf("a replayed token = %d %q, want 303 /signin?err=expired and no session", replay.Code, replay.Header().Get("Location"))
+ }
+}
+
+// The confirm door is drawn by Memoria (renderConfirm), inside its own
+// layout — and auth's headers survive the render: no-store, the
+// strict-origin Referrer-Policy (the URL is a live credential, and the
+// layout's stylesheet and script requests would otherwise carry it as
+// their Referer), the framing refusals, and BOTH CSP values: rastrillo's
+// policy, then auth's added frame-ancestors 'none'.
+//
+// MUTATIONS (author-chosen, 2026-10-06; an independent pass is owed per
+// AGENTS.md): renderConfirm setting Referrer-Policy to
+// strict-origin-when-cross-origin, or Set-ing a CSP of its own, each fail
+// here. Reverted.
+func TestTheConfirmDoorKeepsEveryPolicy(t *testing.T) {
+ h, _ := servedApp(t, tempDB(t), []string{chromeOwner})
+ const evil = `"><script>alert(1)</script>`
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, testOrigin+"/auth/verify?token="+url.QueryEscape(evil), nil))
+ if rec.Code != http.StatusOK {
+ t.Fatalf("GET /auth/verify = %d", rec.Code)
+ }
+ hd := rec.Header()
+ for name, want := range map[string]string{
+ "Cache-Control": "no-store",
+ "Referrer-Policy": "strict-origin",
+ "X-Frame-Options": "DENY",
+ "X-Robots-Tag": "noindex, nofollow",
+ } {
+ if got := hd.Get(name); got != want {
+ t.Errorf("%s = %q, want %q", name, got, want)
+ }
+ }
+ csp := hd.Values("Content-Security-Policy")
+ if want := []string{defaultCSP(t), "frame-ancestors 'none'"}; strings.Join(csp, "\n") != strings.Join(want, "\n") {
+ t.Errorf("CSP values = %q, want %q", csp, want)
+ }
+ body := rec.Body.String()
+ _, token := confirmForm(t, body)
+ if token != evil {
+ t.Errorf("the door carries token %q, want the link's own", token)
+ }
+ if strings.Contains(body, "<script>alert(1)") {
+ t.Error("the token reached the page unescaped")
+ }
+ for _, want := range []string{"<header estilo-bar", "estilo-door", "Select Sign in to finish signing in."} {
+ if !strings.Contains(body, want) {
+ t.Errorf("the confirm door is not Memoria's door: no %q", want)
+ }
+ }
+ if strings.Contains(body, "<footer") {
+ t.Error("the confirm door carries the footer; sign-in pages do not")
+ }
+}
+
+// Standalone, Memoria serves rastrillo's own CSP, unchanged: no page or
+// script writes an inline style (memoria.ServeOptions), so there is
+// nothing to loosen style-src for. Read through ServeOptions and
+// rastrillo.Handler, the stack main runs.
+//
+// MUTATIONS (author-chosen, 2026-10-06; an independent pass is owed):
+// ServeOptions setting opts.CSP to the pre-fb806a2 policy (style-src
+// 'self' 'unsafe-inline') fails here. Reverted.
+func TestStandaloneServesRastrillosDefaultCSP(t *testing.T) {
+ h, _ := servedApp(t, tempDB(t), []string{chromeOwner})
+ want := defaultCSP(t)
+ if strings.Contains(want, "'unsafe-inline'") {
+ t.Fatalf("rastrillo's default allows inline styles again (%q); this test is about the stricter one", want)
+ }
+ for _, path := range []string{"/signin", "/signin?err=expired", "/signed-out", "/auth/verify?token=x", "/help/open-source", "/no-such-page"} {
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, testOrigin+path, nil))
+ if got := rec.Header().Values("Content-Security-Policy"); len(got) == 0 || got[0] != want {
+ t.Errorf("%s: CSP %q, want rastrillo's default %q", path, got, want)
+ }
+ }
+}
+
+// styleAttr is an inline style attribute in markup: the thing the
+// default CSP refuses.
+var styleAttr = regexp.MustCompile(`(?i)<[a-z][^>]*\sstyle\s*=`)
+
+// No page Memoria serves standalone carries an inline style attribute,
+// signed out or in — the rendered markup, estilo's and rastrillo's
+// partials included, not a grep of our own templates. This is what lets
+// TestStandaloneServesRastrillosDefaultCSP's policy stand; the browser
+// check (TestPagesRenderUnderTheProductionCSP) watches the scripts too.
+//
+// MUTATIONS (author-chosen, 2026-10-06; an independent pass is owed):
+// style="color:red" on layout.html's <main>, on the index's document-count
+// heading, and on signin.html's confirm form each fail here. Reverted.
+func TestNoPageCarriesAStyleAttribute(t *testing.T) {
+ check := func(h http.Handler, path string) {
+ t.Helper()
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, testOrigin+path, nil))
+ // The page itself, not a redirect away from it: a 303 has no
+ // markup to find a style in, and would pass by measuring nothing.
+ want := http.StatusOK
+ if path == "/no-such-page" {
+ want = http.StatusNotFound
+ }
+ if rec.Code != want {
+ t.Fatalf("GET %s = %d, want %d", path, rec.Code, want)
+ }
+ if m := styleAttr.FindString(rec.Body.String()); m != "" {
+ t.Errorf("%s (%d) carries an inline style, which rastrillo's CSP refuses: %s", path, rec.Code, html.UnescapeString(m))
+ }
+ }
+
+ out, _ := servedApp(t, tempDB(t), []string{chromeOwner})
+ for _, path := range []string{"/signin", "/signin?err=expired", "/signin?sent=1", "/signin?err=address", "/signin?reauth=1", "/signed-out", "/auth/verify?token=x", "/help/open-source"} {
+ check(out, path)
+ }
+
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ d, err := db.Open(tempDB(t), logger)
+ if err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() { d.Close() })
+ mux, a, err := memoria.AppForTest(d, testOrigin, testDEK(t), logger)
+ if err != nil {
+ t.Fatal(err)
+ }
+ in := production(t, a.ServeOptions(rastrillo.Options{Logger: logger}, mux))
+ var buf bytes.Buffer
+ mw := multipart.NewWriter(&buf)
+ _ = mw.WriteField("title", "Office handbook")
+ _ = mw.WriteField("body", "The Lisbon office is at Rua Augusta 100.")
+ mw.Close()
+ req := httptest.NewRequest(http.MethodPost, testOrigin+"/documents", &buf)
+ req.Header.Set("Content-Type", mw.FormDataContentType())
+ req.Header.Set("Origin", testOrigin)
+ req.Header.Set("Sec-Fetch-Site", "same-origin")
+ rec := httptest.NewRecorder()
+ in.ServeHTTP(rec, req)
+ if rec.Code != http.StatusSeeOther {
+ t.Fatalf("store a document = %d", rec.Code)
+ }
+ index := httptest.NewRecorder()
+ in.ServeHTTP(index, httptest.NewRequest(http.MethodGet, testOrigin+"/", nil))
+ doc := regexp.MustCompile(`href="(/documents/\d+)"`).FindStringSubmatch(index.Body.String())
+ if doc == nil {
+ t.Fatal("the index links no document; the document pages below would be measured as 404s")
+ }
+ for _, path := range []string{"/", "/?q=Lisbon", "/add", "/add?stored=1", doc[1], doc[1] + "/delete", "/search?q=Lisbon", "/connectors", "/connectors/notion", "/help/open-source", "/no-such-page"} {
+ check(in, path)
+ }
+}
diff --git a/internal/memoriatest/signin_keymail_test.go b/internal/memoriatest/signin_keymail_test.go
index 9e3e943..b71cfae 100644
--- a/internal/memoriatest/signin_keymail_test.go
+++ b/internal/memoriatest/signin_keymail_test.go
@@ -3,6 +3,9 @@ package memoriatest
import (
"context"
"encoding/binary"
+ "encoding/json"
+ "fmt"
+ "html"
"io"
"log/slog"
"net"
@@ -10,9 +13,11 @@ import (
"net/http/httptest"
"net/url"
"path/filepath"
+ "regexp"
"strings"
stdsync "sync"
"testing"
+ "time"
"amadan.net/rastrillo/rastrillo/db"
@@ -21,12 +26,27 @@ import (
// fakeKeymail answers the two probes signin's classifier makes of a
// Keymail server — /.well-known/keymail and /api/federation/lookup — for
-// one host, and 404s everything else. It stands in for http.DefaultTransport,
-// which the classifier's client falls back to.
+// one host, and the token exchange for the codes approve hands out, and
+// 404s everything else. It stands in for http.DefaultTransport, which the
+// classifier's and the callback's clients fall back to.
type fakeKeymail struct {
- host string
- mu stdsync.Mutex
- hits []string
+ host string
+ mu stdsync.Mutex
+ hits []string
+ codes map[string]string
+}
+
+// approve is Keymail's consent screen saying yes for address: the code
+// the browser carries back to /auth/callback.
+func (f *fakeKeymail) approve(address string) string {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ if f.codes == nil {
+ f.codes = map[string]string{}
+ }
+ code := fmt.Sprintf("code-%d", len(f.codes)+1)
+ f.codes[code] = address
+ return code
}
func (f *fakeKeymail) RoundTrip(r *http.Request) (*http.Response, error) {
@@ -40,6 +60,21 @@ func (f *fakeKeymail) RoundTrip(r *http.Request) (*http.Response, error) {
body, code = `{"version":"1"}`, http.StatusOK
case "/api/federation/lookup":
code = http.StatusOK
+ case "/api/oauth/token":
+ var in map[string]string
+ _ = json.NewDecoder(r.Body).Decode(&in)
+ f.mu.Lock()
+ address, ok := f.codes[in["code"]]
+ delete(f.codes, in["code"])
+ f.mu.Unlock()
+ body, code = `{"error":"invalid_grant"}`, http.StatusBadRequest
+ if ok && r.Method == http.MethodPost {
+ out, _ := json.Marshal(map[string]any{
+ "access_token": "synthetic", "token_type": "bearer",
+ "address": address, "auth_time": time.Now().Unix(),
+ })
+ body, code = string(out), http.StatusOK
+ }
}
}
return &http.Response{StatusCode: code, Body: io.NopCloser(strings.NewReader(body)),
@@ -94,9 +129,19 @@ func nxdomainResolver() *net.Resolver {
// keymailSigninApp is the real app with the real sign-in wired, whose
// owner's domain resolves — through the fakes above — to a Keymail server.
func keymailSigninApp(t *testing.T) (http.Handler, *fakeKeymail) {
+ t.Helper()
+ km := &fakeKeymail{host: "example.test"}
+ return keymailSigninAppWith(t, filepath.Join(t.TempDir(), "app.db"), []string{dialTestOwner}, km), km
+}
+
+// keymailSigninAppWith is keymailSigninApp over a given database and
+// owners list and Keymail fake: two apps over one database share the
+// pending state, so a sign-in begun under one owners list can come back
+// under another.
+func keymailSigninAppWith(t *testing.T, dbPath string, owners []string, km *fakeKeymail) http.Handler {
t.Helper()
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
- d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger)
+ d, err := db.Open(dbPath, logger)
if err != nil {
t.Fatal(err)
}
@@ -107,20 +152,19 @@ func keymailSigninApp(t *testing.T) (http.Handler, *fakeKeymail) {
}
au, err := memoria.NewAuth(d.Writer(), memoria.AuthEnv{
Origin: testOrigin, InstanceKey: "signin-keymail-instance-key",
- Owners: []string{dialTestOwner}, Mailer: &stubMailer{},
- }, logger, a.Admit)
+ Owners: owners, Mailer: &stubMailer{},
+ }, logger, a)
if err != nil {
t.Fatal(err)
}
a.UseAuth(au)
- a.SetOwners([]string{dialTestOwner})
+ a.SetOwners(owners)
a.MountAuth(mux, testOrigin)
- km := &fakeKeymail{host: "example.test"}
prevT, prevR := http.DefaultTransport, net.DefaultResolver
http.DefaultTransport, net.DefaultResolver = km, nxdomainResolver()
t.Cleanup(func() { http.DefaultTransport, net.DefaultResolver = prevT, prevR })
- return mux, km
+ return mux
}
// **Signing in with a Keymail address must be able to leave the page.**
@@ -210,6 +254,75 @@ func TestSigninPathsThatDoNotLeaveAreUnchanged(t *testing.T) {
}
}
+// authorizeHref is the authorize URL a continuation page carries.
+var authorizeHref = regexp.MustCompile(`href="(https://example\.test/oauth/authorize\?[^"]+)"`)
+
+// A Keymail sign-in that comes back verified for an address Admit refuses
+// is the door, in auth's own 403, through the Refused hook (app.refused):
+// not rastrillo's bare text, not a redirect to a query anybody could
+// type, and no session. beginGuard refuses a non-owner before Begin
+// (gate 33), so the refusal is reached the way the magic link's is: the
+// address is an owner when the sign-in starts and is not one when it
+// comes back. The admitted half runs first, through the same continuation
+// page, and its session opens the index — or the refusal proves nothing.
+func TestKeymailRefusalIsTheDoor(t *testing.T) {
+ km := &fakeKeymail{host: "example.test"}
+ dbPath := filepath.Join(t.TempDir(), "app.db")
+ h := keymailSigninAppWith(t, dbPath, []string{dialTestOwner}, km)
+
+ // begin starts a Keymail sign-in on h and returns the browser that
+ // carries it and the callback Keymail would send it to.
+ begin := func() (*client, string) {
+ t.Helper()
+ c := newClient(t, h)
+ rr := c.do(http.MethodPost, testOrigin+"/auth/begin", url.Values{"address": {dialTestOwner}})
+ m := authorizeHref.FindStringSubmatch(rr.Body.String())
+ if rr.Code != http.StatusOK || m == nil {
+ t.Fatalf("POST /auth/begin = %d with no Keymail continuation:\n%s", rr.Code, rr.Body.String())
+ }
+ u, err := url.Parse(html.UnescapeString(m[1]))
+ if err != nil {
+ t.Fatal(err)
+ }
+ return c, testOrigin + "/auth/callback?" + url.Values{"state": {u.Query().Get("state")}, "code": {km.approve(dialTestOwner)}}.Encode()
+ }
+
+ c, callback := begin()
+ cb := c.get(callback)
+ if cb.Code != http.StatusSeeOther || !sessionSet(cb) {
+ t.Fatalf("admitted Keymail callback = %d (session %v), want 303 and a session:\n%s", cb.Code, sessionSet(cb), cb.Body.String())
+ }
+ dest := cb.Header().Get("Location")
+ for i := 0; i < 3 && dest != "/"; i++ {
+ dest = location(t, c.get(testOrigin+dest))
+ }
+ if got := c.get(testOrigin + "/"); dest != "/" || got.Code != http.StatusOK || !strings.Contains(got.Body.String(), "estilo-account") {
+ t.Fatalf("the Keymail session did not open the index (landed %q, GET / = %d)", dest, got.Code)
+ }
+
+ // Begun as an owner; back after the owner was removed.
+ c, callback = begin()
+ c.h = keymailSigninAppWith(t, dbPath, []string{"someone-else@example.test"}, km)
+ cb = c.get(callback)
+ if cb.Code != http.StatusForbidden || cb.Header().Get("Location") != "" || sessionSet(cb) {
+ t.Fatalf("refused Keymail callback = %d %q (session %v), want 403 and the door itself", cb.Code, cb.Header().Get("Location"), sessionSet(cb))
+ }
+ door := html.UnescapeString(cb.Body.String())
+ for _, want := range []string{"estilo-door", `name="address"`, `rst-tone="negative"`, "That address can’t sign in to Memoria."} {
+ if !strings.Contains(door, want) {
+ t.Errorf("the refusal door lacks %q:\n%s", want, door)
+ }
+ }
+ if strings.Contains(door, "verified but not admitted") {
+ t.Error("the refusal is still rastrillo's bare text")
+ }
+ // The sentence means "you proved this address and it is not
+ // admitted": nobody may summon it by typing a URL.
+ if spoof := html.UnescapeString(newClient(t, h).get(testOrigin + "/signin?err=refused").Body.String()); strings.Contains(spoof, "can’t sign in to Memoria") {
+ t.Error("anybody can show the refusal by visiting /signin?err=refused")
+ }
+}
+
func contains(xs []string, want string) bool {
for _, x := range xs {
if x == want {
diff --git a/internal/memoriatest/signindial_test.go b/internal/memoriatest/signindial_test.go
index 8b79c9e..8da30aa 100644
--- a/internal/memoriatest/signindial_test.go
+++ b/internal/memoriatest/signindial_test.go
@@ -98,7 +98,7 @@ func signinAppWithOwners(t *testing.T, owners []string) (http.Handler, *dialReco
InstanceKey: "signin-dial-gate-instance-key",
Owners: []string{dialTestOwner},
Mailer: &stubMailer{},
- }, logger, a.Admit)
+ }, logger, a)
if err != nil {
t.Fatalf("NewAuth: %v", err)
}
diff --git a/internal/memoriatest/signinoutcome_test.go b/internal/memoriatest/signinoutcome_test.go
index 3676db5..bc53bb6 100644
--- a/internal/memoriatest/signinoutcome_test.go
+++ b/internal/memoriatest/signinoutcome_test.go
@@ -129,10 +129,10 @@ func TestSigninPageSaysWhatHappened(t *testing.T) {
})
t.Run("a dead magic link says it expired", func(t *testing.T) {
+ // The GET is the confirm door and spends nothing, so a dead
+ // token is only found dead at the POST its button makes.
h, _ := signinApp(t)
- req := httptest.NewRequest(http.MethodGet, "/auth/verify?token=not-a-real-token", nil)
- rr := httptest.NewRecorder()
- h.ServeHTTP(rr, req)
+ rr := newClient(t, h).openLink("/auth/verify?token=not-a-real-token")
body := followToSignin(t, h, rr, "err=expired")
assertFlash(t, body, "negative", "expired or was already used")
})