~bigbes/sr-ht-compare

65ffb96c316be74225e3405ca5f9ef2ab68825f9 — bigbes 9 days ago 0b9a823
log: slog through auxilia's scribe, not logrus

This was the last service on the instance still logging through logrus,
and the ecore bump made it a correctness question rather than a
consistency one: the recovery middleware reports a panic through slog's
DEFAULT logger, so without a SetDefault the one record carrying a stack
trace would have gone to Go's plain handler while every other line went
through logrus.

initLogging installs a scribe tint handler on stderr — source on, colour
only when stderr is a terminal, and the mask rules for token, cookie and
authorization. It reads -d out of the argument vector rather than
waiting for core-go's parse, because a daemon that only became verbose
after it had finished starting would be silent for exactly the part of
its life an operator passes -d to watch. Terminal detection is a stdlib
Stat rather than golang.org/x/term, which would be a new dependency for
one predicate.

The startup config check keeps its shape deliberately: it still
accumulates every missing key and reports them in ONE record before ONE
exit, so an operator fixes the config in a single pass. The keys go in
as a slice attribute rather than a joined string, so the structured
sinks keep them as a list.

culpa goes in at the one boundary where it pays: the errors web.New
returns, which are the only errors this package produces and all of
which arrive at that single startup record. Each carries a hint naming
the config key or the build step that fixes it, and scribe.Err unfolds
message, code, hint and stacktrace into fields of their own. The failing
request path logs the same way, with ErrorContext so a cancelled request
reads as cancelled rather than as an unexplained 500.

TestPanicIsAnErrorPage covers the wiring end to end through Register: a
panicking authorizer yields the chrome-wrapped 500, the panic value does
not reach the viewer, and the report reaches slog's default logger.
7 files changed, 169 insertions(+), 49 deletions(-)

M cmd/comparesrht/main.go
M go.mod
M go.sum
M web/handlers.go
M web/server.go
M web/templates.go
M web/web_test.go
M cmd/comparesrht/main.go => cmd/comparesrht/main.go +69 -14
@@ 26,14 26,16 @@ package main

import (
	"fmt"
	"log/slog"
	"os"
	"slices"
	"strings"
	"time"

	"github.com/go-chi/chi/v5"
	"github.com/go-chi/chi/v5/middleware"
	"github.com/sirupsen/logrus"
	"github.com/vaughan0/go-ini"
	"go.bigb.es/auxilia/scribe"
	"sourcecraft.dev/bigbes/sr-ht-core/config"
	coreserver "sourcecraft.dev/bigbes/sr-ht-core/server"



@@ 50,8 52,51 @@ const (
	authzTTL = 60 * time.Second
)

// initLogging installs the instance's log handler as slog's default.
//
// Setting the *default* is the load-bearing part, not the formatting: the
// packages that log below this one hold no logger of their own — the web tier
// calls slog's package functions, and so does ecore's panic-recovery
// middleware, whose report (method, path, panic, stack) would otherwise go to
// Go's plain stderr handler with the stack as one unreadable field.
//
// -d is read straight out of the argument vector because core-go's server.New
// owns the real flag parse and does not run until after this: a daemon that
// only became verbose once it had finished starting would be silent for exactly
// the part of its life an operator passes -d to watch.
func initLogging(debug bool) {
	level := slog.LevelInfo
	if debug {
		level = slog.LevelDebug
	}
	slog.SetDefault(slog.New(scribe.NewTintHandler(
		scribe.WithWriter(os.Stderr),
		scribe.WithLevel(level),
		scribe.WithSource(true),
		scribe.WithTimeFormat(time.DateTime),
		// Colour is for a terminal; under systemd stderr is the journal, where
		// the escapes are noise in every stored record. Asked of the stdlib
		// rather than of golang.org/x/term, which would be a whole new
		// dependency for one predicate.
		scribe.WithNoColor(!isTerminal(os.Stderr)),
		// This daemon logs no credential deliberately, which is precisely why
		// the masks are here: the one that leaks is the attribute somebody adds
		// later, and a request or a cookie is the likeliest thing to be handed
		// to a log line while debugging the very cookie path this service reads.
		scribe.WithMaskKeys("token", "cookie", "authorization"),
		scribe.WithMask(`(?i)(secret|token|api_?key|password)`, "***"),
	)))
}

// isTerminal reports whether f is a character device — a tty rather than the
// pipe systemd, a build runner or a shell redirect hands the process.
func isTerminal(f *os.File) bool {
	info, err := f.Stat()
	return err == nil && info.Mode()&os.ModeCharDevice != 0
}

func main() {
	logrus.SetFormatter(&logrus.TextFormatter{FullTimestamp: true})
	initLogging(slices.Contains(os.Args[1:], "-d"))

	// LoadConfig never panics on a missing file (it returns a nil ini.File);
	// validateConfig turns any absent required key into a single clear fatal.


@@ 68,7 113,11 @@ func main() {
	authorizer := authz.NewAuthorizer(authzTTL)
	app, err := web.New(conf, authorizer)
	if err != nil {
		logrus.Fatalf("initialize web server: %v", err)
		// slog has no Fatal, and the explicit exit is the better shape anyway:
		// the line above is a log record like any other, and the decision to
		// stop is visible on its own line rather than hidden in a logger call.
		slog.Error("initialize the web server", scribe.Err(err))
		os.Exit(1)
	}

	// Middleware chain per the web package contract (outermost first). This is


@@ 98,11 147,11 @@ func main() {
	})

	reposRoot, _ := conf.Get("git.sr.ht", "repos")
	logrus.WithFields(logrus.Fields{
		"bind":          resolveBind(os.Args[1:]),
		"repos":         reposRoot,
		"git.sr.ht-api": apiOrigin,
	}).Info("compare.sr.ht starting")
	slog.Info("compare.sr.ht starting",
		"bind", resolveBind(os.Args[1:]),
		"repos", reposRoot,
		"git.sr.ht-api", apiOrigin,
	)

	// Run blocks until SIGINT, then performs a warm shutdown. systemd should
	// stop this unit with KillSignal=SIGINT (see contrib/compare-srht.service).


@@ 110,10 159,11 @@ func main() {
}

// validateConfig verifies every configuration key compare.sr.ht needs before it
// can serve or authorize a request. It reports all missing keys at once via a
// single logrus.Fatal so operators fix the config in one pass instead of
// discovering each gap on a separate restart. It returns the git.sr.ht internal
// API origin that GraphQL authorization will use (also logged at startup).
// can serve or authorize a request. It reports all missing keys at once, in a
// single record followed by a single exit, so operators fix the config in one
// pass instead of discovering each gap on a separate restart. It returns the
// git.sr.ht internal API origin that GraphQL authorization will use (also
// logged at startup).
//
// This runs BEFORE anything can reach config.GetAPI, which panics when no
// origin candidate is configured, and before crypto.InitCrypto (invoked by


@@ 143,8 193,13 @@ func validateConfig(conf ini.File) string {
	}

	if len(missing) > 0 {
		logrus.Fatalf("incomplete configuration; missing required keys:\n\t%s",
			strings.Join(missing, "\n\t"))
		// One record and one exit, deliberately: an operator fixing a config
		// wants every gap in front of them at once, and a line per missing key
		// is a restart per missing key. The keys go in as a slice attribute
		// rather than a joined string so the structured sinks keep them as a
		// list — the tint handler still renders them on one line.
		slog.Error("incomplete configuration", "missing", missing)
		os.Exit(1)
	}
	return apiOrigin
}

M go.mod => go.mod +6 -6
@@ 5,9 5,9 @@ go 1.26.4
require (
	github.com/go-chi/chi/v5 v5.3.1
	github.com/go-git/go-git/v5 v5.19.1
	github.com/sirupsen/logrus v1.9.4
	github.com/stretchr/testify v1.11.1
	github.com/vaughan0/go-ini v0.0.0-20130923145212-a98ad7ee00ec
	go.bigb.es/auxilia v0.5.0
	sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260718185800-dd418a200152
	sourcecraft.dev/bigbes/sr-ht-ecore v0.0.0-20260808194355-f019dbe4ea3e
)


@@ 44,7 44,7 @@ require (
	github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
	github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
	github.com/kavu/go_reuseport v1.5.0 // indirect
	github.com/kevinburke/ssh_config v1.2.0 // indirect
	github.com/kevinburke/ssh_config v1.6.0 // indirect
	github.com/klauspost/cpuid/v2 v2.3.0 // indirect
	github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect
	github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect


@@ 61,10 61,10 @@ require (
	github.com/skeema/knownhosts v1.3.1 // indirect
	github.com/vektah/gqlparser/v2 v2.5.8 // indirect
	github.com/xanzy/ssh-agent v0.3.3 // indirect
	golang.org/x/crypto v0.50.0 // indirect
	golang.org/x/net v0.53.0 // indirect
	golang.org/x/sys v0.43.0 // indirect
	golang.org/x/text v0.36.0 // indirect
	golang.org/x/crypto v0.52.0 // indirect
	golang.org/x/net v0.54.0 // indirect
	golang.org/x/sys v0.45.0 // indirect
	golang.org/x/text v0.37.0 // indirect
	google.golang.org/protobuf v1.33.0 // indirect
	gopkg.in/warnings.v0 v0.1.2 // indirect
	gopkg.in/yaml.v3 v3.0.1 // indirect

M go.sum => go.sum +14 -14
@@ 125,8 125,8 @@ github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/kavu/go_reuseport v1.5.0 h1:UNuiY2OblcqAtVDE8Gsg1kZz8zbBWg907sP1ceBV+bk=
github.com/kavu/go_reuseport v1.5.0/go.mod h1:CG8Ee7ceMFSMnx/xr25Vm0qXaj2Z4i5PWoUx+JZ5/CU=
github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4=
github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM=
github.com/kevinburke/ssh_config v1.6.0 h1:J1FBfmuVosPHf5GRdltRLhPJtJpTlMdKTBjRgTaQBFY=
github.com/kevinburke/ssh_config v1.6.0/go.mod h1:q2RIzfka+BXARoNexmF9gkxEX7DmvbW9P4hIVx2Kg4M=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=


@@ 195,8 195,6 @@ github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE=
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=


@@ 214,6 212,8 @@ github.com/vektah/gqlparser/v2 v2.5.8/go.mod h1:z8xXUff237NntSuH8mLFijZ+1tjV1swD
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.bigb.es/auxilia v0.5.0 h1:S5+btW6++4CQDOfAEZe1UxrXRl6nxtmu0rI3uIXwCaQ=
go.bigb.es/auxilia v0.5.0/go.mod h1:hBkJvydQRfmgSTR2U4PvYgJcZnh7Bj/otukrk14iJU4=
golang.org/x/crypto v0.0.0-20180904163835-0709b304e793/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=


@@ 221,8 221,8 @@ golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0
golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q=
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f h1:W3F4c+6OLc6H2lb//N1q4WpJkhzJCK5J6kUi1NTVXfM=
golang.org/x/exp v0.0.0-20260410095643-746e56fc9e2f/go.mod h1:J1xhfL/vlindoeF/aINzNzt2Bket5bjo9sdOYzOsU80=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=


@@ 237,8 237,8 @@ golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs=
golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w=
golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=


@@ 265,8 265,8 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=


@@ 274,8 274,8 @@ golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=


@@ 286,8 286,8 @@ golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.12.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=

M web/handlers.go => web/handlers.go +8 -2
@@ 6,11 6,12 @@ import (
	"errors"
	"fmt"
	"html/template"
	"log/slog"
	"net/http"
	"strings"

	"github.com/go-chi/chi/v5"
	"github.com/sirupsen/logrus"
	"go.bigb.es/auxilia/scribe"
	"sourcecraft.dev/bigbes/sr-ht-ecore/chrome"

	"sourcecraft.dev/bigbes/sr-ht-compare/authz"


@@ 111,7 112,12 @@ func httpStatusFor(err error) int {
func (s *Server) fail(w http.ResponseWriter, r *http.Request, err error) {
	status := httpStatusFor(err)
	if status >= 500 {
		logrus.WithError(err).WithField("path", r.URL.Path).Error("web: request failed")
		// ErrorContext, so a cancelled or deadlined request is visible as such
		// in the record rather than as an unexplained 500. scribe.Err expands a
		// culpa error's message, code, hint and stacktrace into fields of their
		// own instead of flattening the chain into one sentence.
		slog.ErrorContext(r.Context(), "web: request failed",
			scribe.Err(err), "method", r.Method, "path", r.URL.Path)
		s.renderError(w, r, status, "")
		return
	}

M web/server.go => web/server.go +29 -11
@@ 47,12 47,12 @@
package web

import (
	"fmt"
	"io/fs"
	"log/slog"
	"net/http"

	"github.com/sirupsen/logrus"
	"github.com/vaughan0/go-ini"
	"go.bigb.es/auxilia/culpa"
	"sourcecraft.dev/bigbes/sr-ht-ecore/assets"
	"sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
	"sourcecraft.dev/bigbes/sr-ht-ecore/pages"


@@ 112,9 112,13 @@ type Server struct {
// guards both hrefs on emptiness so a bare page is what such a build serves,
// rather than a <link href=""> that re-requests the page it is on.
func New(conf ini.File, authorizer authz.Authorizer) (*Server, error) {
	// Every refusal below carries a hint naming the config key or the build step
	// that fixes it. These are the only errors this package returns, they all
	// arrive at one slog.Error in the cmd layer, and the reader of that record
	// is an operator who wants the remedy rather than the call path.
	reposRoot, ok := conf.Get("git.sr.ht", "repos")
	if !ok || reposRoot == "" {
		return nil, fmt.Errorf("web: [git.sr.ht] repos is required")
		return nil, missingKey("git.sr.ht", "repos", "the root directory holding the bare repositories")
	}

	// The two origins are checked through the chrome that will render them


@@ 122,35 126,38 @@ func New(conf ini.File, authorizer authz.Authorizer) (*Server, error) {
	// on the page cannot disagree about which origins this service has.
	chromeSvc := chrome.NewService(conf, configSection)
	if chromeSvc.MetaOrigin() == "" {
		return nil, fmt.Errorf("web: [meta.sr.ht] origin is required")
		return nil, missingKey("meta.sr.ht", "origin", "the login and logout links in the nav are built from it")
	}
	if chromeSvc.SelfOrigin() == "" {
		return nil, fmt.Errorf("web: [%s] origin is required", configSection)
		return nil, missingKey(configSection, "origin", "the same-origin guard and every return_to are built from it")
	}

	cssHref, err := assets.Resolve(staticFS, cssGlob, assets.DefaultPrefix)
	if err != nil {
		return nil, fmt.Errorf("web: resolve the stylesheet: %w", err)
		return nil, culpa.WithHint(culpa.Wrap(err, "web: resolve the stylesheet"),
			"the glob is a literal in this package, so this is a bug and not a deployment fault")
	}
	bundleHref, err := assets.Resolve(staticFS, bundleGlob, assets.DefaultPrefix)
	if err != nil {
		return nil, fmt.Errorf("web: resolve the bundle: %w", err)
		return nil, culpa.WithHint(culpa.Wrap(err, "web: resolve the bundle"),
			"the glob is a literal in this package, so this is a bug and not a deployment fault")
	}
	if cssHref == "" || bundleHref == "" {
		logrus.WithFields(logrus.Fields{"css": cssHref, "bundle": bundleHref}).
			Warn("web: built without a front-end artefact; run `make` before `go build`")
		slog.Warn("web: built without a front-end artefact; run `make` before `go build`",
			"css", cssHref, "bundle", bundleHref)
	}
	chromeSvc.StyleHref = cssHref
	chromeSvc.Assets = map[string]string{bundleAsset: bundleHref}

	set, err := pages.Load(tmplFS, pages.Options{Funcs: funcMap})
	if err != nil {
		return nil, fmt.Errorf("web: load the page templates: %w", err)
		return nil, culpa.WithHint(culpa.Wrap(err, "web: load the page templates"),
			"a page in web/templates defines no {{define \"content\"}}, or the layout is missing")
	}

	staticSub, err := fs.Sub(staticFS, "static")
	if err != nil {
		return nil, fmt.Errorf("web: sub static FS: %w", err)
		return nil, culpa.Wrap(err, "web: sub static FS")
	}

	s := &Server{


@@ 167,6 174,17 @@ func New(conf ini.File, authorizer authz.Authorizer) (*Server, error) {
	return s, nil
}

// missingKey is the refusal for a config key this service cannot start without:
// the key in the message, and what it is for in the hint. why completes the
// sentence "it is ...", so it reads as an answer to the question an operator
// staring at a failed unit actually has.
func missingKey(section, key, why string) error {
	return culpa.WithHint(
		culpa.Errorf("web: [%s] %s is required", section, key),
		"it is "+why,
	)
}

// viewData is the root value every template is executed against.
//
// chrome.Page is embedded rather than copied field by field, so the shared

M web/templates.go => web/templates.go +3 -2
@@ 3,9 3,10 @@ package web
import (
	"embed"
	"html/template"
	"log/slog"
	"net/http"

	"github.com/sirupsen/logrus"
	"go.bigb.es/auxilia/scribe"
	"sourcecraft.dev/bigbes/sr-ht-ecore/chrome"
	"sourcecraft.dev/bigbes/sr-ht-ecore/pages"
)


@@ 98,7 99,7 @@ var funcMap = func() template.FuncMap {
// through the page that just broke. That is why this returns nothing.
func (s *Server) render(w http.ResponseWriter, status int, page string, vd viewData) {
	if err := s.pages.Render(w, status, page, vd); err != nil {
		logrus.WithError(err).WithField("page", page).Error("web: render")
		slog.Error("web: render", scribe.Err(err), "page", page, "status", status)
	}
}


M web/web_test.go => web/web_test.go +40 -0
@@ 1,9 1,11 @@
package web

import (
	"bytes"
	"context"
	"encoding/json"
	"errors"
	"log/slog"
	"net/http"
	"net/http/httptest"
	"os"


@@ 62,6 64,14 @@ func (s *stubAuthorizer) MyRepos(_ context.Context, _ string) ([]authz.RepoInfo,
	return s.my, nil
}

// panicAuthorizer fails the way a bug fails: not by returning an error, but by
// panicking inside a handler.
type panicAuthorizer struct{ stubAuthorizer }

func (p *panicAuthorizer) Repo(context.Context, string, string, string) (*authz.RepoInfo, error) {
	panic("the authorizer exploded")
}

// gitFixture drives the git CLI to build a bare repo at <root>/~alice/demo:
//
//	c1 (main): add a.txt


@@ 417,6 427,36 @@ func TestStaticListingRefused(t *testing.T) {
	}
}

// TestPanicIsAnErrorPage checks the recovery middleware through the wiring
// Register actually installs, not through a router assembled by the test: a
// panicking handler must produce this service's chrome-wrapped 500 rather than
// the dropped connection net/http answers a panic with.
//
// It also pins where the report goes. ecore's middleware logs the panic through
// slog's *default* logger, so a service that never calls slog.SetDefault sends
// its stack traces to Go's plain stderr handler; the capture below is the same
// seam initLogging writes to in the cmd layer.
func TestPanicIsAnErrorPage(t *testing.T) {
	root, _ := gitFixture(t)
	h := testServer(t, root, &panicAuthorizer{})

	var captured bytes.Buffer
	previous := slog.Default()
	slog.SetDefault(slog.New(slog.NewTextHandler(&captured, nil)))
	t.Cleanup(func() { slog.SetDefault(previous) })

	rec := get(t, h, "/~alice/demo", "")
	require.Equal(t, http.StatusInternalServerError, rec.Code)
	assert.Contains(t, rec.Body.String(), pages.InternalMessage)
	assert.Contains(t, rec.Body.String(), "navbar-brand", "the 500 has no chrome")
	assert.NotContains(t, rec.Body.String(), "the authorizer exploded",
		"the panic value must not reach the viewer")

	logged := captured.String()
	assert.Contains(t, logged, "the authorizer exploded", "the panic was not reported")
	assert.Contains(t, logged, "/~alice/demo", "the report does not name the request")
}

// TestPagesAreNotCacheable pins the policy every page behind the login cookie
// carries: these URLs say nothing about the viewer, so a cache with no
// instruction would hand one account's page to the next request.