~bigbes/sr-ht-dolt

bf7897cd3026881c99dfef141933e333a38d4ed6 — Eugene Blikh 9 days ago 25541bc
web: draw the chrome from sr-ht-ecore

The brand, the service switcher, the login block, the environment banner
and the database listing were a local port of core.sr.ht's nav — one of
five such ports on this instance, and they had already drifted. They are
now sourcecraft.dev/bigbes/sr-ht-ecore/chrome, the one copy every custom
service draws from.

Deleted: web/chrome.go entire (navEntry, networkOrder, networkExcluded,
buildNetwork, basePage, loginURL, logoutURL), templates/nav.html,
templates/icons/circle.svg (ecore inlines the identical SVG), the
repoList partial, and the local dict/shortHash duplicates.

Added: one chrome.Service built in newApp from our config section with
the hashed stylesheet href set on it, a chrome.Page per request through
app.page, chrome.Attach on every template set, and chrome.Funcs as the
base of the funcmap. Handlers embed chrome.Page in their view structs
instead of copying its fields; the row browser sets ContainerClass to
container-fluid, since its column count is the table's and not ours.

Three behaviour changes come with ecore's policy, all deliberate: the
profile link now prefers hub's ~username page when hub.sr.ht is
configured (it was always meta's /profile), the brand carries a fixed
15rem min-width so the switcher starts at the same x on every service,
and a binary built without a stylesheet renders bare rather than linking
an empty href. The nav test went with the code it tested — ordering,
exclusions and login URLs are ecore's to cover — and what replaced it
asserts only what is ours: that pages are drawn through the chrome at
all, and that the row browser is full-bleed.

The auth path is untouched: a foreign bearer token is still accepted as
a meta.sr.ht PAT.
M docs/DESIGN.md => docs/DESIGN.md +16 -11
@@ 91,7 91,8 @@ authn/      # ctx.go (Caller), cookie.go (optional unified-login middleware),
storage/    # init.go (InitStore/DeleteStore via WriteEmptyRepo), dbcache.go (remotesrv.DBCache)
remoteapi/  # server.go (remotesrv assembly), interceptors.go, credsvc.go (WhoAmI grpc server)
browse/     # open.go, log.go, tables.go, diff.go — read-only doltdb over bare stores
web/        # router.go, handlers_*.go, chrome.go, templates.go, templates/*.html
web/        # router.go, handlers_*.go, templates.go, templates/*.html
            # (nav/brand/login chrome comes from sr-ht-ecore/chrome)
```

Dependency direction: `core` ← all; `db`/`authn`/`storage`/`browse` mutually


@@ 179,16 180,20 @@ with both auth variants); `GET /~{user}/{db}/log?branch=&from=`;
inline JS to prefill the form, since `dolt login` appends the pubkey as a fragment);
`GET /static/*`.

**Chrome** (web/chrome.go + templates): Go `html/template` port of core.sr.ht
`layout.html`/`nav.html` — `<nav class="container navbar navbar-light
navbar-expand-sm">`, brand `[sr.ht]site-name` + `<span class="text-danger">dolt</span>`,
network list = config sections ending `.sr.ht` (minus paste/pages) linking
`config.GetOrigin(conf, site, true)`, `active` on self; login block →
`{meta-origin}/login?return_to=...` / logout; environment banner when
`[sr.ht]environment != "production"`; stylesheet href globbed from
`static-dir/main.min.*.css` at startup. CSS: `sassc -I $(ASSETS)/scss scss/main.scss`
→ minify → `main.min.<sha256[:8]>.css` (Makefile cloned from git.sr.ht); logo.svg
copied from core.sr.ht.
**Chrome** (`sourcecraft.dev/bigbes/sr-ht-ecore/chrome`): the brand, the service
switcher, the login block, the environment banner and the database listing are
the shared package's, not ours — one `chrome.Service` built at startup from the
instance config with our section (`dolt.sr.ht`), one `chrome.Page` per request
embedded in each handler's view struct, and the `srht-nav` / `srht-env-banner` /
`srht-repo-list` partials attached to every template set by `chrome.Attach`. The
policy is ecore's: the switcher renders for authenticated viewers only, paste,
pages and hub never appear in it, and the profile link prefers hub's `~username`
page. Ours is the layout document itself, the page-width choice
(`Page.ContainerClass`, `container-fluid` on the row browser) and the stylesheet
href, globbed from `static-dir/main.min.*.css` at startup because its name
carries a build hash. CSS: `sassc -I $(ASSETS)/scss scss/main.scss` → minify →
`main.min.<sha256[:8]>.css` (Makefile cloned from git.sr.ht); logo.svg copied
from core.sr.ht.

## Access matrix (core/access.go — pure, table-driven tests)


M go.mod => go.mod +1 -0
@@ 15,6 15,7 @@ require (
	google.golang.org/grpc v1.79.3
	gopkg.in/go-jose/go-jose.v2 v2.6.3
	sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260718185800-dd418a200152
	sourcecraft.dev/bigbes/sr-ht-ecore v0.0.0-20260808143603-174115990895
)

require (

M go.sum => go.sum +3 -1
@@ 427,9 427,9 @@ github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xI
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=


@@ 666,3 666,5 @@ modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
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=

M web/csrf.go => web/csrf.go +1 -2
@@ 21,8 21,7 @@ import (
// shared unified-login cookie (SameSite handling lives in meta) and has no
// cross-origin embedding.
func (a *app) checkSameOrigin(r *http.Request) bool {
	selfOrigin := a.newBasePage(r, "").SelfOrigin
	self, err := url.Parse(selfOrigin)
	self, err := url.Parse(a.chrome.SelfOrigin())
	if err != nil || self.Host == "" {
		return false
	}

M web/deps.go => web/deps.go +13 -2
@@ 1,6 1,17 @@
// Package web is the HTTP layer of dolt.sr.ht: the chi router, request
// handlers, SourceHut nav/chrome, and the html/template views for the database
// dashboard, browse pages, settings and dolt-key management.
// handlers, and the html/template views for the database dashboard, browse
// pages, settings and dolt-key management.
//
// # The chrome is not ours
//
// The brand, the service switcher, the login block and the environment banner
// come from sourcecraft.dev/bigbes/sr-ht-ecore/chrome, the one copy every
// custom service on this instance draws from. This package builds a single
// chrome.Service at startup (newApp), asks it for a chrome.Page per request
// (app.page), and embeds that Page in each handler's view struct so the shared
// partials find their fields on the dot they are handed. Nothing here rebuilds
// the switcher, re-derives a login URL or re-reads our own origin: the copies
// that used to live in web/chrome.go are what ecore exists to have deleted.
//
// # Dependency injection
//

M web/handlers_browse.go => web/handlers_browse.go +24 -16
@@ 7,6 7,8 @@ import (

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

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

	"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
	"sourcecraft.dev/bigbes/sr-ht-dolt/core"
)


@@ 66,14 68,14 @@ func (a *app) handleLog(w http.ResponseWriter, r *http.Request) {
	}

	view := struct {
		basePage
		chrome.Page
		Repo     *core.Repo
		Branches []browse.Branch
		Branch   string
		Commits  []browse.CommitInfo
		NextHash string
	}{
		basePage: a.newBasePage(r, "Log — "+repo.OwnerName+"/"+repo.Name),
		Page:     a.page(r, "Log — "+repo.OwnerName+"/"+repo.Name),
		Repo:     repo,
		Branches: branches,
		Branch:   branch,


@@ 107,13 109,13 @@ func (a *app) handleCommit(w http.ResponseWriter, r *http.Request) {
	}

	view := struct {
		basePage
		chrome.Page
		Repo    *core.Repo
		Summary *browse.CommitDiff
	}{
		basePage: a.newBasePage(r, "Commit "+shortHash(hash)+" — "+repo.OwnerName+"/"+repo.Name),
		Repo:     repo,
		Summary:  summary,
		Page:    a.page(r, "Commit "+chrome.ShortSHA(hash)+" — "+repo.OwnerName+"/"+repo.Name),
		Repo:    repo,
		Summary: summary,
	}
	a.render(w, http.StatusOK, "commit.html", view)
}


@@ 142,17 144,17 @@ func (a *app) handleTree(w http.ResponseWriter, r *http.Request) {
	}

	view := struct {
		basePage
		chrome.Page
		Repo   *core.Repo
		Ref    string
		Tables []browse.TableInfo
		Views  []View
	}{
		basePage: a.newBasePage(r, "Tree "+ref+" — "+repo.OwnerName+"/"+repo.Name),
		Repo:     repo,
		Ref:      ref,
		Tables:   tables,
		Views:    applicableViews(a.views, tables),
		Page:   a.page(r, "Tree "+ref+" — "+repo.OwnerName+"/"+repo.Name),
		Repo:   repo,
		Ref:    ref,
		Tables: tables,
		Views:  applicableViews(a.views, tables),
	}
	a.render(w, http.StatusOK, "tree.html", view)
}


@@ 201,28 203,34 @@ func (a *app) handleTable(w http.ResponseWriter, r *http.Request) {
		views = applicableViews(a.views, tables)
	}

	// PageNum and not Page: the chrome's own Page is embedded here, and the
	// pagination counter is the page's payload, which must not shadow it.
	view := struct {
		basePage
		chrome.Page
		Repo       *core.Repo
		Ref        string
		Table      string
		Rows       *browse.RowPage
		Views      []View
		Page       int
		PageNum    int
		TotalPages int
		HasPrev    bool
		HasNext    bool
	}{
		basePage:   a.newBasePage(r, table+" — "+repo.OwnerName+"/"+repo.Name),
		Page:       a.page(r, table+" — "+repo.OwnerName+"/"+repo.Name),
		Repo:       repo,
		Ref:        ref,
		Table:      table,
		Rows:       rows,
		Views:      views,
		Page:       page,
		PageNum:    page,
		TotalPages: totalPages,
		HasPrev:    page > 1,
		HasNext:    page < totalPages,
	}
	// A row page is the one full-bleed screen this service has: the column count
	// is the table's, not ours, so the centered container turns a wide table into
	// a narrow strip with a scrollbar under it.
	view.ContainerClass = "container-fluid"
	a.render(w, http.StatusOK, "table.html", view)
}

M web/handlers_internal.go => web/handlers_internal.go +5 -4
@@ 186,7 186,7 @@ func (a *app) handleInternalCreate(w http.ResponseWriter, r *http.Request) {
		authorName = caller.Username
	}
	if authorEmail == "" {
		authorEmail = caller.Username + "@" + hostOf(config.GetOrigin(a.cfg.Conf, serviceName, true))
		authorEmail = caller.Username + "@" + hostOf(a.chrome.SelfOrigin())
	}
	if err := a.cfg.Stores.InitStore(ctx, diskPath, authorName, authorEmail); err != nil {
		// InitStore self-cleans its directory; undo the metadata row too so a


@@ 199,10 199,11 @@ func (a *app) handleInternalCreate(w http.ResponseWriter, r *http.Request) {
}

// repoURL builds the external web URL for a database, e.g.
// https://dolt.srht.bigb.es/~owner/name.
// https://dolt.srht.bigb.es/~owner/name. The origin is the chrome's, resolved
// once at startup and already stripped of a trailing slash, so the URL this
// hands back to git.sr.ht is the same one the pages link to.
func (a *app) repoURL(owner, name string) string {
	origin := strings.TrimRight(config.GetOrigin(a.cfg.Conf, serviceName, true), "/")
	return fmt.Sprintf("%s/~%s/%s", origin, owner, name)
	return fmt.Sprintf("%s/~%s/%s", a.chrome.SelfOrigin(), owner, name)
}

// hostOf returns the host authority of a URL, or the input unchanged if it does

M web/handlers_keys.go => web/handlers_keys.go +7 -5
@@ 6,12 6,14 @@ import (
	"strconv"
	"strings"

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

	"sourcecraft.dev/bigbes/sr-ht-dolt/db"
)

// keysView is the dolt-key management page model.
type keysView struct {
	basePage
	chrome.Page
	Keys   []*db.DoltKey
	Error  string
	Notice string


@@ 24,10 26,10 @@ func (a *app) renderKeys(w http.ResponseWriter, r *http.Request, ac *authContext
		return
	}
	view := keysView{
		basePage: a.newBasePage(r, "Dolt keys — "+serviceName),
		Keys:     keys,
		Error:    errMsg,
		Notice:   notice,
		Page:   a.page(r, "Dolt keys — "+serviceName),
		Keys:   keys,
		Error:  errMsg,
		Notice: notice,
	}
	a.render(w, status, "keys.html", view)
}

M web/handlers_repo.go => web/handlers_repo.go +42 -19
@@ 8,6 8,8 @@ import (
	"github.com/go-chi/chi/v5"
	"sourcecraft.dev/bigbes/sr-ht-core/config"

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

	"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
	"sourcecraft.dev/bigbes/sr-ht-dolt/core"
	"sourcecraft.dev/bigbes/sr-ht-dolt/db"


@@ 22,9 24,9 @@ func (a *app) handleIndex(w http.ResponseWriter, r *http.Request) {
	ac, caller := callerOf(r.Context())

	view := struct {
		basePage
		Repos []*core.Repo
	}{basePage: a.newBasePage(r, serviceName)}
		chrome.Page
		Repos chrome.RepoList
	}{Page: a.page(r, serviceName), Repos: repoList(nil)}

	if ac != nil {
		repos, err := a.cfg.Repos.ListReposForDashboard(r.Context(), caller.UserID)


@@ 32,7 34,7 @@ func (a *app) handleIndex(w http.ResponseWriter, r *http.Request) {
			http.Error(w, "failed to list databases", http.StatusInternalServerError)
			return
		}
		view.Repos = repos
		view.Repos = repoList(repos)
	}
	a.render(w, http.StatusOK, "index.html", view)
}


@@ 54,13 56,13 @@ type createForm struct {

func (a *app) renderCreate(w http.ResponseWriter, r *http.Request, status int, form createForm, errMsg string) {
	view := struct {
		basePage
		chrome.Page
		Form  createForm
		Error string
	}{
		basePage: a.newBasePage(r, "Create database — "+serviceName),
		Form:     form,
		Error:    errMsg,
		Page:  a.page(r, "Create database — "+serviceName),
		Form:  form,
		Error: errMsg,
	}
	a.render(w, status, "create.html", view)
}


@@ 154,17 156,36 @@ func (a *app) handleUser(w http.ResponseWriter, r *http.Request) {
	}

	view := struct {
		basePage
		chrome.Page
		Owner string
		Repos []*core.Repo
		Repos chrome.RepoList
	}{
		basePage: a.newBasePage(r, "~"+owner+" — "+serviceName),
		Owner:    owner,
		Repos:    repos,
		Page:  a.page(r, "~"+owner+" — "+serviceName),
		Owner: owner,
		Repos: repoList(repos),
	}
	a.render(w, http.StatusOK, "user.html", view)
}

// repoList adapts our databases to the shared listing partial
// ("srht-repo-list"), which every custom service on the instance renders its
// projects through. The Href and Title are the only service-specific part: a
// database lives at /~owner/name and is named for it, exactly as a repository
// is on git.sr.ht.
func repoList(repos []*core.Repo) chrome.RepoList {
	items := make([]chrome.ListItem, 0, len(repos))
	for _, repo := range repos {
		path := "/~" + repo.OwnerName + "/" + repo.Name
		items = append(items, chrome.ListItem{
			Href:        path,
			Title:       path[1:],
			Visibility:  string(repo.Visibility),
			Description: repo.Description,
		})
	}
	return chrome.RepoList{Items: items, Empty: "No databases yet."}
}

// handleOverview renders the database overview: description, visibility badge,
// branch list, latest commits, and a clone box showing both auth flows.
func (a *app) handleOverview(w http.ResponseWriter, r *http.Request) {


@@ 206,7 227,7 @@ func (a *app) handleOverview(w http.ResponseWriter, r *http.Request) {
	}

	view := struct {
		basePage
		chrome.Page
		Repo          *core.Repo
		Branches      []browse.Branch
		DefaultBranch string


@@ 215,21 236,23 @@ func (a *app) handleOverview(w http.ResponseWriter, r *http.Request) {
		CloneURL      string
		BrowseError   string
	}{
		basePage:      a.newBasePage(r, repo.OwnerName+"/"+repo.Name+" — "+serviceName),
		Page:          a.page(r, repo.OwnerName+"/"+repo.Name+" — "+serviceName),
		Repo:          repo,
		Branches:      branches,
		DefaultBranch: defBr,
		Commits:       commits,
		Views:         views,
		CloneURL:      a.cloneURL(r, repo),
		CloneURL:      a.cloneURL(repo),
		BrowseError:   browseErr,
	}
	a.render(w, http.StatusOK, "overview.html", view)
}

// cloneURL builds the HTTPS clone URL for repo: {self origin}/~owner/name.
func (a *app) cloneURL(r *http.Request, repo *core.Repo) string {
	return a.newBasePage(r, "").SelfOrigin + "/~" + repo.OwnerName + "/" + repo.Name
// cloneURL builds the HTTPS clone URL for repo: {self origin}/~owner/name. The
// origin is the chrome's, resolved once at startup from our config section, so
// a clone box and a nav link can never quote two different hosts for us.
func (a *app) cloneURL(repo *core.Repo) string {
	return a.chrome.SelfOrigin() + "/~" + repo.OwnerName + "/" + repo.Name
}

// requireLogin returns the authenticated caller, or nil after redirecting an

M web/handlers_settings.go => web/handlers_settings.go +8 -6
@@ 8,6 8,8 @@ import (

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

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

	"sourcecraft.dev/bigbes/sr-ht-dolt/core"
	"sourcecraft.dev/bigbes/sr-ht-dolt/db"
)


@@ 46,7 48,7 @@ func (a *app) loadRepoForAdmin(w http.ResponseWriter, r *http.Request) (repo *co

// settingsView is the settings page model.
type settingsView struct {
	basePage
	chrome.Page
	Repo   *core.Repo
	ACL    []*db.ACLEntry
	Error  string


@@ 60,11 62,11 @@ func (a *app) renderSettings(w http.ResponseWriter, r *http.Request, status int,
		return
	}
	view := settingsView{
		basePage: a.newBasePage(r, "Settings — "+repo.OwnerName+"/"+repo.Name),
		Repo:     repo,
		ACL:      acl,
		Error:    errMsg,
		Notice:   notice,
		Page:   a.page(r, "Settings — "+repo.OwnerName+"/"+repo.Name),
		Repo:   repo,
		ACL:    acl,
		Error:  errMsg,
		Notice: notice,
	}
	a.render(w, status, "settings.html", view)
}

M web/handlers_view.go => web/handlers_view.go +6 -4
@@ 6,6 6,8 @@ import (

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

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

	"sourcecraft.dev/bigbes/sr-ht-dolt/browse"
	"sourcecraft.dev/bigbes/sr-ht-dolt/core"
)


@@ 76,20 78,20 @@ func (a *app) handleView(w http.ResponseWriter, r *http.Request) {
	// consistent whether or not ?ref= was supplied.
	branches, _ := sess.Branches(r.Context())

	page := struct {
		basePage
	envelope := struct {
		chrome.Page
		Repo     *core.Repo
		Ref      string
		Branches []browse.Branch
		Views    []View
		Data     any
	}{
		basePage: a.newBasePage(r, view.Label()+" — "+repo.OwnerName+"/"+repo.Name),
		Page:     a.page(r, view.Label()+" — "+repo.OwnerName+"/"+repo.Name),
		Repo:     repo,
		Ref:      ref,
		Branches: branches,
		Views:    applicableViews(a.views, tables),
		Data:     data,
	}
	a.render(w, http.StatusOK, view.Template(), page)
	a.render(w, http.StatusOK, view.Template(), envelope)
}

M web/router.go => web/router.go +47 -10
@@ 6,21 6,49 @@ import (

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

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

	"sourcecraft.dev/bigbes/sr-ht-dolt/authn"
	"sourcecraft.dev/bigbes/sr-ht-dolt/core"
)

// app bundles the parsed templates, the discovered stylesheet href and the
// injected config. Handlers are methods on *app so they share this state
// without a global.
// serviceName is our own service key: the config section, the JWT audience and
// the entry the shared switcher has to recognise as the current service. One
// constant, because a service that spelled its section differently in two
// places would appear in the instance's navigation and fail to find itself in
// it.
const serviceName = "dolt.sr.ht"

// app bundles the parsed templates, the shared chrome and the injected config.
// Handlers are methods on *app so they share this state without a global.
type app struct {
	cfg       Config
	templates templateSet
	styleHref string
	// chrome is sr-ht-ecore's shared page frame: the brand, the service
	// switcher, the login block and the environment banner, built once from the
	// instance config and asked for a per-request chrome.Page (see page below).
	chrome *chrome.Service
	// views is a snapshot of the global registeredViews taken at Register time.
	// Handlers read this (never the global) so tests can inject their own set.
	views []View
}

// page builds the chrome for one request: the shared frame plus the per-page
// <title>. The caller sets any page-specific fields on its own view struct,
// which embeds the returned chrome.Page.
//
// The username handed over is the resolved caller's and not whatever the cookie
// said — an unreadable or expired cookie has already become anonymity by the
// time a handler runs — so the nav and the page content cannot disagree about
// who is looking.
func (a *app) page(r *http.Request, title string) chrome.Page {
	var username string
	if ac := authn.CallerFromContext(r.Context()); ac != nil {
		username = ac.Username
	}
	return a.chrome.Page(r, title, username)
}

// Register mounts every dolt.sr.ht web route onto r. The caller (the Phase-3
// main) installs the config/database/cookie middleware upstream on the router
// group it passes here, then calls Register with the assembled Config.


@@ 45,16 73,25 @@ func newApp(cfg Config) (*app, error) {
		cfg.Users == nil || cfg.RepoDiskPath == nil {
		return nil, fmt.Errorf("web: Register requires Repos, Stores, Browse, Users and RepoDiskPath")
	}
	if cfg.Conf == nil {
		return nil, fmt.Errorf("web: Register requires Conf (the chrome and the origins are built from it)")
	}

	templates, err := loadTemplates()
	if err != nil {
		return nil, err
	}

	// The switcher, the brand and the login links come from the shared config
	// read once here; the stylesheet is discovered separately because its name
	// carries a build hash, which no config file can know.
	chromeSvc := chrome.NewService(cfg.Conf, serviceName)
	chromeSvc.StyleHref = discoverStyleHref(cfg.StaticDir)

	return &app{
		cfg:       cfg,
		templates: templates,
		styleHref: discoverStyleHref(cfg.StaticDir),
		chrome:    chromeSvc,
		// Snapshot the registry so all handlers see a stable set and tests can
		// override it per-app without mutating the global.
		views: append([]View{}, registeredViews...),


@@ 95,24 132,24 @@ func (a *app) mount(r chi.Router) {
// hide the existence of PRIVATE repos the caller may not browse.
func (a *app) notFound(w http.ResponseWriter, r *http.Request) {
	view := struct {
		basePage
	}{basePage: a.newBasePage(r, "Not found — "+serviceName)}
		chrome.Page
	}{Page: a.page(r, "Not found — "+serviceName)}
	a.render(w, http.StatusNotFound, "404.html", view)
}

// forbidden renders the 403 page for a denied but non-hidden request.
func (a *app) forbidden(w http.ResponseWriter, r *http.Request, msg string) {
	view := struct {
		basePage
		chrome.Page
		Message string
	}{basePage: a.newBasePage(r, "Forbidden — "+serviceName), Message: msg}
	}{Page: a.page(r, "Forbidden — "+serviceName), Message: msg}
	a.render(w, http.StatusForbidden, "403.html", view)
}

// redirectLogin sends an unauthenticated caller to meta's login, returning them
// to the current URL afterwards.
func (a *app) redirectLogin(w http.ResponseWriter, r *http.Request) {
	http.Redirect(w, r, a.newBasePage(r, "").LoginURL, http.StatusSeeOther)
	http.Redirect(w, r, a.page(r, "").LoginURL, http.StatusSeeOther)
}

// loadRepoForBrowse loads the repo named by the {user}/{db} URL params and

M web/templates.go => web/templates.go +46 -58
@@ 11,6 11,8 @@ import (
	"sort"
	"strings"
	"time"

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

//go:embed templates/*.html templates/icons/*.svg


@@ 34,11 36,14 @@ var pageTemplates = []string{
	"403.html",
}

// sharedTemplates are parsed into every page: the outer layout, the nav
// fragment, and reusable partials (badges, pagination, etc.).
// sharedTemplates are parsed into every page: the outer layout and this
// service's own reusable partials (badges, tab bars). The chrome partials —
// the brand, the switcher, the login block, the environment banner, the
// listing — are NOT here: they come from sr-ht-ecore and are attached to every
// set by chrome.Attach (loadTemplates), which is the copy every custom service
// on the instance draws from.
var sharedTemplates = []string{
	"templates/layout.html",
	"templates/nav.html",
	"templates/partials.html",
}



@@ 57,7 62,10 @@ func loadTemplates() (templateSet, error) {

	set := make(templateSet, len(pageTemplates))
	for _, page := range pageTemplates {
		t := template.New("layout").Funcs(funcs)
		t, err := chrome.Attach(template.New("layout").Funcs(funcs))
		if err != nil {
			return nil, fmt.Errorf("web: attach the shared chrome partials for %s: %w", page, err)
		}
		files := append(append([]string{}, sharedTemplates...), "templates/"+page)
		if _, err := t.ParseFS(templateFS, files...); err != nil {
			return nil, fmt.Errorf("web: parse template %s: %w", page, err)


@@ 78,7 86,10 @@ func loadTemplates() (templateSet, error) {
		if _, ok := set[name]; ok {
			continue
		}
		t := template.New("layout").Funcs(funcs)
		t, err := chrome.Attach(template.New("layout").Funcs(funcs))
		if err != nil {
			return nil, fmt.Errorf("web: attach the shared chrome partials for %s: %w", name, err)
		}
		files := append(append([]string{}, sharedTemplates...), "templates/"+name)
		if _, err := t.ParseFS(templateFS, files...); err != nil {
			return nil, fmt.Errorf("web: parse view template %s: %w", name, err)


@@ 112,53 123,38 @@ func loadIcons() (map[string]template.HTML, error) {
}

// templateFuncs is the funcmap available in every template.
//
// It starts from chrome.Funcs — "dict" and "shortsha", which the shared
// partials and half this family's pages were written against — and adds this
// service's own on top. Adding after is deliberate: a name may then be shadowed
// on purpose rather than by accident of map ordering. Nothing here re-defines a
// shared helper; the local copies of dict and the hash abbreviator are gone.
func templateFuncs(icons map[string]template.HTML) template.FuncMap {
	return template.FuncMap{
		// icon renders a named inline SVG (from templates/icons). An unknown name
		// yields empty output rather than a hard error, so a missing icon never
		// crashes a page.
		"icon": func(name string) template.HTML { return icons[name] },
		// shorthash abbreviates a dolt/NBS hash to its first 8 characters, the
		// convention used everywhere commits are listed.
		"shorthash": shortHash,
		// reltime renders a humanized relative time ("3 hours ago"), no deps.
		"reltime": humanizeTime,
		// abstime renders an absolute UTC timestamp for tooltips/detail.
		"abstime": func(t time.Time) string { return t.UTC().Format("2006-01-02 15:04:05 UTC") },
		// humansize renders a byte count as a human-readable size.
		"humansize": humanizeSize,
		"upper":     strings.ToUpper,
		"lower":     strings.ToLower,
		// inc/dec support 1-based page arithmetic in pagination links.
		"inc": func(n int) int { return n + 1 },
		"dec": func(n int) int { return n - 1 },
		// doltHost derives the host:port a `dolt login --auth-endpoint` expects
		// from our origin URL (defaulting to :443 for https).
		"doltHost": doltHost,
		// dict builds a map from alternating key/value args, so a partial that
		// needs several fields (e.g. the "viewtabs" tab bar) can be invoked with
		// an inline context: {{template "viewtabs" (dict "Repo" .Repo ...)}}.
		"dict": dict,
	}
}
	m := chrome.Funcs()

// dict builds a map[string]any from alternating key/value arguments. It powers
// multi-field partial invocations from templates, which otherwise can pass only
// a single pipeline value. An odd argument count or a non-string key is a
// template authoring error and surfaces as a render error.
func dict(kv ...any) (map[string]any, error) {
	if len(kv)%2 != 0 {
		return nil, fmt.Errorf("dict: expected an even number of arguments, got %d", len(kv))
	}
	m := make(map[string]any, len(kv)/2)
	for i := 0; i < len(kv); i += 2 {
		k, ok := kv[i].(string)
		if !ok {
			return nil, fmt.Errorf("dict: key %d is not a string", i)
		}
		m[k] = kv[i+1]
	}
	return m, nil
	// icon renders a named inline SVG (from templates/icons). An unknown name
	// yields empty output rather than a hard error, so a missing icon never
	// crashes a page.
	m["icon"] = func(name string) template.HTML { return icons[name] }
	// reltime renders a humanized relative time ("3 hours ago"), no deps.
	m["reltime"] = humanizeTime
	// abstime renders an absolute UTC timestamp for tooltips/detail.
	m["abstime"] = func(t time.Time) string { return t.UTC().Format("2006-01-02 15:04:05 UTC") }
	// humansize renders a byte count as a human-readable size.
	m["humansize"] = humanizeSize
	// "upper" and "lower" used to be here for the environment banner and the
	// database listing's visibility label. Both are the shared chrome's markup
	// now, and it does its own casing, so nothing in this service's templates
	// calls them any more.
	//
	// inc/dec support 1-based page arithmetic in pagination links.
	m["inc"] = func(n int) int { return n + 1 }
	m["dec"] = func(n int) int { return n - 1 }
	// doltHost derives the host:port a `dolt login --auth-endpoint` expects
	// from our origin URL (defaulting to :443 for https).
	m["doltHost"] = doltHost

	return m
}

// doltHost renders the host:port for `dolt login --auth-endpoint` from an origin


@@ 177,14 173,6 @@ func doltHost(origin string) string {
	return u.Host + ":443"
}

// shortHash returns the first 8 characters of h (or h itself if shorter).
func shortHash(h string) string {
	if len(h) <= 8 {
		return h
	}
	return h[:8]
}

// humanizeTime renders t as a coarse relative time in the past. It is a small
// self-contained helper (no new dependency) covering seconds→years.
func humanizeTime(t time.Time) string {

M web/templates/index.html => web/templates/index.html +5 -5
@@ 1,9 1,9 @@
{{define "content" -}}
{{if .CurrentUser}}
{{if .Username}}
<div class="row">
  <div class="col-md-4">
    <p>
      Welcome back, {{.CurrentUser.Username}}! This is your Dolt database
      Welcome back, {{.Username}}! This is your Dolt database
      hosting service: <code>dolt clone</code>, <code>push</code> and
      <code>pull</code> over HTTPS, with a web UI for browsing branches,
      commits and tables.


@@ 15,9 15,9 @@
  </div>
  <div class="col-md-8">
    <hr class="d-md-none" />
    {{template "repoList" .Repos}}
    {{if .Repos}}
    <a href="/~{{.CurrentUser.Username}}" class="btn btn-default pull-right"
    {{template "srht-repo-list" .Repos}}
    {{if .Repos.Items}}
    <a href="/~{{.Username}}" class="btn btn-default pull-right"
      >More on your profile {{icon "caret-right"}}</a>
    {{end}}
  </div>

M web/templates/layout.html => web/templates/layout.html +18 -8
@@ 1,3 1,15 @@
{{/*
  The SourceHut chrome. The brand, the service switcher, the login block and the
  environment banner are NOT rendered here: they come from sr-ht-ecore's shared
  partials ("srht-env-banner", "srht-nav"), which every custom service on this
  instance draws from one copy. The dot is a handler's view struct, which embeds
  chrome.Page, so the fields those partials read promote into it.

  What is left here is the document itself and the content hole. The wrapper's
  width is .ContainerClass rather than a literal "container": the row browser
  hands out "container-fluid" so a wide table gets the whole viewport, and every
  other page gets the centered default chrome.Page already carries.
*/}}
{{define "layout" -}}
<!doctype html>
<html lang="en">


@@ 6,18 18,16 @@
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>{{.Title}}</title>
    <link rel="icon" type="image/svg+xml" href="/static/logo.svg" />
    <link rel="stylesheet" href="{{.StyleHref}}">
    {{/* Guarded rather than emitted empty: <link href=""> re-requests the page
         it is on, which is one extra page load per page load. */}}
    {{if .StyleHref}}<link rel="stylesheet" href="{{.StyleHref}}">{{end}}
  </head>
  <body>
    {{if .ShowEnvBanner}}
    <div style="background: #228800; color: white; font-weight: bold; width: 100%; text-align: center">
      {{.Environment | upper}} ENVIRONMENT
    </div>
    {{end}}
    {{template "srht-env-banner" .}}
    <nav class="container navbar navbar-light navbar-expand-sm">
      {{template "nav" .}}
      {{template "srht-nav" .}}
    </nav>
    <div class="container">
    <div class="{{.ContainerClass}}">
      {{template "content" .}}
    </div>
  </body>

M web/templates/log.html => web/templates/log.html +1 -1
@@ 21,7 21,7 @@
    <tr>
      <td>
        <a href="/~{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.Hash}}">
          <code>{{.Hash | shorthash}}</code>
          <code>{{.Hash | shortsha}}</code>
        </a>
      </td>
      <td>{{.Message}}</td>

M web/templates/overview.html => web/templates/overview.html +2 -2
@@ 31,7 31,7 @@ dolt clone {{.CloneURL}}</pre>
      <li>
        <a href="/~{{$.Repo.OwnerName}}/{{$.Repo.Name}}/tree/{{.Name}}">{{.Name}}</a>
        {{if eq .Name $.DefaultBranch}}<span class="badge badge-secondary">default</span>{{end}}
        <code class="text-muted">{{.Head | shorthash}}</code>
        <code class="text-muted">{{.Head | shortsha}}</code>
      </li>
      {{end}}
    </ul>


@@ 51,7 51,7 @@ dolt clone {{.CloneURL}}</pre>
        <tr>
          <td>
            <a href="/~{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.Hash}}">
              <code>{{.Hash | shorthash}}</code>
              <code>{{.Hash | shortsha}}</code>
            </a>
          </td>
          <td>{{.Message}}</td>

M web/templates/partials.html => web/templates/partials.html +5 -21
@@ 36,24 36,8 @@
</ul>
{{- end}}

{{define "repoList" -}}
{{if .}}
<div class="event-list">
  {{range .}}
  <div class="event">
    <h4>
      <a href="/~{{.OwnerName}}/{{.Name}}">~{{.OwnerName}}/{{.Name}}</a>
      {{if ne (printf "%s" .Visibility) "PUBLIC"}}
      <small class="pull-right">{{lower (printf "%s" .Visibility)}}</small>
      {{end}}
    </h4>
    {{if .Description}}
    <p>{{.Description}}</p>
    {{end}}
  </div>
  {{end}}
</div>
{{else}}
<p class="text-muted">No databases yet.</p>
{{end}}
{{- end}}
{{/*
  The database listing used to live here as "repoList". It is now sr-ht-ecore's
  "srht-repo-list", whose markup this copy already matched line for line;
  handlers build its chrome.RepoList dot (repoList in handlers_repo.go).
*/}}

M web/templates/table.html => web/templates/table.html +3 -3
@@ 32,14 32,14 @@
  <ul class="pagination">
    <li class="page-item {{if not .HasPrev}}disabled{{end}}">
      <a class="page-link"
         href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}/table/{{.Ref | urlquery}}/{{.Table | urlquery}}?page={{dec .Page}}">
         href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}/table/{{.Ref | urlquery}}/{{.Table | urlquery}}?page={{dec .PageNum}}">
        &larr; Previous
      </a>
    </li>
    <li class="page-item disabled"><span class="page-link">Page {{.Page}} of {{.TotalPages}}</span></li>
    <li class="page-item disabled"><span class="page-link">Page {{.PageNum}} of {{.TotalPages}}</span></li>
    <li class="page-item {{if not .HasNext}}disabled{{end}}">
      <a class="page-link"
         href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}/table/{{.Ref | urlquery}}/{{.Table | urlquery}}?page={{inc .Page}}">
         href="/~{{.Repo.OwnerName}}/{{.Repo.Name}}/table/{{.Ref | urlquery}}/{{.Table | urlquery}}?page={{inc .PageNum}}">
        Next &rarr;
      </a>
    </li>

M web/templates/user.html => web/templates/user.html +1 -1
@@ 1,5 1,5 @@
{{define "content" -}}
<h2>{{icon "user"}} ~{{.Owner}}</h2>
<p class="text-muted">Databases owned by ~{{.Owner}}.</p>
{{template "repoList" .Repos}}
{{template "srht-repo-list" .Repos}}
{{- end}}

M web/web_test.go => web/web_test.go +27 -14
@@ 14,6 14,8 @@ import (

	"github.com/dolthub/dolt/go/libraries/doltcore/creds"
	"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-core/auth"



@@ 599,21 601,32 @@ func TestKeysAddDeleteAndFragmentPage(t *testing.T) {
	}
}

func TestNavRendersNetworkAndActive(t *testing.T) {
// What the nav contains — which services appear, in what order, which one is
// marked active, where the login link points — is sr-ht-ecore's chrome and is
// tested there. What is ours is that every page is drawn through it at all, and
// that the one page we ask to be full-bleed gets its own wrapper.
func TestPagesAreDrawnThroughTheSharedChrome(t *testing.T) {
	h := newHarness(t)
	rec := h.do("GET", "/", testCaller(1, "someone"), nil)
	body := rec.Body.String()
	// git.sr.ht and todo.sr.ht are network entries; paste is excluded.
	if !strings.Contains(body, "https://git.example") || !strings.Contains(body, "https://todo.example") {
		t.Fatalf("nav missing network entries; body=%s", body)
	}
	if strings.Contains(body, "https://paste.example") {
		t.Fatalf("nav included excluded paste.sr.ht")
	}
	// Our own service is active.
	if !strings.Contains(body, `nav-item active`) {
		t.Fatalf("nav missing active class for self")
	}
	h.store.add(&core.Repo{Name: "db", OwnerID: 1, OwnerName: "alice", Path: "/d", Visibility: core.VisibilityPublic})
	h.browse.sess = &fakeSession{
		branches: []browse.Branch{{Name: "main", Head: "abcdef1234567890"}},
		rows:     &browse.RowPage{Columns: []string{"id"}, Rows: [][]string{{"1"}}, Total: 1},
	}

	dash := h.do("GET", "/", testCaller(1, "someone"), nil)
	require.Equal(t, http.StatusOK, dash.Code)
	body := dash.Body.String()
	// The switcher and the brand come from the shared partials; the dolt label
	// is the red service suffix chrome derives from our config section.
	assert.Contains(t, body, "https://git.example", "shared switcher not rendered")
	assert.Contains(t, body, `<span class="text-danger">dolt</span>`, "brand label not rendered")
	// The default page width, which every page but the row browser keeps.
	assert.Contains(t, body, `<div class="container">`)

	rows := h.do("GET", "/~alice/db/table/main/things", nil, nil)
	require.Equal(t, http.StatusOK, rows.Code)
	assert.Contains(t, rows.Body.String(), `<div class="container-fluid">`,
		"the row browser must be full-bleed")
}

func TestLogAndTablePages(t *testing.T) {