~bigbes/sr-ht-spec

e756d504ebc789e9e88f9bdd175d57afd4c87439 — Eugene Blikh 9 days ago 53e56db
web: draw the whole web tier from sr-ht-ecore

The chrome moved last time; this moves the four packages around it. pages
discovers the page templates and carries the shared error body, so the
hand-maintained page list, the render helper and error.html go; assets finds the
hashed stylesheet and serves the static tree, so the local regexp, the href glob
and handleStatic go; middleware brings the private-cache policy and a panic
guard that aborts a connection rather than appending an error page to a
truncated one; and ecoretest replaces the hand-built config and the TestMain
that minted its own keys.

The one that matters is csrf. The guard was a predicate three handlers
remembered to call, which made unprotected the default for any form added
later. It is now csrf.Require on the router, so it covers the routes that are
not written yet, it runs before routing — an unrouted POST is refused rather
than 404'd — and the refusal is the shared sentence. Host comparison becomes
case-insensitive, which is strictly more permissive and only for an operator
typo in the config.

threads.html becomes _threads.html, the partial spelling pages.Load discovers,
and the review page takes container-fluid: two prose columns do not fit the
centred container, which is what cover and dolt already concluded for their wide
views.

What stays here: the sentinel-to-status mapping in fail, and a renderError that
wraps ecore's error body in this service's view struct.
M go.mod => go.mod +1 -1
@@ 22,7 22,7 @@ require (
	go.bigb.es/auxilia v0.5.0
	gopkg.in/yaml.v3 v3.0.1
	sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260718185800-dd418a200152
	sourcecraft.dev/bigbes/sr-ht-ecore v0.0.0-20260808143603-174115990895
	sourcecraft.dev/bigbes/sr-ht-ecore v0.0.0-20260808192241-9377c43a02ca
)

require (

M go.sum => go.sum +2 -2
@@ 421,5 421,5 @@ modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260718185800-dd418a200152 h1:9kQC+tDO2CO8avlKadb9Z0if4a6vJuEK80+4zcb6/fU=
sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260718185800-dd418a200152/go.mod h1:Mu1Vx39ws/OTKWGoVERXvkdRSPLBdhuFTYv0ftVV31c=
sourcecraft.dev/bigbes/sr-ht-ecore v0.0.0-20260808143603-174115990895 h1:OGZrtBtMoXhyZGXrPqMzmrNQnStoCLBVuegGo7yF1Us=
sourcecraft.dev/bigbes/sr-ht-ecore v0.0.0-20260808143603-174115990895/go.mod h1:KeoZjm+/nnsdtc1WxB7X/0EeC+Rggt2OkwJDEc6XWnw=
sourcecraft.dev/bigbes/sr-ht-ecore v0.0.0-20260808192241-9377c43a02ca h1:LCfxvF1VJl7djl7noAeXObfuY1csJr2OOFExwU4Y/N4=
sourcecraft.dev/bigbes/sr-ht-ecore v0.0.0-20260808192241-9377c43a02ca/go.mod h1:KeoZjm+/nnsdtc1WxB7X/0EeC+Rggt2OkwJDEc6XWnw=

M scss/main.scss => scss/main.scss +1 -1
@@ 518,7 518,7 @@
  // chrome and this rule may hide it; one holding a draft is content, exactly
  // as a posted thread is, and stays drawn until it is posted or emptied.
  //
  // DO NOT "tidy this up" by deleting the composer from threads.html, and do
  // DO NOT "tidy this up" by deleting the composer from _threads.html, and do
  // not drop the `.ph-js` qualifier: with JavaScript off no table is marked,
  // this rule matches nothing, and every block keeps its visible composer,
  // which is the only reason hiding it here is honest.

M web/comments.go => web/comments.go +8 -8
@@ 327,10 327,14 @@ func (s *Server) handleProposalResolve(w http.ResponseWriter, r *http.Request) {
	s.backToThread(w, r, p, threadID)
}

// commentPost is the prologue every comment POST shares: the cross-site guard,
// read authority, a parsed form, and the proposal the URL names — refusing one
// that belongs to another space for the same reason the review page does, that
// the id is global but the link names its space.
// commentPost is the prologue every comment POST shares: read authority, a
// parsed form, and the proposal the URL names — refusing one that belongs to
// another space for the same reason the review page does, that the id is global
// but the link names its space.
//
// The cross-site guard used to open this list and is gone from it: it is
// csrf.Require on the router now (Handler), where it also covers the POST
// nobody has written yet.
//
// The one authority it checks is the read ACL — these handlers go on to read the
// proposal branch to place an anchor and to list a proposal's threads, and that


@@ 339,10 343,6 @@ func (s *Server) handleProposalResolve(w http.ResponseWriter, r *http.Request) {
// service knows both rules, and a second copy here would be a second place for
// them to be wrong.
func (s *Server) commentPost(w http.ResponseWriter, r *http.Request) (service.Proposal, bool) {
	if !s.sameOrigin(r) {
		s.renderError(w, r, http.StatusForbidden, "this request did not originate from this site")
		return service.Proposal{}, false
	}
	if !mayRead(r) {
		s.renderError(w, r, http.StatusForbidden, "you may not read this proposal")
		return service.Proposal{}, false

M web/grant_test.go => web/grant_test.go +3 -14
@@ 8,8 8,8 @@ import (
	"github.com/go-chi/chi/v5"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"github.com/vaughan0/go-ini"

	"sourcecraft.dev/bigbes/sr-ht-ecore/ecoretest"
	"sourcecraft.dev/bigbes/sr-ht-ecore/grants"

	"sourcecraft.dev/bigbes/sr-ht-spec/authn"


@@ 29,18 29,7 @@ func mustGrants(t *testing.T, s string) grants.Grants {
func grantRouter(t *testing.T, p authn.Principal) http.Handler {
	t.Helper()
	srv, err := New(Options{
		Conf: ini.File{
			"sr.ht": ini.Section{
				"network-key": testConf.Section("sr.ht")["network-key"],
				"site-name":   "sourcehut",
				"environment": "development",
				"owner-name":  "bigbes",
			},
			"webhooks":     ini.Section{"private-key": testConf.Section("webhooks")["private-key"]},
			"spec.sr.ht":   ini.Section{"origin": "https://spec.example"},
			"meta.sr.ht":   ini.Section{"origin": "https://meta.example"},
			"tokens.sr.ht": ini.Section{"origin": "https://tokens.example"},
		},
		Conf:     ecoretest.Config(authn.ConfigSection),
		Reader:   newFakeReader(),
		Searcher: &fakeSearcher{},
		Resolver: testResolver(t),


@@ 122,7 111,7 @@ func TestAnonymousDenialIsUnchanged(t *testing.T) {
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/~bigbes/rfcs/specs/0007-storage", nil))
	assert.Equal(t, http.StatusFound, rec.Code)
	assert.Contains(t, rec.Header().Get("Location"), "meta.example")
	assert.Contains(t, rec.Header().Get("Location"), ecoretest.Origin("meta.sr.ht"))

	rec = httptest.NewRecorder()
	h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/~bigbes/rfcs/specs/0007-storage.md", nil))

M web/handlers.go => web/handlers.go +20 -5
@@ 139,11 139,18 @@ func httpStatusFor(err error) int {

// fail renders the chrome error page for err, logging 5xx causes and telling
// the viewer nothing about them.
//
// The 5xx message is left empty so the page takes ecore's shared sentence for
// the status: a bug here and a panic recovered by the router are the same event
// to a viewer, and two house phrases for it would only say that they came out
// of different code. Below 500 the error's own text is the message — those name
// a revision that does not parse or a document that is not there, which is what
// the viewer needs.
func (s *Server) fail(w http.ResponseWriter, r *http.Request, err error) {
	status := httpStatusFor(err)
	if status >= 500 {
		log.Printf("web: %s: %v", r.URL.Path, err)
		s.renderError(w, r, status, "an internal error occurred")
		s.renderError(w, r, status, "")
		return
	}
	s.renderError(w, r, status, err.Error())


@@ 208,7 215,9 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
		}
	}
	vd.Data = data
	s.render(w, http.StatusOK, "index", vd)
	if err := s.pages.Render(w, http.StatusOK, "index", vd); err != nil {
		log.Printf("web: render the landing page for %s: %v", r.URL.Path, err)
	}
}

// ---- space ----------------------------------------------------------------


@@ 260,7 269,9 @@ func (s *Server) handleSpace(w http.ResponseWriter, r *http.Request) {
		Count:    len(snap.Archive.All()),
		Items:    flattenTree(snap, revQuery(rev)),
	}
	s.render(w, http.StatusOK, "space", vd)
	if err := s.pages.Render(w, http.StatusOK, "space", vd); err != nil {
		log.Printf("web: render the space page for %s: %v", r.URL.Path, err)
	}
}

// flattenTree walks the archive's `parent:` hierarchy into an ordered, depth-


@@ 518,7 529,9 @@ func (s *Server) handleDocument(w http.ResponseWriter, r *http.Request) {

	vd := s.view(r, page.Title+" — "+ref.String())
	vd.Data = data
	s.render(w, http.StatusOK, "document", vd)
	if err := s.pages.Render(w, http.StatusOK, "document", vd); err != nil {
		log.Printf("web: render the document page for %s: %v", r.URL.Path, err)
	}
}

// pinned wraps the archive's resolver so that every site-internal link a


@@ 633,7 646,9 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
	vd := s.view(r, "")
	vd.Title = "search — " + vd.SiteName + " " + vd.SiteLabel
	vd.Data = data
	s.render(w, http.StatusOK, "search", vd)
	if err := s.pages.Render(w, http.StatusOK, "search", vd); err != nil {
		log.Printf("web: render the search page for %s: %v", r.URL.Path, err)
	}
}

// hitHref turns a hit into the pinned URL the design specifies for it:

M web/inbox.go => web/inbox.go +6 -8
@@ 1,6 1,7 @@
package web

import (
	"log"
	"net/http"
	"strconv"
	"time"


@@ 70,25 71,22 @@ func (s *Server) handleInbox(w http.ResponseWriter, r *http.Request) {
		Digest:   digestRows,
		NewCount: newCount,
	}
	s.render(w, http.StatusOK, "inbox", vd)
	if err := s.pages.Render(w, http.StatusOK, "inbox", vd); err != nil {
		log.Printf("web: render the inbox page for %s: %v", r.URL.Path, err)
	}
}

// handleInboxSeen advances the owner's digest mark to now, then redirects back
// to the queue so a reload does not re-submit. It is the one write the review
// queue makes; keeping it a POST is what lets handleInbox stay a pure read.
//
// Only the owner may move their own mark, and the cross-site guard is the same
// one approve/reject use — the CSRF defense a form post needs when the session
// cookie is meta's and this service cannot set its SameSite.
// Only the owner may move their own mark. The cross-site guard this handler
// used to call for itself is csrf.Require on the router now (Handler).
func (s *Server) handleInboxSeen(w http.ResponseWriter, r *http.Request) {
	if !authn.PrincipalFromContext(r.Context()).IsOwner() {
		s.renderError(w, r, http.StatusForbidden, "only the instance owner may mark the digest seen")
		return
	}
	if !s.sameOrigin(r) {
		s.renderError(w, r, http.StatusForbidden, "this request did not originate from this site")
		return
	}
	if err := s.reader.MarkDigestSeen(r.Context(), time.Now()); err != nil {
		s.fail(w, r, err)
		return

M web/proposal.go => web/proposal.go +13 -34
@@ 3,8 3,8 @@ package web
import (
	"context"
	"fmt"
	"log"
	"net/http"
	"net/url"
	"strconv"

	"github.com/go-chi/chi/v5"


@@ 155,6 155,11 @@ func (s *Server) handleProposal(w http.ResponseWriter, r *http.Request) {
	}

	vd := s.view(r, fmt.Sprintf("Proposal #%d — %s", p.ID, p.Title))
	// The review page is two prose columns side by side, and the centred
	// container gives them about half the width they need — every line wraps
	// twice and the diff stops reading as a diff. This is the page that pays for
	// full bleed, so it takes it; the rest of the surface stays centred.
	vd.ContainerClass = "container-fluid"
	vd.Data = proposalData{
		Proposal:   p,
		SpaceHref:  "/" + ref.String(),


@@ 166,7 171,9 @@ func (s *Server) handleProposal(w http.ResponseWriter, r *http.Request) {
		Lost:       lostPanels(lost, controls),
		Unresolved: unresolvedThreads(threads),
	}
	s.render(w, http.StatusOK, "proposal", vd)
	if err := s.pages.Render(w, http.StatusOK, "proposal", vd); err != nil {
		log.Printf("web: render the proposal page for %s: %v", r.URL.Path, err)
	}
}

// handleProposalApprove merges a proposal on the owner's approval, then redirects


@@ 181,13 188,13 @@ func (s *Server) handleProposalReject(w http.ResponseWriter, r *http.Request) {
}

// actOnProposal is the shared approve/reject path: the owner-only gate, the
// cross-site guard, the action, and the post-redirect-get back to the page.
// action, and the post-redirect-get back to the page.
//
// Only the owner may approve or reject — that is the one authority the whole
// authorization model turns on, and an agent, though authenticated, has it no
// more than an anonymous viewer. The cross-site guard refuses a state change
// whose Origin is not this instance, which is the CSRF defense a form post needs
// when the session cookie is meta's and this service cannot set its SameSite.
// more than an anonymous viewer. The cross-site guard is not here any more: it
// is csrf.Require on the router (Handler), so it holds for every mutation this
// service serves and not only for the ones whose handler remembered to ask.
func (s *Server) actOnProposal(w http.ResponseWriter, r *http.Request,
	act func(context.Context, core.SpaceRef, int) (service.Proposal, error)) {



@@ 195,10 202,6 @@ func (s *Server) actOnProposal(w http.ResponseWriter, r *http.Request,
		s.renderError(w, r, http.StatusForbidden, "only the instance owner may approve or reject a proposal")
		return
	}
	if !s.sameOrigin(r) {
		s.renderError(w, r, http.StatusForbidden, "this request did not originate from this site")
		return
	}
	ref, err := spaceRefFrom(r)
	if err != nil {
		s.fail(w, r, err)


@@ 216,30 219,6 @@ func (s *Server) actOnProposal(w http.ResponseWriter, r *http.Request,
	http.Redirect(w, r, fmt.Sprintf("/%s/p/%d", ref, id), http.StatusSeeOther)
}

// sameOrigin reports whether a state-changing request came from this site. It
// checks the Origin header — which browsers send on every form POST — and falls
// back to Referer, refusing a request that carries neither. A cross-site forgery
// carries the attacker's origin and fails; this instance's own form carries its
// own and passes.
func (s *Server) sameOrigin(r *http.Request) bool {
	claimed := r.Header.Get("Origin")
	if claimed == "" {
		claimed = r.Header.Get("Referer")
	}
	if claimed == "" {
		return false
	}
	got, err := url.Parse(claimed)
	if err != nil || got.Host == "" {
		return false
	}
	want, err := url.Parse(s.chromeSvc.SelfOrigin())
	if err != nil {
		return false
	}
	return got.Scheme == want.Scheme && got.Host == want.Host
}

// stateBadge maps a proposal state onto the Bootstrap badge class the template
// tags it with, so the presentation choice lives in one place.
func stateBadge(state core.ProposalState) string {

M web/proposal_test.go => web/proposal_test.go +5 -0
@@ 64,6 64,11 @@ func TestProposalPageRendersDiffAndControls(t *testing.T) {
	if !strings.Contains(body, "specs/0007-storage.md") {
		t.Errorf("page does not name the changed document")
	}
	// The review page is the one full-bleed page of this surface: two prose
	// columns side by side do not fit the centred container.
	if !strings.Contains(body, `class="container-fluid"`) {
		t.Errorf("the review page is not full width; body:\n%s", body)
	}
}

// TestProposalPageHidesControlsWhenMerged proves a terminal proposal shows no

M web/router.go => web/router.go +38 -23
@@ 3,32 3,60 @@ package web
import (
	"net/http"
	"net/url"
	"path"
	"strings"

	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"

	"sourcecraft.dev/bigbes/sr-ht-ecore/assets"
	"sourcecraft.dev/bigbes/sr-ht-ecore/csrf"
	"sourcecraft.dev/bigbes/sr-ht-ecore/middleware"
)

// Handler returns a router with everything this package needs already
// installed: panic recovery and the authn principal middleware, then the
// routes. The daemon mounts it at "/".
// installed: panic recovery, the private cache policy, the authn principal
// middleware and the same-origin guard, then the routes. The daemon mounts it
// at "/".
//
// The order is the one the shared packages ask for. RecoverPanics is outermost
// so it covers the later middleware as well as the handlers, and it is
// sr-ht-ecore's rather than chi's or our own for one behaviour: a panic that
// arrives *after* the response has started aborts the connection instead of
// appending an error page to a truncated one. PrivateCache sits inside it so
// that every answer — including the two refusals below — carries the same
// private, no-store a page rendered behind a login cookie needs.
//
// csrf.Require goes last of the four, and on the router rather than on the
// routes, which is the whole point of the change: the guard used to be a
// predicate that three handlers remembered to call, so a form added later went
// out unprotected by default. Here it covers the routes that are not written
// yet, and it runs before routing — a mutation aimed at an address this surface
// does not serve is refused rather than 404'd, which is the right way round,
// since an unrouted POST answering differently from a routed one would be a way
// to enumerate them without ever passing the check. It sits after the resolver
// so the refusal page names the viewer the way every other page does.
//
// A caller that owns its own middleware stack — and has already applied
// authn.Resolver.Middleware to it — uses Register instead. Installing the
// principal middleware twice is harmless but pointless: it is idempotent.
// authn.Resolver.Middleware to it — uses Register instead, and owes its router
// this guard: Register installs no middleware of its own.
func (s *Server) Handler() http.Handler {
	r := chi.NewRouter()
	r.Use(middleware.Recoverer)
	r.Use(middleware.RecoverPanics(func(w http.ResponseWriter, r *http.Request, _ any) {
		s.renderError(w, r, http.StatusInternalServerError, "")
	}))
	r.Use(middleware.PrivateCache)
	r.Use(s.resolver.Middleware())
	r.Use(csrf.Require(s.chromeSvc.SelfOrigin(), func(w http.ResponseWriter, r *http.Request) {
		s.renderError(w, r, http.StatusForbidden, csrf.Message)
	}))
	s.Register(r)
	return r
}

// Register mounts every spec.sr.ht read-plane route onto r. It installs no
// middleware of its own; the router it is handed must already resolve a
// principal into the request context (authn.Resolver.Middleware), or every
// viewer looks anonymous.
// principal into the request context (authn.Resolver.Middleware) and must
// already carry csrf.Require, or every viewer looks anonymous and every form is
// forgeable. Handler does both.
//
// The document route is a single wildcard because the format selector lives in
// the *extension* and the document's address does not have one: ".md" and


@@ 38,7 66,7 @@ func (s *Server) Handler() http.Handler {
func (s *Server) Register(r chi.Router) {
	r.Get("/", s.handleIndex)
	r.Get("/healthz", s.handleHealthz)
	r.Get("/static/*", s.handleStatic)
	r.Mount(assets.DefaultPrefix, s.static)
	r.Get("/search", s.handleSearch)
	r.Get("/inbox", s.handleInbox)
	r.Post("/inbox/seen", s.handleInboxSeen)


@@ 70,19 98,6 @@ func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
	_, _ = w.Write([]byte("ok\n"))
}

// handleStatic serves the embedded assets, tagging the content-addressed
// stylesheet as immutable: its name changes whenever its bytes do, so a browser
// may keep it forever and a deploy still busts the cache.
func (s *Server) handleStatic(w http.ResponseWriter, r *http.Request) {
	name := path.Base(r.URL.Path)
	if hashedCSSRe.MatchString(name) {
		w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
	} else {
		w.Header().Set("Cache-Control", "public, max-age=3600")
	}
	s.staticFileServer.ServeHTTP(w, r)
}

// unescapePath decodes a chi wildcard back into a tree path.
//
// chi routes on r.URL.RawPath when the request had one, so the wildcard arrives

M web/server.go => web/server.go +64 -39
@@ 3,7 3,7 @@
// review page and keyword search — all served from one chi router the daemon
// mounts.
//
// # The chrome is not ours
// # The web tier is not ours
//
// The brand, the service switcher, the login block and the environment banner
// come from sourcecraft.dev/bigbes/sr-ht-ecore/chrome, which every custom


@@ 14,6 14,25 @@
// that code — inherited from compare.sr.ht, which had inherited it from
// somewhere else — is what ecore exists to have deleted.
//
// Four more of ecore's packages carry what used to be local copies of the same
// idea, and the pattern is the same every time — the rule lives in one place
// and this package supplies only what is genuinely spec.sr.ht's:
//
//   - pages discovers the page templates, refuses at startup a page that
//     defines no "content", renders into a buffer before touching the response
//     and ships the shared error body. What stays here is renderError, which
//     wraps that body in this service's view struct, and fail, which maps this
//     service's own sentinels onto statuses.
//   - assets finds the hashed stylesheet and serves the static tree with the
//     cache policy each name implies.
//   - csrf is the same-origin guard, installed on the router rather than called
//     by three handlers — see [Server.Handler].
//   - middleware is the private-cache policy and the panic guard.
//
// pages.Render answers the response itself and returns an error only for the
// log. It must never be handed to fail: that would either write a second
// response over a committed one or recurse through the page that just broke.
//
// # URL grammar
//
// The design pins this, so it is spelled out here rather than left to the


@@ 48,7 67,8 @@
// [Server.Handler] returns a router with everything this package needs already
// installed, so the daemon can mount it at "/". A caller that owns its own
// router and middleware stack uses [Server.Register] instead; it installs
// routes only, and assumes authn.Resolver.Middleware is already applied.
// routes only, and assumes both authn.Resolver.Middleware and csrf.Require are
// already applied — the second is a security rule, not a convenience.
//
// # Assets are embedded
//


@@ 65,21 85,17 @@ import (
	"io/fs"
	"log"
	"net/http"
	"path"
	"regexp"

	"github.com/vaughan0/go-ini"
	"sourcecraft.dev/bigbes/sr-ht-core/config"
	"sourcecraft.dev/bigbes/sr-ht-ecore/assets"
	"sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
	"sourcecraft.dev/bigbes/sr-ht-ecore/pages"

	"sourcecraft.dev/bigbes/sr-ht-spec/authn"
	"sourcecraft.dev/bigbes/sr-ht-spec/doc"
)

// hashedCSSRe matches the content-addressed stylesheet name so it can be served
// with an immutable cache lifetime (the hash changes whenever the bytes do).
var hashedCSSRe = regexp.MustCompile(`^main\.min\.[0-9a-f]{6,}\.css$`)

// tokensSection is tokens.sr.ht's config section, spelled the way the instance's
// config.ini spells it. service.TokensSection is the same string read for the
// internal origin; this package cannot import service/ (the dependency arrow


@@ 119,11 135,17 @@ type Server struct {
	// chromeSvc is the shared page frame of sr-ht-ecore: the brand, the service
	// switcher, the login block and the environment banner, built once from
	// config.ini and asked for a per-request Page in view (view.go). It is also
	// this package's only reader of our own and meta's origins — sameOrigin and
	// the login redirect ask it rather than keeping a second copy that could
	// this package's only reader of our own and meta's origins — the CSRF guard
	// and the login redirect ask it rather than keeping a second copy that could
	// disagree with the links on the page.
	chromeSvc *chrome.Service

	// pages is the page set of sr-ht-ecore: one template set per file in
	// templates/, discovered at startup. Adding a page is adding a file — there
	// is no list here to forget to edit — and a page that defines no "content"
	// fails New rather than serving the chrome around a hole.
	pages pages.Set

	// tokensOrigin is [tokens.sr.ht] origin in its *external* form. The only
	// thing this package does with it is redirect a browser there, and a browser
	// cannot reach the internal origin the bearer validator uses. Empty when the


@@ 131,7 153,9 @@ type Server struct {
	// than papers over with a redirect to nowhere.
	tokensOrigin string

	staticFileServer http.Handler
	// static serves the embedded asset tree with the cache policy each name
	// implies — see sr-ht-ecore/assets.
	static http.Handler
}

// New assembles a Server from the shared SourceHut config.


@@ 167,9 191,12 @@ func New(opts Options) (*Server, error) {
		return nil, fmt.Errorf("web: [meta.sr.ht] origin is required")
	}

	cssHref, err := resolveCSSHref()
	// The error is a malformed glob — a mistake in this line — and not a missing
	// stylesheet, which resolves to "" and is a warning: a service that will not
	// boot without a build artefact cannot be run from a checkout.
	cssHref, err := assets.Resolve(staticFS, "static/main.min.*.css", assets.DefaultPrefix)
	if err != nil {
		return nil, err
		return nil, fmt.Errorf("web: %w", err)
	}
	if cssHref == "" {
		log.Printf("web: no main.min.*.css embedded in this binary — pages will " +


@@ 179,36 206,34 @@ func New(opts Options) (*Server, error) {
	// empty <link>, so an unstyled build stays a presentation failure.
	chromeSvc.StyleHref = cssHref

	staticSub, err := fs.Sub(staticFS, "static")
	// A page that defines no "content" is refused here rather than serving a
	// 200 around a hole, so this error is a startup failure and not a warning.
	set, err := pages.Load(tmplFS, pages.Options{Funcs: funcMap})
	if err != nil {
		return nil, fmt.Errorf("web: sub static FS: %w", err)
		return nil, fmt.Errorf("web: %w", err)
	}

	return &Server{
		reader:           opts.Reader,
		searcher:         opts.Searcher,
		resolver:         opts.Resolver,
		renderer:         doc.NewRenderer(),
		chromeSvc:        chromeSvc,
		tokensOrigin:     config.GetOrigin(opts.Conf, tokensSection, true),
		staticFileServer: http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))),
	}, nil
}

// resolveCSSHref globs the embedded static FS for the content-addressed
// stylesheet and returns its site-absolute URL, or "" when the binary was built
// without one.
//
// Absence is reported rather than substituted: there is no placeholder href to
// invent, and a link to a stylesheet that is not there would 404 on every page
// load instead of saying what is wrong once, at startup.
func resolveCSSHref() (string, error) {
	matches, err := fs.Glob(staticFS, "static/main.min.*.css")
	staticSub, err := fs.Sub(staticFS, "static")
	if err != nil {
		return "", fmt.Errorf("web: glob stylesheet: %w", err)
		return nil, fmt.Errorf("web: sub static FS: %w", err)
	}
	if len(matches) == 0 {
		return "", nil

	s := &Server{
		reader:       opts.Reader,
		searcher:     opts.Searcher,
		resolver:     opts.Resolver,
		renderer:     doc.NewRenderer(),
		chromeSvc:    chromeSvc,
		pages:        set,
		tokensOrigin: config.GetOrigin(opts.Conf, tokensSection, true),
	}
	return "/static/" + path.Base(matches[0]), nil
	// The 404 of the asset tree is this service's own page and not net/http's
	// plaintext one: an asset URL typed by hand is a dead end without a nav to
	// get out of. It is wired after the Server exists because it renders through
	// it.
	s.static = assets.Handler(staticSub, assets.DefaultPrefix,
		http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			s.renderError(w, r, http.StatusNotFound, "")
		}))
	return s, nil
}

M web/templates.go => web/templates.go +40 -82
@@ 1,7 1,6 @@
package web

import (
	"bytes"
	"embed"
	"html/template"
	"log"


@@ 9,11 8,17 @@ import (
	"strings"

	"sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
	"sourcecraft.dev/bigbes/sr-ht-ecore/pages"
)

// tmplFS holds the page templates. Each page is parsed together with the shared
// layout into its own template set so that per-page "content" defines do not
// collide across pages.
// tmplFS holds the page templates. sr-ht-ecore's pages.Load discovers them:
// layout.html is the chrome every page is executed through, a file whose name
// starts with "_" is a partial parsed into every set, and everything else is a
// page that must define "content".
//
// The glob and not the bare directory, because Go's embed excludes names
// starting with '_' when it walks a directory and _threads.html is exactly such
// a name.
//
//go:embed templates/*.html
var tmplFS embed.FS


@@ 25,106 30,59 @@ var tmplFS embed.FS
//go:embed static
var staticFS embed.FS

// funcMap holds the template helpers available to every page.
// funcMap holds this service's own template helpers.
//
// It starts from chrome.Funcs — the generic helpers every custom service on
// this instance was carrying its own copy of, `shortsha` among them — and adds
// this service's own on top, after, so that a name may be shadowed
// It carries only what is ours: pages.Load merges it over chrome.Funcs — the
// generic helpers every custom service on this instance was carrying its own
// copy of, `shortsha` among them — in that order, so a name may be shadowed
// deliberately rather than by accident of map ordering. Nothing shadows one
// today, and a helper that diverged from the shared spelling of the same name
// would be the drift ecore exists to prevent.
var funcMap = func() template.FuncMap {
	m := chrome.Funcs()

var funcMap = template.FuncMap{
	// indent renders a tree depth as non-breaking space, so the space view's
	// hierarchy reads as a hierarchy without a nested-list template recursion.
	// A negative depth (a level-1 heading, once decremented) indents nothing.
	m["indent"] = func(depth int) template.HTML {
	"indent": func(depth int) template.HTML {
		if depth <= 0 {
			return ""
		}
		return template.HTML(strings.Repeat("&nbsp;&nbsp;&nbsp;&nbsp;", depth))
	}
	},
	// dec turns a 1-based heading level into a 0-based indent depth.
	m["dec"] = func(n int) int { return n - 1 }

	return m
}()

// pageNames are the content templates; each is parsed with layout.html.
var pageNames = []string{"index", "space", "document", "search", "error", "proposal", "inbox"}

// pages maps a page name to its parsed template set (layout + the shared
// chrome partials + local partials + that page). threads.html is parsed into
// every set rather than only into the proposal page's: it defines review-thread
// markup and nothing else, and a partial that only some sets know about is a
// lookup that fails on the page that later needs it. ecore's "srht-nav" and
// "srht-env-banner" are attached to every set for the same reason, and through
// MustAttach because a set that cannot draw the chrome is not a page this
// binary should start serving.
var pages = func() map[string]*template.Template {
	m := make(map[string]*template.Template, len(pageNames))
	for _, name := range pageNames {
		t := chrome.MustAttach(template.New("layout.html").Funcs(funcMap))
		t = template.Must(t.ParseFS(tmplFS,
			"templates/layout.html", "templates/threads.html", "templates/"+name+".html"))
		m[name] = t
	}
	return m
}()
	"dec": func(n int) int { return n - 1 },
}

// blockThreadsTmpl is the per-block comment markup, taken out of the proposal
// page's own set so the diff renderer — which builds its HTML in Go and cannot
// reach a page template through the usual {{template}} call — and the page
// itself cannot drift into two spellings of a thread.
// blockThreadsTmpl is the per-block comment markup, parsed on its own so the
// diff renderer — which builds its HTML in Go and cannot reach a page template
// through the usual {{template}} call — and the proposal page cannot drift into
// two spellings of a thread. It is the same file the page set parses as a
// partial, so there is one spelling and not two.
//
// A missing define is a build-time mistake in this package, so it panics at
// init the way template.Must does, rather than yielding a page with the
// comments silently absent.
var blockThreadsTmpl = func() *template.Template {
	t := pages["proposal"].Lookup("blockthreads")
	if t == nil {
		panic(`web: templates/threads.html does not define "blockthreads"`)
	t := template.Must(template.New("_threads.html").
		Funcs(chrome.Funcs()).Funcs(funcMap).
		ParseFS(tmplFS, "templates/_threads.html"))
	blocks := t.Lookup("blockthreads")
	if blocks == nil {
		panic(`web: templates/_threads.html does not define "blockthreads"`)
	}
	return t
	return blocks
}()

// render executes a page into a buffer first, so a template error yields a
// clean 500 rather than a half-written response. On success it writes the
// status and the buffered HTML.
func (s *Server) render(w http.ResponseWriter, status int, page string, vd viewData) {
	t, ok := pages[page]
	if !ok {
		log.Printf("web: unknown template page %q", page)
		http.Error(w, "internal server error", http.StatusInternalServerError)
		return
	}
	var buf bytes.Buffer
	if err := t.ExecuteTemplate(&buf, "layout.html", vd); err != nil {
		log.Printf("web: executing template %q: %v", page, err)
		http.Error(w, "internal server error", http.StatusInternalServerError)
		return
	}
	w.Header().Set("Content-Type", "text/html; charset=utf-8")
	w.WriteHeader(status)
	_, _ = buf.WriteTo(w)
}

// errorData is the payload of the error page.
type errorData struct {
	Status     int
	StatusText string
	Message    string
}

// renderError renders the chrome-wrapped error page. It never recurses into
// render on failure (render falls back to http.Error itself).
// renderError renders the chrome-wrapped error page: this service's view struct
// around ecore's shared error body.
//
// An empty message takes the standard sentence for the status, so a refusal
// with nothing of its own to add says what every other service on this instance
// says. It never recurses on failure — pages.Render answers the response itself
// and hands back only a line for the log.
func (s *Server) renderError(w http.ResponseWriter, r *http.Request, status int, message string) {
	vd := s.view(r, http.StatusText(status))
	vd.Data = errorData{
		Status:     status,
		StatusText: http.StatusText(status),
		Message:    message,
	vd.Data = pages.Error(status, message)
	if err := s.pages.Render(w, status, pages.ErrorPage, vd); err != nil {
		log.Printf("web: render the %d page for %s %s: %v", status, r.Method, r.URL.Path, err)
	}
	s.render(w, status, "error", vd)
}

R web/templates/threads.html => web/templates/_threads.html +0 -0
D web/templates/error.html => web/templates/error.html +0 -9
@@ 1,9 0,0 @@
{{define "content"}}
<div class="row">
  <div class="col-md-12">
    <h2>{{.Data.Status}} — {{.Data.StatusText}}</h2>
    {{if .Data.Message}}<p class="text-muted">{{.Data.Message}}</p>{{end}}
    <p><a href="/">Return to the landing page</a>.</p>
  </div>
</div>
{{end}}

M web/tokens_test.go => web/tokens_test.go +10 -13
@@ 7,7 7,10 @@ import (

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"github.com/vaughan0/go-ini"

	"sourcecraft.dev/bigbes/sr-ht-ecore/ecoretest"

	"sourcecraft.dev/bigbes/sr-ht-spec/authn"
)

// /tokens is a signpost now. spec.sr.ht mints no credential of its own, so the


@@ 24,14 27,16 @@ func TestTokensRedirectsToTokensSrHt(t *testing.T) {
			assert.Equal(t, http.StatusSeeOther, rec.Code, "body: %s", rec.Body)
			// The external origin, because this is for a browser, and the page
			// SPEC ch. 7 puts the token UI on.
			assert.Equal(t, "https://tokens.example/tokens", rec.Header().Get("Location"))
			assert.Equal(t, ecoretest.Origin("tokens.sr.ht")+"/tokens", rec.Header().Get("Location"))
		})
	}
}

// The POST routes went with the table behind them: nothing here mints or
// revokes any more, and a form posted at the old address must not 404 into
// something that looks like it might have worked.
// revokes any more, and a form posted at the old address must not answer as
// though it might have worked. It is now csrf.Require that refuses them — the
// guard runs before routing, so a POST to an address this surface does not
// serve is refused rather than 404'd.
func TestTokensAcceptsNoWrites(t *testing.T) {
	h, _ := testServer(t)



@@ 51,15 56,7 @@ func TestTokensAcceptsNoWrites(t *testing.T) {
// says so instead of redirecting to a URL built out of an empty string.
func TestTokensWithoutTheSectionSaysSo(t *testing.T) {
	srv, err := New(Options{
		Conf: ini.File{
			"sr.ht": ini.Section{
				"network-key": testConf.Section("sr.ht")["network-key"],
				"owner-name":  "bigbes",
			},
			"webhooks":   ini.Section{"private-key": testConf.Section("webhooks")["private-key"]},
			"spec.sr.ht": ini.Section{"origin": "https://spec.example"},
			"meta.sr.ht": ini.Section{"origin": "https://meta.example"},
		},
		Conf:     ecoretest.Config(authn.ConfigSection, ecoretest.Delete("tokens.sr.ht")),
		Reader:   newFakeReader(),
		Searcher: &fakeSearcher{},
		Resolver: testResolver(t),

M web/view.go => web/view.go +7 -5
@@ 43,10 43,12 @@ func (s *Server) view(r *http.Request, title string) viewData {
// with return_to pointing back at what they asked for. There is no login flow
// of our own — identity is the shared unified-login cookie and nothing else.
//
// The URL comes from a throwaway chrome.Page rather than from a second
// hand-rolled concatenation of the meta origin and an escaped return_to: the
// link in the nav and the redirect a gate issues must be the same URL, and the
// cheapest way to guarantee that is to have exactly one place that builds it.
// The URL comes from the chrome rather than from a second hand-rolled
// concatenation of the meta origin and an escaped return_to: the link in the
// nav and the redirect a gate issues must be the same URL, and the cheapest way
// to guarantee that is to have exactly one place that builds it. LoginURLFor is
// that place — a redirect wants the one field, not a whole page built to be
// thrown away.
func (s *Server) loginRedirect(w http.ResponseWriter, r *http.Request) {
	http.Redirect(w, r, s.chromeSvc.Page(r, "", "").LoginURL, http.StatusFound)
	http.Redirect(w, r, s.chromeSvc.LoginURLFor(r), http.StatusFound)
}

M web/web_test.go => web/web_test.go +46 -63
@@ 2,9 2,7 @@ package web

import (
	"context"
	"crypto/rand"
	"crypto/sha1"
	"encoding/base64"
	"encoding/hex"
	"encoding/json"
	"fmt"


@@ 16,13 14,13 @@ import (
	"testing"
	"time"

	"github.com/fernet/fernet-go"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"github.com/vaughan0/go-ini"
	"sourcecraft.dev/bigbes/sr-ht-core/auth"
	"sourcecraft.dev/bigbes/sr-ht-core/crypto"
	"sourcecraft.dev/bigbes/sr-ht-ecore/bearer"
	"sourcecraft.dev/bigbes/sr-ht-ecore/csrf"
	"sourcecraft.dev/bigbes/sr-ht-ecore/ecoretest"

	"sourcecraft.dev/bigbes/sr-ht-spec/authn"
	"sourcecraft.dev/bigbes/sr-ht-spec/core"


@@ 30,24 28,13 @@ import (
	"sourcecraft.dev/bigbes/sr-ht-spec/service"
)

// testConf carries the crypto keys established in TestMain so tests can seal
// unified-login cookies the way meta.sr.ht does.
var testConf ini.File

// TestMain installs sr-ht-ecore's fixed test keyset, which is what lets these
// tests seal a unified-login cookie the way meta.sr.ht does with no meta and no
// network. The keys are constants there, so it is idempotent: two packages of
// this service can both call it without the second rotating what the first
// sealed with.
func TestMain(m *testing.M) {
	var fk fernet.Key
	if err := fk.Generate(); err != nil {
		panic("generate fernet key: " + err.Error())
	}
	seed := make([]byte, 32)
	if _, err := rand.Read(seed); err != nil {
		panic("generate webhook seed: " + err.Error())
	}
	testConf = ini.File{
		"sr.ht":    ini.Section{"network-key": fk.Encode()},
		"webhooks": ini.Section{"private-key": base64.StdEncoding.EncodeToString(seed)},
	}
	crypto.InitCrypto(testConf)
	ecoretest.InitCrypto()
	// The agent credential is a signed tokens.sr.ht working token now, so it
	// cannot be a constant: it is minted here, once the signing key exists.
	agentTk = agentToken("spec:read")


@@ 467,28 454,12 @@ func testServer(t *testing.T) (http.Handler, *fakeSearcher) {
// tests can seed proposals into it and still get the same middleware stack.
func testServerWith(t *testing.T, reader *fakeReader) (http.Handler, *fakeReader, *fakeSearcher) {
	t.Helper()
	conf := ini.File{
		"sr.ht": ini.Section{
			"network-key": testConf.Section("sr.ht")["network-key"],
			"site-name":   "sourcehut",
			"environment": "development",
			"owner-name":  "bigbes",
		},
		"webhooks":   ini.Section{"private-key": testConf.Section("webhooks")["private-key"]},
		"spec.sr.ht": ini.Section{"origin": "https://spec.example"},
		"meta.sr.ht": ini.Section{"origin": "https://meta.example"},
		"git.sr.ht":  ini.Section{"origin": "https://git.example"},
		// The rest of the instance's services. Which of them the switcher shows
		// and in what order is sr-ht-ecore's decision and is tested there; they
		// are here so that these pages render against a realistic config.
		"todo.sr.ht":  ini.Section{"origin": "https://todo.example"},
		"paste.sr.ht": ini.Section{"origin": "https://paste.example"},
		"pages.sr.ht": ini.Section{"origin": "https://pages.example"},
		"hub.sr.ht":   ini.Section{"origin": "https://hub.example"},
		// /tokens redirects here, and the external origin is the one a browser
		// can reach.
		"tokens.sr.ht": ini.Section{"origin": "https://tokens.example"},
	}
	// The synthetic instance config of sr-ht-ecore: this service's own section
	// with an origin, meta's, tokens' (which /tokens redirects to), and the rest
	// of the instance so that these pages render against a realistic switcher.
	// Which entries the switcher shows and in what order is ecore's decision and
	// is tested there.
	conf := ecoretest.Config(authn.ConfigSection)
	resolver := testResolver(t)
	searcher := &fakeSearcher{}
	srv, err := New(Options{


@@ 893,31 864,43 @@ func TestHealthz(t *testing.T) {
	}
}

// The same-origin guard is router-wide (Handler) rather than a line three
// handlers remember, and this is what that buys: it runs before routing, so a
// mutation aimed at an address this surface does not serve is refused too, and
// a form added tomorrow is protected by having been registered.
func TestTheGuardCoversRoutesThisSurfaceDoesNotServe(t *testing.T) {
	h, _ := testServer(t)

	rec := post(t, h, "/no/such/route", "bigbes", "")
	assert.Equal(t, http.StatusForbidden, rec.Code)
	assert.Contains(t, rec.Body.String(), csrf.Message)

	// With this site's own Origin the guard passes and the router answers for
	// itself — the refusal above is the guard's and not the router's.
	rec = post(t, h, "/no/such/route", "bigbes", ecoretest.Origin(authn.ConfigSection))
	assert.Equal(t, http.StatusNotFound, rec.Code)
}

// The asset tree is mounted and served publicly. Which lifetime a name earns is
// sr-ht-ecore/assets' rule and is tested there; what this asserts is that this
// service's static mount reaches the embedded tree at all, and that the page
// policy (private, no-store) does not follow an asset out.
func TestStaticLogoIsServed(t *testing.T) {
	h, _ := testServer(t)
	rec := get(t, h, "/static/logo.svg", "")
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d", rec.Code)
	}
	if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "max-age") {
		t.Fatalf("cache-control = %q", cc)
	}
	assert.Equal(t, http.StatusOK, rec.Code)
	assert.Contains(t, rec.Header().Get("Cache-Control"), "max-age")
	assert.Empty(t, rec.Header().Get("Vary"), "an asset is the same bytes for everybody")
}

// TestHashedCSSIsImmutable checks the cache policy without depending on a built
// stylesheet: `make css` needs sassc and the shared sourcehut partials, neither
// of which a test may assume.
func TestHashedCSSIsImmutable(t *testing.T) {
	for _, name := range []string{"main.min.79713f25.css", "main.min.abc123.css"} {
		if !hashedCSSRe.MatchString(name) {
			t.Fatalf("%s should be recognised as a hashed stylesheet", name)
		}
	}
	for _, name := range []string{"main.css", "main.min.css", "logo.svg"} {
		if hashedCSSRe.MatchString(name) {
			t.Fatalf("%s should not be recognised as a hashed stylesheet", name)
		}
	}
// An asset name that is not in the tree gets this service's own 404 page rather
// than net/http's plaintext one — a URL typed by hand is a dead end without a
// nav to get out of.
func TestMissingAssetGetsTheChromePage(t *testing.T) {
	h, _ := testServer(t)
	rec := get(t, h, "/static/nope.css", "")
	assert.Equal(t, http.StatusNotFound, rec.Code)
	assert.Contains(t, rec.Body.String(), "navbar-brand", "the 404 carries the chrome")
}

// ---- unit-level grammar ---------------------------------------------------