# sr-ht-ecore Extended core for the custom services of a self-hosted SourceHut instance (compare, spec, dolt, cover, bench, ...). Everything these services share that is *ours* — not upstream's — lives here, so the sr-ht-core fork can stay a clean mirror of upstream core-go, and so the services stop carrying drifting copies of the same code. ## Packages - `chrome` — the shared page chrome: service-switcher nav built from the shared config.ini (`chrome.BuildNav`), per-request `chrome.Page` with login/logout/profile URLs against meta.sr.ht's unified login, embedded `srht-nav` / `srht-env-banner` template partials (circle brand + red service label + switcher + login box), and the generic template helpers (`dict`, `shortsha`, `reltime`, `abstime`). - `grants` — the grant vocabulary of tokens.sr.ht (SPEC ch. 3): `:` members split on ASCII whitespace, `*` for every action of every service, the reserved `id:` member a registered token carries, and the subset rule an exchange narrows by. Shared because the daemon that mints and every service that validates have to read one grammar — two parsers that disagree about what counts as a permission is a hole on the security path, not a cosmetic divergence. - `bearer` — the shared working-token validator (SPEC ch. 6), one copy for every service that accepts a tokens.sr.ht token: verify the signature, decide whether the token is ours, check the grant, and — only for a 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..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 `` 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 ```go svc := chrome.NewService(conf, "compare.sr.ht") svc.StyleHref = cssHref // after discovering the hashed stylesheet t := chrome.MustAttach(template.New("layout").Funcs(chrome.Funcs())) // ... parse the service's own templates into t ... page := svc.Page(r, "Page title", username) // username "" = anonymous ``` In the layout: ```html {{template "srht-env-banner" .}} ``` The template dot must expose the `chrome.Page` fields — either a `Page` itself, or a service view struct that embeds one (promoted fields resolve in templates). ## Usage: bearer ```go v, err := bearer.New(bearer.Options{ Origin: conf.Get("tokens.sr.ht", "origin"), // https://tokens.srht.bigb.es ClientID: "bench.sr.ht", // the CALLING service NodeID: hostname, }) tok, err := v.Validate(r.Context(), presented, "bench:upload") switch { case err == nil: // tok.Username, tok.Grants, tok.TokenID case errors.Is(err, bearer.ErrNotOurs): // service policy: accept as a meta.sr.ht PAT, or refuse case errors.Is(err, bearer.ErrForbidden): http.Error(w, "insufficient grants", http.StatusForbidden) case errors.Is(err, bearer.ErrUnavailable): http.Error(w, "token service unavailable", http.StatusServiceUnavailable) default: // ErrInvalid, ErrRevoked http.Error(w, "invalid token", http.StatusUnauthorized) } ``` `crypto.InitCrypto` must have run first — the signing key and the network key both live in that package's globals. Two of those arms are the ones to get right. `ErrNotOurs` is deliberately not decided by the validator: SPEC ch. 6 step 2 leaves it to each service whether a foreign bearer token is a meta PAT to accept (dolt) or something to refuse (bench, cover). And `ErrUnavailable` is **503, never 401** — reading an 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 them: the switcher renders only for authenticated viewers; paste, pages and hub never appear in it; the brand is always circle + site name + red service label, where the name links to hub (or to the service root on an instance without one) and the label links to the service root; the profile link prefers hub's `~username` page when hub is configured. Service-specific nav entries go through `Service.ExtraNav`; per-page width through `Page.ContainerClass`. The brand is two links rather than upstream's one because upstream has to choose between them: with hub configured it points the whole brand at hub and drops the service label, without hub it keeps the label and points at the service root. Both halves are load-bearing — hub is excluded from the switcher, so the brand is the only route to it, and chrome that does not name its own service is worse chrome. Note that embedding names the field `Page`: a view struct that wants `Page` for its own payload (a pagination counter, usually) must rename that field. The collision is a compile error, not a silent shadow.