@@ 27,6 27,49 @@ copies of the same code.
registered token — ask tokens.sr.ht whether it is still live, behind a 60s
cache. Every step that can refuse locally runs before the one that cannot,
so a short token never touches the network at all.
+- `pages` — the page-template machinery: discover a service's pages from its
+ embedded tree, refuse at startup a page that defines no `content`, render
+ into a buffer before touching the response, and carry the shared error page.
+ Both invariants are the point. A content-less page would serve the chrome
+ around a hole with a 200, which is why `content` is invoked with
+ `{{template}}` and must never become a `{{block}}` — a block quietly defines
+ an empty default for every page at once. And a template that fails halfway
+ has already written a partial page unless it rendered into a buffer, at
+ which point the error page cannot be rendered through the call that just
+ failed. `Render` answers the response itself and returns the error only for
+ the log, because handing it to a service's `fail` would either overwrite a
+ committed response or recurse through the page that just broke.
+- `assets` — hashed static assets: find the current `main.min.<sha>.css` in an
+ `fs.FS` (an `embed.FS` or an `os.DirFS`, since the services use both), and
+ serve the static tree with the cache policy the hash implies — `immutable`
+ for a content-addressed name, an hour for everything else — without
+ publishing a directory listing. An absent asset resolves to `""` rather than
+ an error: a service that will not boot without a build artefact cannot be
+ run from a checkout. The empty href must then be guarded in the template,
+ because `<link href="">` re-requests the page it is on.
+- `csrf` — the same-origin guard. These services have no CSRF token: the
+ session cookie is meta.sr.ht's unified-login cookie, set on the parent
+ domain, and no individual service can set its `SameSite`. So the defence is
+ an Origin/Referer check against the service's own origin, and it belongs
+ here for the same reason `grants` does — one rule, five copies, applied
+ inconsistently. A request that carries neither header is refused: one that
+ will not say where it came from cannot be shown to have come from us.
+- `middleware` — the small HTTP middleware the services were copying between
+ each other verbatim: `private, no-store` for anything behind the login
+ cookie (`no-cache` still permits a stored copy, revalidated against the
+ *next* viewer's cookie), panic recovery through a service-supplied error
+ renderer, and nginx's 499 for a client that hung up — which a service wants
+ distinguished from a real failure, or a disconnected browser inflates the
+ 5xx rate. A panic that arrives after the response has started aborts the
+ connection instead of appending an error page to a truncated one.
+- `ecoretest` — the test bootstrap: a synthetic instance `config.ini` with the
+ sections the nav rules need (canonical services, hub, the excluded
+ paste/pages, the custom ones, and one section with no origin that must not
+ appear in a switcher) and the fernet/webhook key seeding every service's
+ `TestMain` was doing by hand. Fixed keys, not generated ones — they secure
+ nothing inside a test process, and constancy is what lets two packages of
+ one service both initialise without the second rotating what the first
+ sealed with.
## Usage: chrome
@@ 87,6 130,52 @@ foreign bearer token is a meta PAT to accept (dolt) or something to refuse
unreachable daemon as "revoked" would refuse live tokens across the instance
for the length of a tokens.sr.ht restart.
+## Usage: the web tier
+
+The five web-side packages are independent — adopt them one at a time — but a
+service's `New` ends up reading roughly like this:
+
+```go
+//go:embed static templates
+var webFS embed.FS
+
+cssHref, err := assets.Resolve(webFS, "static/main.min.*.css", assets.DefaultPrefix)
+// err is a malformed glob, not a missing stylesheet: absent resolves to "".
+
+svc := chrome.NewService(conf, "compare.sr.ht")
+svc.StyleHref = cssHref
+
+set, err := pages.Load(webFS, pages.Options{Funcs: myHelpers})
+// Fatal at startup: a page that defines no "content" is a 200 around a hole.
+
+r.Use(middleware.PrivateCache)
+r.Use(middleware.RecoverPanics(func(w http.ResponseWriter, r *http.Request, _ any) {
+ renderError(w, r, http.StatusInternalServerError)
+}))
+r.Use(csrf.Require(svc.SelfOrigin(), nil)) // nil deny = plain 403 with csrf.Message
+r.Mount(assets.DefaultPrefix, assets.Handler(webFS, assets.DefaultPrefix, notFound))
+```
+
+and a handler renders through the set, with the view struct embedding the
+chrome:
+
+```go
+vd := viewData{Page: svc.Page(r, "Title", username), Data: payload}
+if err := set.Render(w, http.StatusOK, "index", vd); err != nil {
+ log.Printf("render index: %v", err) // the response is already answered
+}
+```
+
+In tests, `ecoretest` replaces the hand-built config and the `TestMain` that
+seeded crypto:
+
+```go
+func TestMain(m *testing.M) { ecoretest.InitCrypto(); os.Exit(m.Run()) }
+
+conf := ecoretest.Config("compare.sr.ht")
+noHub := ecoretest.Config("compare.sr.ht", ecoretest.Delete("hub.sr.ht"))
+```
+
## Policy: chrome
The chrome bakes in the instance-wide decisions instead of parameterizing