~bigbes/sr-ht-spec

53e56db27ab35117d0c2a91f15533f7dd528612c — Eugene Blikh 9 days ago a4d8cc5
web: draw the chrome from sr-ht-ecore

The nav/service-switcher, the login block, the environment banner and the
brand were this service's own copy of code compare.sr.ht had already copied
from somewhere else. They come from sourcecraft.dev/bigbes/sr-ht-ecore/chrome
now: one chrome.Service built at startup from the shared config.ini, one
chrome.Page per request, embedded in viewData so the shared partials find
their fields on the dot.

web/chrome.go is gone — buildNav, navItem, canonIndex, the login/logout/
profile URL building and the chrome half of viewData with it. The layout
renders srht-env-banner and srht-nav instead of the local markup, the landing
page's space list renders through srht-repo-list, and the template FuncMap
starts from chrome.Funcs() (the local shortsha was a duplicate of the shared
one). sameOrigin and the login redirect ask the chrome for our origin rather
than keeping a second copy that could disagree with the links on the page.

Three of ecore's policies differ from what this service did, and win, per that
package's own doc: [sr.ht]site-name defaults to "sr.ht" rather than
"sourcehut" and [sr.ht]environment to "development" rather than "production"
when the key is absent, and the brand carries a fixed 15rem min-width so the
switcher starts at the same x on every service. The instance's config.ini sets
both keys, so on it only the brand width is visible.

The nav tests that only restated ecore's rules — switcher order, the
paste/pages/hub exclusion, the shape of a login URL — are dropped; ecore tests
those. What is left covers this service's seam: that the identity authn
resolved is the one the chrome is handed.
M .gitignore => .gitignore +5 -0
@@ 8,6 8,11 @@
# Local instance config
/config.ini

# Git worktrees. Both spellings, because the tooling picks either one and a
# worktree that shows up as untracked invites somebody to commit a checkout.
/.worktrees/
/.claude/worktrees/

# Local bare repos and the bleve index + render cache. Both are the service's
# runtime state, not source; the cache is safe to delete at any time.
/repos/

D web/chrome.go => web/chrome.go +0 -146
@@ 1,146 0,0 @@
package web

import (
	"net/http"
	"net/url"
	"sort"
	"strings"

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

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

// navCanonical is the SourceHut service-switcher order. Services not listed
// here (including our own spec) sort alphabetically after these.
var navCanonical = []string{"hub", "git", "hg", "lists", "todo", "builds", "man", "meta"}

// navExcluded are service sections that never appear in the switcher: paste and
// pages have no top-level UI worth linking, and hub is rendered as the brand.
var navExcluded = map[string]bool{"paste": true, "pages": true, "hub": true}

// navItem is one entry in the service switcher.
type navItem struct {
	Name   string // short service name, e.g. "git"
	Origin string // external origin URL
	Active bool   // true for spec.sr.ht (this service)
}

// buildNav derives the service switcher from the shared config: every section
// whose name ends in ".sr.ht" (with a configured origin) except paste/pages/hub,
// ordered by navCanonical then alphabetically, with spec.sr.ht marked active.
//
// The ".sr.ht" suffix is the whole membership rule — it is what
// core.sr.ht's own _network does, and it is why our section must be named
// literally "spec.sr.ht" no matter what host it is served from.
func buildNav(conf ini.File) []navItem {
	var items []navItem
	for section := range conf {
		if !strings.HasSuffix(section, ".sr.ht") {
			continue
		}
		short := strings.TrimSuffix(section, ".sr.ht")
		if navExcluded[short] {
			continue
		}
		origin := config.GetOrigin(conf, section, true)
		if origin == "" {
			continue
		}
		items = append(items, navItem{
			Name:   short,
			Origin: origin,
			Active: section == authn.ConfigSection,
		})
	}
	sort.SliceStable(items, func(i, j int) bool {
		ci, cj := canonIndex(items[i].Name), canonIndex(items[j].Name)
		if ci != cj {
			return ci < cj
		}
		return items[i].Name < items[j].Name
	})
	return items
}

// canonIndex returns a service's position in navCanonical, or a sentinel past
// the end for services that are not canonically ordered.
func canonIndex(name string) int {
	for i, n := range navCanonical {
		if n == name {
			return i
		}
	}
	return len(navCanonical)
}

// viewData is the root value every template is executed against: the chrome
// fields are common to all pages; Data carries the page-specific payload.
type viewData struct {
	Title       string
	SiteName    string
	HubOrigin   string // non-empty ⇒ brand links to hub instead of "/"
	Nav         []navItem
	Username    string // "" for a viewer with no authority
	LoginURL    string
	LogoutURL   string
	RegisterURL string
	ProfileURL  string
	CSSHref     string // "" when the binary was built without a stylesheet
	Environment string
	ShowBanner  bool

	// ContainerClass selects the width of the page's content wrapper.
	ContainerClass string

	Data any
}

// chrome builds the common chrome fields for a request. Login return_to is the
// current full URL (so the viewer lands back where they were); logout return_to
// is this service's origin.
//
// Username is the *authoritative* identity, not whatever the cookie said: a
// logged-in human who is not the instance owner resolves to anonymous, so the
// nav shows "log in" to them rather than greeting them by a name that grants
// nothing.
func (s *Server) chrome(r *http.Request) viewData {
	p := authn.PrincipalFromContext(r.Context())
	username := ""
	if p.IsOwner() {
		username = p.Owner
	}

	current := s.origin + r.URL.RequestURI()
	loginURL := s.metaOrigin + "/login?return_to=" + url.QueryEscape(current)
	logoutURL := s.metaOrigin + "/logout?return_to=" + url.QueryEscape(s.origin)

	profileURL := s.metaOrigin + "/profile"
	if s.hubOrigin != "" && username != "" {
		profileURL = s.hubOrigin + "/~" + username
	}

	return viewData{
		ContainerClass: "container",
		SiteName:       s.siteName,
		HubOrigin:      s.hubOrigin,
		Nav:            s.nav,
		Username:       username,
		LoginURL:       loginURL,
		LogoutURL:      logoutURL,
		RegisterURL:    s.metaOrigin,
		ProfileURL:     profileURL,
		CSSHref:        s.cssHref,
		Environment:    strings.ToUpper(s.environment),
		ShowBanner:     s.environment != "" && s.environment != "production",
	}
}

// loginRedirect sends a viewer with no read authority to meta.sr.ht's login,
// 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.
func (s *Server) loginRedirect(w http.ResponseWriter, r *http.Request) {
	current := s.origin + r.URL.RequestURI()
	http.Redirect(w, r, s.metaOrigin+"/login?return_to="+url.QueryEscape(current), http.StatusFound)
}

M web/handlers.go => web/handlers.go +34 -15
@@ 10,6 10,7 @@ import (
	"strings"

	"github.com/go-chi/chi/v5"
	"sourcecraft.dev/bigbes/sr-ht-ecore/chrome"

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


@@ 166,14 167,20 @@ func (s *Server) failFormat(w http.ResponseWriter, r *http.Request, f format, er

// ---- landing --------------------------------------------------------------

type spaceLink struct {
	Ref  string
	Href string
}
// emptySpaces is what the landing page says when the owner has no spaces yet.
// It is a sentence and not "nothing here" because the remedy is not obvious:
// a space is created by pushing to it, not by a button on this page.
const emptySpaces = "No spaces yet. A space is a bare git repository owned by this service; " +
	"it appears here once it has been created and pushed to."

type indexData struct {
	LoggedIn bool
	Spaces   []spaceLink

	// Spaces is rendered by ecore's "srht-repo-list" partial, which is the
	// listing markup every service on this instance converged on. A space has no
	// visibility to show — this service has exactly one reader — so only the
	// title and the href are filled in.
	Spaces chrome.RepoList
}

// handleIndex is the landing page: the spaces you can read.


@@ 181,10 188,12 @@ type indexData struct {
// It renders for an anonymous viewer too, because the chrome's login link has
// to live somewhere reachable — but it lists nothing, so no space name leaks.
func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
	vd := s.chrome(r)
	vd.Title = s.siteName + " spec"
	vd := s.view(r, "")
	// The title is built from the chrome's own fields rather than from a second
	// read of [sr.ht]site-name: the tab and the brand must name the same site.
	vd.Title = vd.SiteName + " " + vd.SiteLabel

	data := indexData{LoggedIn: mayRead(r)}
	data := indexData{LoggedIn: mayRead(r), Spaces: chrome.RepoList{Empty: emptySpaces}}
	if data.LoggedIn {
		refs, err := s.reader.ListSpaces(r.Context())
		if err != nil {


@@ 192,7 201,10 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) {
			return
		}
		for _, ref := range refs {
			data.Spaces = append(data.Spaces, spaceLink{Ref: ref.String(), Href: "/" + ref.String()})
			data.Spaces.Items = append(data.Spaces.Items, chrome.ListItem{
				Href:  "/" + ref.String(),
				Title: ref.String(),
			})
		}
	}
	vd.Data = data


@@ 239,8 251,7 @@ func (s *Server) handleSpace(w http.ResponseWriter, r *http.Request) {
		return
	}

	vd := s.chrome(r)
	vd.Title = ref.String()
	vd := s.view(r, ref.String())
	vd.Data = spaceData{
		Ref:      ref.String(),
		Rev:      snap.Rev,


@@ 505,8 516,7 @@ func (s *Server) handleDocument(w http.ResponseWriter, r *http.Request) {
		data.Backlinks = append(data.Backlinks, docLink{Title: b.Title, Href: snap.Archive.DocHref(b) + rq})
	}

	vd := s.chrome(r)
	vd.Title = page.Title + " — " + ref.String()
	vd := s.view(r, page.Title+" — "+ref.String())
	vd.Data = data
	s.render(w, http.StatusOK, "document", vd)
}


@@ 549,6 559,15 @@ type searchHit struct {
	Snippet template.HTML
}

// spaceLink is one option of the search form's space filter. It is not a
// chrome.ListItem: a filter option is a <option>, not a card, and the listing
// on the landing page is the only thing on this service shaped like the
// family's event list.
type spaceLink struct {
	Ref  string
	Href string
}

type searchData struct {
	Query  string
	Space  string


@@ 611,8 630,8 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
		}
	}

	vd := s.chrome(r)
	vd.Title = "search — " + s.siteName + " spec"
	vd := s.view(r, "")
	vd.Title = "search — " + vd.SiteName + " " + vd.SiteLabel
	vd.Data = data
	s.render(w, http.StatusOK, "search", vd)
}

M web/inbox.go => web/inbox.go +1 -2
@@ 64,8 64,7 @@ func (s *Server) handleInbox(w http.ResponseWriter, r *http.Request) {

	digestRows, newCount := digestRows(digest, mark, marked)

	vd := s.chrome(r)
	vd.Title = "Review queue"
	vd := s.view(r, "Review queue")
	vd.Data = inboxData{
		Open:     proposalRows(open),
		Digest:   digestRows,

M web/proposal.go => web/proposal.go +2 -3
@@ 154,8 154,7 @@ func (s *Server) handleProposal(w http.ResponseWriter, r *http.Request) {
		lost = append(lost, ts...)
	}

	vd := s.chrome(r)
	vd.Title = fmt.Sprintf("Proposal #%d — %s", p.ID, p.Title)
	vd := s.view(r, fmt.Sprintf("Proposal #%d — %s", p.ID, p.Title))
	vd.Data = proposalData{
		Proposal:   p,
		SpaceHref:  "/" + ref.String(),


@@ 234,7 233,7 @@ func (s *Server) sameOrigin(r *http.Request) bool {
	if err != nil || got.Host == "" {
		return false
	}
	want, err := url.Parse(s.origin)
	want, err := url.Parse(s.chromeSvc.SelfOrigin())
	if err != nil {
		return false
	}

M web/server.go => web/server.go +39 -30
@@ 1,13 1,18 @@
// Package web is spec.sr.ht's read plane in a browser: the SourceHut chrome
// ported to Go html/templates, a space's document tree, a rendered document
// with its metadata and backlinks, and keyword search — all served from one chi
// router the daemon mounts.
// Package web is spec.sr.ht's read plane in a browser: a space's document
// tree, a rendered document with its metadata and backlinks, the proposal
// review page and keyword search — all served from one chi router the daemon
// mounts.
//
// It is compare.sr.ht's web/ package with the diff views replaced by document
// views: the nav/service-switcher, login block, environment banner, error page,
// embedded-static pattern and hashed-asset resolution are the same code shaped
// the same way, because "no upstream SourceHut modification" means every
// service reimplements that chrome and two of them already agreed on how.
// # 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, which every custom
// service on this instance shares. This package builds one chrome.Service at
// startup, asks it for a chrome.Page per request, and embeds that Page in its
// own view struct so the fields promote into the templates (view.go). Nothing
// here rebuilds the switcher or re-derives a login URL: this service's copy of
// that code — inherited from compare.sr.ht, which had inherited it from
// somewhere else — is what ecore exists to have deleted.
//
// # URL grammar
//


@@ 65,6 70,7 @@ import (

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

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


@@ 83,9 89,11 @@ const tokensSection = "tokens.sr.ht"
// Options is everything a Server needs. Every field is required; New says which
// one is missing rather than failing later inside a handler.
type Options struct {
	// Conf is the shared SourceHut config.ini. The nav switcher, the site name,
	// the environment banner and the meta.sr.ht login origin all come from it,
	// and it is read once at startup rather than per request.
	// Conf is the shared SourceHut config.ini, and it is here for one reason:
	// the chrome is built from it. The switcher is a question about every
	// [*.sr.ht] section the instance defines and not about our own keys, so
	// chrome.NewService reads the whole file once at startup; nothing here
	// parses it again per request.
	Conf ini.File

	// Reader is the read surface over spaces and documents. NewReader adapts a


@@ 108,12 116,13 @@ type Server struct {
	resolver *authn.Resolver
	renderer *doc.Renderer

	siteName    string
	environment string
	metaOrigin  string
	origin      string
	hubOrigin   string
	cssHref     string
	// 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
	// disagree with the links on the page.
	chromeSvc *chrome.Service

	// 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


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

	nav              []navItem
	staticFileServer http.Handler
}



@@ 146,12 154,16 @@ func New(opts Options) (*Server, error) {
		return nil, fmt.Errorf("web: authn Resolver is required")
	}

	origin := config.GetOrigin(opts.Conf, authn.ConfigSection, true)
	if origin == "" {
	// The section is authn.ConfigSection and not a literal, because that
	// constant is what the daemon, the config file and the switcher's "which
	// entry is me" test all have to agree on. A service that spelled its section
	// differently in two places would appear in the instance's navigation and
	// fail to recognise itself in it.
	chromeSvc := chrome.NewService(opts.Conf, authn.ConfigSection)
	if chromeSvc.SelfOrigin() == "" {
		return nil, fmt.Errorf("web: [%s] origin is required", authn.ConfigSection)
	}
	metaOrigin := config.GetOrigin(opts.Conf, "meta.sr.ht", true)
	if metaOrigin == "" {
	if chromeSvc.MetaOrigin() == "" {
		return nil, fmt.Errorf("web: [meta.sr.ht] origin is required")
	}



@@ 163,6 175,9 @@ func New(opts Options) (*Server, error) {
		log.Printf("web: no main.min.*.css embedded in this binary — pages will " +
			"render unstyled; run `make css` before `go build`")
	}
	// chrome.Page renders a bare page for an empty StyleHref rather than an
	// empty <link>, so an unstyled build stays a presentation failure.
	chromeSvc.StyleHref = cssHref

	staticSub, err := fs.Sub(staticFS, "static")
	if err != nil {


@@ 174,14 189,8 @@ func New(opts Options) (*Server, error) {
		searcher:         opts.Searcher,
		resolver:         opts.Resolver,
		renderer:         doc.NewRenderer(),
		siteName:         config.GetString(opts.Conf, "sr.ht", "site-name", "sourcehut"),
		environment:      config.GetString(opts.Conf, "sr.ht", "environment", "production"),
		metaOrigin:       metaOrigin,
		origin:           origin,
		hubOrigin:        config.GetOrigin(opts.Conf, "hub.sr.ht", true),
		chromeSvc:        chromeSvc,
		tokensOrigin:     config.GetOrigin(opts.Conf, tokensSection, true),
		cssHref:          cssHref,
		nav:              buildNav(opts.Conf),
		staticFileServer: http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))),
	}, nil
}

M web/templates.go => web/templates.go +29 -21
@@ 7,6 7,8 @@ import (
	"log"
	"net/http"
	"strings"

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

// tmplFS holds the page templates. Each page is parsed together with the shared


@@ 23,40 25,47 @@ var tmplFS embed.FS
//go:embed static
var staticFS embed.FS

// funcMap holds the template helpers shared by every page.
var funcMap = template.FuncMap{
	// shortsha abbreviates an object id to 8 hex chars.
	"shortsha": func(s string) string {
		if len(s) > 8 {
			return s[:8]
		}
		return s
	},
// funcMap holds the template helpers available to every page.
//
// 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
// 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()

	// 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.
	"indent": func(depth int) template.HTML {
	m["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.
	"dec": func(n int) int { return n - 1 },
}
	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 + 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.
// 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 := template.New("layout.html").Funcs(funcMap)
		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


@@ 111,8 120,7 @@ type errorData struct {
// renderError renders the chrome-wrapped error page. It never recurses into
// render on failure (render falls back to http.Error itself).
func (s *Server) renderError(w http.ResponseWriter, r *http.Request, status int, message string) {
	vd := s.chrome(r)
	vd.Title = http.StatusText(status)
	vd := s.view(r, http.StatusText(status))
	vd.Data = errorData{
		Status:     status,
		StatusText: http.StatusText(status),

M web/templates/index.html => web/templates/index.html +4 -15
@@ 20,26 20,15 @@
  </div>
  <div class="col-md-8">
    <hr class="d-md-none" />
    {{if .Data.Spaces}}
    <div class="event-list">
      {{range .Data.Spaces}}
      <div class="event">
        <h4><a href="{{.Href}}">{{.Ref}}</a></h4>
      </div>
      {{end}}
    </div>
    {{else}}
    <p class="text-muted">
      No spaces yet. A space is a bare git repository owned by this service;
      it appears here once it has been created and pushed to.
    </p>
    {{end}}
    {{/* The listing markup is ecore's, so a space here and a repository on the
         sibling services look like the same kind of thing. */}}
    {{template "srht-repo-list" .Data.Spaces}}
  </div>
</div>
{{else}}
<div class="row">
  <div class="col-md-12">
    <h2>{{.SiteName}} <span class="text-danger">spec</span></h2>
    <h2>{{.SiteName}} <span class="text-danger">{{.SiteLabel}}</span></h2>
    <p>
      Reviewable document storage: agents propose, you curate, agents read back
      the approved text. Reads default to the approved revision; add

M web/templates/layout.html => web/templates/layout.html +19 -38
@@ 1,3 1,16 @@
{{/*
  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 this package's viewData,
  which embeds chrome.Page, so the fields those partials read promote into it.

  What is left is the document and three seams:
    head    — extra <head> content. A block, so a page that needs none renders.
    content — the page itself; every page template defines it.
    scripts — nothing uses it yet; the proposal page's script goes in "head"
              with defer.
*/}}
<!doctype html>
<html lang="en">
  <head>


@@ 5,48 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">
    {{if .CSSHref}}<link rel="stylesheet" href="{{.CSSHref}}">{{end}}
    {{/* Empty when this binary was built without `make css`. The link is
         guarded rather than emitted empty: <link href=""> re-requests the page
         it is on, which is a page load per page load. */}}
    {{if .StyleHref}}<link rel="stylesheet" href="{{.StyleHref}}">{{end}}
    {{block "head" .}}{{end}}
  </head>
  <body>
    {{if .ShowBanner}}
    <div style="background: #228800; color: white; font-weight: bold; width: 100%; text-align: center">
      {{.Environment}} ENVIRONMENT
    </div>
    {{end}}
    {{template "srht-env-banner" .}}
    <nav class="container navbar navbar-light navbar-expand-sm">
      <span class="navbar-brand">
        <span class="icon icon-circle" aria-hidden="true"><svg width="22" height="22" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512"><path d="M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8zm0 448c-110.5 0-200-89.5-200-200S145.5 56 256 56s200 89.5 200 200-89.5 200-200 200z"/></svg></span>
        <a class="navbar-brand" href="/">
          {{.SiteName}}
          <span class="text-danger">spec</span>
        </a>
      </span>
      <ul class="navbar-nav">
        {{if .Username}}
        {{range .Nav}}
        <li class="nav-item {{if .Active}}active{{end}}">
          <a class="nav-link" href="{{.Origin}}">{{.Name}}</a>
        </li>
        {{end}}
        {{end}}
      </ul>
      <div class="login">
        {{if .Username}}
        <span class="navbar-text">
          Logged in as
          <a href="{{.ProfileURL}}">{{.Username}}</a>
          &mdash;
          <a href="{{.LogoutURL}}">Log out</a>
        </span>
        {{else}}
        <span class="navbar-text">
          <a href="{{.LoginURL}}" rel="nofollow">Log in</a>
          &mdash;
          <a href="{{.RegisterURL}}">Register</a>
        </span>
        {{end}}
      </div>
      {{template "srht-nav" .}}
    </nav>
    <div class="{{.ContainerClass}}">
      {{template "content" .}}

A web/view.go => web/view.go +52 -0
@@ 0,0 1,52 @@
package web

import (
	"net/http"

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

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

// viewData is the root value every template is executed against.
//
// chrome.Page is embedded rather than copied field by field, so the shared
// partials — "srht-nav", "srht-env-banner" — find the fields they read on the
// dot they are handed, and a field ecore adds later arrives here without an
// edit. The page's own payload lives under Data and is reached as
// {{.Data.Something}}, which is what keeps a page from shadowing a chrome
// field: a page wanting a "Username" of its own puts it in its payload, where
// it cannot silently replace the one the login block reads.
type viewData struct {
	chrome.Page

	// Data is the page's own payload.
	Data any
}

// view builds the frame for one request: the shared chrome plus a title.
//
// The username handed to the chrome is the *authoritative* identity, not
// whatever the cookie said: on this instance a logged-in human who is not the
// owner resolves to anonymous, so the nav offers them a login rather than
// greeting them by a name that grants nothing. An agent's bearer token is not
// an identity for the nav either — it never renders a page for itself.
func (s *Server) view(r *http.Request, title string) viewData {
	username := ""
	if p := authn.PrincipalFromContext(r.Context()); p.IsOwner() {
		username = p.Owner
	}
	return viewData{Page: s.chromeSvc.Page(r, title, username)}
}

// loginRedirect sends a viewer with no read authority to meta.sr.ht's login,
// 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.
func (s *Server) loginRedirect(w http.ResponseWriter, r *http.Request) {
	http.Redirect(w, r, s.chromeSvc.Page(r, "", "").LoginURL, http.StatusFound)
}

M web/web_test.go => web/web_test.go +39 -53
@@ 17,6 17,8 @@ import (
	"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"


@@ 476,7 478,9 @@ func testServerWith(t *testing.T, reader *fakeReader) (http.Handler, *fakeReader
		"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"},
		// Extra service sections to exercise nav ordering/exclusions.
		// 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"},


@@ 742,67 746,52 @@ func TestTrailingSlashRedirectsToTheSpace(t *testing.T) {
}

// ---- identity and chrome --------------------------------------------------

func TestForgedCookieYieldsLoggedInNav(t *testing.T) {
//
// What the shared chrome itself decides — the switcher's order, the services it
// excludes, the shape of a login URL, the environment banner — is sr-ht-ecore's
// and is tested there. What is left here is the seam: that this service hands
// ecore the identity its own authn resolved, and that the page around the
// chrome shows the right thing to that identity.

// The owner's cookie must reach the chrome as an identity: ecore renders the
// login block from the username it is given, so a greeting by name is the proof
// that view() passed one.
func TestOwnerCookieReachesTheChromeAsAnIdentity(t *testing.T) {
	h, _ := testServer(t)
	rec := get(t, h, "/", "bigbes")
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d", rec.Code)
	}
	require.Equal(t, http.StatusOK, rec.Code)

	body := rec.Body.String()
	if !strings.Contains(body, "Logged in as") || !strings.Contains(body, ">bigbes<") {
		t.Fatalf("cookie did not produce a logged-in nav:\n%s", body)
	}
	if !strings.Contains(body, "~bigbes/rfcs") {
		t.Fatal("logged-in landing page missing the space list")
	}
	if !strings.Contains(body, "https://todo.example") {
		t.Fatal("nav missing an expected service")
	}
	if strings.Contains(body, "https://paste.example") || strings.Contains(body, "https://pages.example") {
		t.Fatal("nav must exclude paste/pages")
	}
	nav := body[strings.Index(body, `<ul class="navbar-nav">`):strings.Index(body, "</ul>")]
	if strings.Contains(nav, "hub.example") {
		t.Fatal("hub is the brand, never a switcher item")
	}
	if !strings.Contains(nav, "nav-item active") {
		t.Fatal("spec should be the active nav item")
	}
	if !strings.Contains(body, "DEVELOPMENT ENVIRONMENT") {
		t.Fatal("non-production environment banner missing")
	}
	assert.Contains(t, body, "Logged in as")
	assert.Contains(t, body, ">bigbes<")
	assert.Contains(t, body, "~bigbes/rfcs", "the logged-in landing page lists the spaces")
}

// A cookie sealed for somebody who is not the instance owner carries no
// authority: authn resolves it to anonymous, and the nav must agree.
// authority: authn resolves it to anonymous, and the page must agree.
func TestNonOwnerCookieIsAnonymous(t *testing.T) {
	h, _ := testServer(t)
	rec := get(t, h, "/", "someoneelse")

	body := rec.Body.String()
	if strings.Contains(body, "Logged in as") {
		t.Fatalf("a non-owner cookie produced a logged-in nav:\n%s", body)
	}
	if strings.Contains(body, "~bigbes/rfcs") {
		t.Fatal("a non-owner must not see the space list")
	}
	assert.NotContains(t, body, "Logged in as", "a non-owner cookie produced a logged-in nav")
	assert.NotContains(t, body, "~bigbes/rfcs", "a non-owner must not see the space list")
}

func TestAnonymousLandingRendersWithoutContent(t *testing.T) {
	h, _ := testServer(t)
	rec := get(t, h, "/", "")
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200", rec.Code)
	}
	require.Equal(t, http.StatusOK, rec.Code)

	body := rec.Body.String()
	if !strings.Contains(body, "return_to=") {
		t.Fatal("login URL missing return_to")
	}
	if strings.Contains(body, "~bigbes/rfcs") {
		t.Fatal("anonymous landing page leaked a space name")
	}
	assert.Contains(t, body, "https://meta.example/login", "the landing page offers a login")
	assert.NotContains(t, body, "~bigbes/rfcs", "anonymous landing page leaked a space name")
}

// The read gate is this package's; the URL it redirects to is the chrome's. The
// assertions below are about the gate firing at all and about it sending a
// browser somewhere it can come back from — not about how ecore spells a login
// URL.
func TestAnonymousContentRedirectsToLogin(t *testing.T) {
	h, _ := testServer(t)
	for _, target := range []string{


@@ 811,16 800,13 @@ func TestAnonymousContentRedirectsToLogin(t *testing.T) {
		"/search?q=storage",
	} {
		rec := get(t, h, target, "")
		if rec.Code != http.StatusFound {
			t.Fatalf("%s: status = %d, want 302\n%s", target, rec.Code, rec.Body.String())
		}
		require.Equal(t, http.StatusFound, rec.Code, "%s: body %s", target, rec.Body)

		loc := rec.Header().Get("Location")
		if !strings.HasPrefix(loc, "https://meta.example/login?return_to=") {
			t.Fatalf("%s: location = %q", target, loc)
		}
		if !strings.Contains(loc, "spec.example") {
			t.Fatalf("%s: return_to does not point back at us: %q", target, loc)
		}
		assert.True(t, strings.HasPrefix(loc, "https://meta.example/login?return_to="),
			"%s: location = %q", target, loc)
		assert.Contains(t, loc, "spec.example",
			"%s: return_to does not point back at us: %q", target, loc)
	}
}