~bigbes/sr-ht-spec

2a7569f43a772345c1bc739089cbad2efae0e278 — Eugene Blikh 27 days ago c833182
feat: web — the Phase 2 read plane UI and its SCSS entry

SourceHut chrome (nav service-switcher, login block, environment banner,
error page, embedded hashed static assets) ported from compare.sr.ht, plus
the read-plane pages: landing, space document tree, rendered document with
frontmatter/backlinks, and search.

The URL grammar is the design's pinned one: a document's address carries no
extension, ".md" is raw source, ".json" is metadata plus body, and ?rev=
pins any of the three to an immutable revision. Links rendered inside a
pinned page keep the pin, so following one does not silently land on the
approved head.

One human and no visibility levels, so the read ACL is one line: the owner
and its agents read, everyone else is redirected to meta's login (a browser)
or refused with 401 (a client asking for .md/.json).
A scss/main.scss => scss/main.scss +91 -0
@@ 0,0 1,91 @@
// spec.sr.ht stylesheet.
//
// Mirrors the SourceHut service pattern (see paste.sr.ht/scss/main.scss and
// compare.sr.ht/scss/main.scss): pull in the shared `base` partial (Bootstrap 4
// plus the SourceHut chrome — contrast, variables, nav, events, highlight,
// dark) and then add only what is unique to the document views.
//
// Build: `make css`, which is `sassc -I /usr/share/sourcehut/scss scss/main.scss`
// plus minify plus a content-hashed filename. The shared partials come from
// core.sr.ht's `make install`; dart-sass works too for a local build.
//
// The output is EMBEDDED INTO THE BINARY (web/templates.go's //go:embed static),
// so `make css` alone does not restyle a running daemon: build the CSS, then
// `go build`, then restart.

@import "base";

// Let scheme-aware widgets follow prefers-color-scheme, as compare.sr.ht does.
:root {
  color-scheme: light dark;
}

// ---- Rendered document ----------------------------------------------------

.spec-doc {
  // Prose, not code: a comfortable measure matters more than filling the
  // column. The sidebar already takes a quarter of the width, so this only
  // bites on very wide screens.
  max-width: 46rem;

  h1, h2, h3, h4, h5, h6 {
    margin-top: 1.5rem;
  }

  // The first heading sits directly under the page title; the extra top margin
  // reads as a gap rather than as structure.
  > :first-child {
    margin-top: 0;
  }

  blockquote {
    padding-left: 0.75rem;
    border-left: 3px solid $gray-400;
    color: $gray-700;

    @media (prefers-color-scheme: dark) {
      border-left-color: $gray-700;
      color: $gray-300;
    }
  }

  table {
    @extend .table;
    width: auto;
  }

  img {
    max-width: 100%;
  }
}

// A wikilink that resolved to nothing. The renderer emits it as a span rather
// than dropping it, so the read plane doubles as a link checker — which only
// works if it is visibly different from a link that resolved.
.wikilink-missing {
  color: $danger;
  text-decoration: line-through dotted;
}

// ---- Search ---------------------------------------------------------------

.search-hit {
  margin-bottom: 0.75rem;
}

.search-snippet {
  color: $gray-700;

  // bleve wraps matched terms in <mark>; the browser default is a yellow that
  // is unreadable on the dark chrome.
  mark {
    padding: 0;
    background: transparent;
    color: inherit;
    font-weight: 700;
  }

  @media (prefers-color-scheme: dark) {
    color: $gray-300;
  }
}

A web/chrome.go => web/chrome.go +146 -0
@@ 0,0 1,146 @@
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)
}

A web/handlers.go => web/handlers.go +649 -0
@@ 0,0 1,649 @@
package web

import (
	"encoding/json"
	"errors"
	"fmt"
	"html/template"
	"log"
	"net/http"
	"path"
	"strings"

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

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

// searchLimit bounds one page of results.
const searchLimit = 25

// ---- format selectors -----------------------------------------------------

// format is which representation of a document was asked for. The extension in
// the URL selects it; the document's own address carries none.
type format int

const (
	formatHTML format = iota
	formatRaw
	formatJSON
)

// splitFormat peels a format selector off the tail of a document address.
//
// The design pins this: ".md" is the raw source and ".json" is metadata plus
// body, so neither is ever part of the address that identifies the document.
// The remainder is the document's address — its tree path minus core.DocExt.
func splitFormat(rest string) (string, format) {
	switch {
	case strings.HasSuffix(rest, ".json"):
		return strings.TrimSuffix(rest, ".json"), formatJSON
	case strings.HasSuffix(rest, core.DocExt):
		return strings.TrimSuffix(rest, core.DocExt), formatRaw
	default:
		return rest, formatHTML
	}
}

// ---- authorization --------------------------------------------------------

// mayRead reports whether a request carries authority to read content.
//
// One human, no visibility levels: the owner and its agents may read and nobody
// else may. A logged-in human who is not the instance owner has already been
// resolved to anonymous by authn, so this is the whole ACL.
func mayRead(r *http.Request) bool {
	p := authn.PrincipalFromContext(r.Context())
	return p.IsOwner() || p.IsAgent()
}

// denyRead answers a viewer with no read authority in the shape their client
// can act on: a browser is sent to meta's login, a machine asking for .md or
// .json gets a 401. Redirecting a bot to an HTML login page would hand it a
// 200 full of markup it cannot use.
func (s *Server) denyRead(w http.ResponseWriter, r *http.Request, f format) {
	if f == formatHTML {
		s.loginRedirect(w, r)
		return
	}
	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
	http.Error(w, "authentication required", http.StatusUnauthorized)
}

// ---- error mapping --------------------------------------------------------

// httpStatusFor maps a service error onto a status. service.ErrNotFound already
// folds "malformed revision" into "absent", so probing cannot tell them apart.
func httpStatusFor(err error) int {
	switch {
	case errors.Is(err, service.ErrNotFound):
		return http.StatusNotFound
	case errors.Is(err, core.ErrInvalidName), errors.Is(err, core.ErrInvalidPath):
		return http.StatusBadRequest
	default:
		return http.StatusInternalServerError
	}
}

// fail renders the chrome error page for err, logging 5xx causes and telling
// the viewer nothing about them.
func (s *Server) fail(w http.ResponseWriter, r *http.Request, err error) {
	status := httpStatusFor(err)
	if status >= 500 {
		log.Printf("web: %s: %v", r.URL.Path, err)
		s.renderError(w, r, status, "an internal error occurred")
		return
	}
	s.renderError(w, r, status, err.Error())
}

// failFormat is fail for a request that asked for .md or .json: those callers
// are machines, so they get a status and a line of text rather than a page.
func (s *Server) failFormat(w http.ResponseWriter, r *http.Request, f format, err error) {
	if f == formatHTML {
		s.fail(w, r, err)
		return
	}
	status := httpStatusFor(err)
	if status >= 500 {
		log.Printf("web: %s: %v", r.URL.Path, err)
		http.Error(w, "internal server error", status)
		return
	}
	http.Error(w, err.Error(), status)
}

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

type spaceLink struct {
	Ref  string
	Href string
}

type indexData struct {
	LoggedIn bool
	Spaces   []spaceLink
}

// handleIndex is the landing page: the spaces you can read.
//
// 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"

	data := indexData{LoggedIn: mayRead(r)}
	if data.LoggedIn {
		refs, err := s.reader.ListSpaces(r.Context())
		if err != nil {
			s.fail(w, r, err)
			return
		}
		for _, ref := range refs {
			data.Spaces = append(data.Spaces, spaceLink{Ref: ref.String(), Href: "/" + ref.String()})
		}
	}
	vd.Data = data
	s.render(w, http.StatusOK, "index", vd)
}

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

// treeItem is one row of the space's document tree, flattened to a depth so the
// template needs no recursion.
type treeItem struct {
	Depth   int
	Title   string
	Href    string
	ID      string
	DocID   string
	Status  string
	Summary string
	Section string
}

type spaceData struct {
	Ref      string
	Rev      string
	Pinned   bool
	RevQuery string
	Count    int
	Items    []treeItem
}

func (s *Server) handleSpace(w http.ResponseWriter, r *http.Request) {
	if !mayRead(r) {
		s.denyRead(w, r, formatHTML)
		return
	}
	ref, err := spaceRefFrom(r)
	if err != nil {
		s.fail(w, r, err)
		return
	}
	rev := r.URL.Query().Get("rev")
	snap, err := s.reader.Snapshot(r.Context(), ref, rev)
	if err != nil {
		s.fail(w, r, err)
		return
	}

	vd := s.chrome(r)
	vd.Title = ref.String()
	vd.Data = spaceData{
		Ref:      ref.String(),
		Rev:      snap.Rev,
		Pinned:   rev != service.ApprovedRev,
		RevQuery: revQuery(rev),
		Count:    len(snap.Archive.All()),
		Items:    flattenTree(snap, revQuery(rev)),
	}
	s.render(w, http.StatusOK, "space", vd)
}

// flattenTree walks the archive's `parent:` hierarchy into an ordered, depth-
// tagged list. A space where nobody sets `parent:` degrades to a flat list in
// path order, which is the common case and reads fine.
func flattenTree(snap *Snapshot, rq string) []treeItem {
	var out []treeItem
	seen := make(map[string]bool, len(snap.Archive.All()))

	var walk func(pages []*doc.Page, depth int)
	walk = func(pages []*doc.Page, depth int) {
		for _, p := range pages {
			if seen[p.ID] {
				continue // a `parent:` cycle must not hang the page
			}
			seen[p.ID] = true
			out = append(out, treeItem{
				Depth:   depth,
				Title:   p.Title,
				Href:    snap.Archive.DocHref(p) + rq,
				ID:      p.ID,
				DocID:   p.DocID,
				Status:  string(p.Status),
				Summary: p.Summary,
				Section: p.Section,
			})
			walk(snap.Archive.Children(p.ID), depth+1)
		}
	}
	walk(snap.Archive.Roots(), 0)

	// Anything a cycle kept out of the walk is still a document of this space
	// and must still be listed; dropping it would make the tree quietly lie
	// about what the revision contains.
	for _, p := range snap.Archive.All() {
		if !seen[p.ID] {
			out = append(out, treeItem{
				Title:   p.Title,
				Href:    snap.Archive.DocHref(p) + rq,
				ID:      p.ID,
				DocID:   p.DocID,
				Status:  string(p.Status),
				Summary: p.Summary,
				Section: p.Section,
			})
		}
	}
	return out
}

// ---- document -------------------------------------------------------------

type docLink struct {
	Title string
	Href  string
}

type docData struct {
	SpaceRef  string
	SpaceHref string
	Path      string
	Address   string
	ID        string
	DocID     string
	Title     string
	Status    string
	Summary   string
	Type      string
	Tags      []string
	Owners    []string
	Props     []doc.DocProperty
	Rev       string
	Blob      string
	Pinned    bool
	RevQuery  string
	Body      template.HTML
	Headings  []doc.Heading
	Children  []docLink
	Backlinks []docLink
	Missing   []string
	WordCount int
	RawHref   string
	JSONHref  string
}

// docJSON is the .json representation: metadata plus body.
type docJSON struct {
	Space      string            `json:"space"`
	Path       string            `json:"path"`
	Address    string            `json:"address"`
	ID         string            `json:"id"`
	DocID      string            `json:"doc_id,omitempty"`
	Rev        string            `json:"rev"`
	Blob       string            `json:"blob"`
	Kind       string            `json:"kind,omitempty"`
	Title      string            `json:"title"`
	Status     string            `json:"status,omitempty"`
	Summary    string            `json:"summary,omitempty"`
	Type       string            `json:"type,omitempty"`
	Section    string            `json:"section,omitempty"`
	Tags       []string          `json:"tags,omitempty"`
	Owners     []string          `json:"owners,omitempty"`
	Supersedes string            `json:"supersedes,omitempty"`
	Props      []doc.DocProperty `json:"props,omitempty"`
	Body       string            `json:"body"`
}

// handleDocument serves all three representations of one document.
//
// The three share a single route because they are one resource: the extension
// selects a format and the remainder is the address. Splitting them into three
// routes would let the address grammar drift apart between them, which is the
// exact confusion the pinned grammar exists to prevent.
func (s *Server) handleDocument(w http.ResponseWriter, r *http.Request) {
	rest, ok := unescapePath(chi.URLParam(r, "*"))
	if !ok {
		s.renderError(w, r, http.StatusBadRequest, "malformed path")
		return
	}
	address, f := splitFormat(rest)

	if !mayRead(r) {
		s.denyRead(w, r, f)
		return
	}
	ref, err := spaceRefFrom(r)
	if err != nil {
		s.failFormat(w, r, f, err)
		return
	}
	rev := r.URL.Query().Get("rev")
	rq := revQuery(rev)

	// "/~owner/space/" is the space, spelled with a trailing slash.
	if address == "" {
		http.Redirect(w, r, "/"+ref.String()+rq, http.StatusFound)
		return
	}

	docPath := address + core.DocExt
	if err := core.ValidateDocPath(docPath); err != nil {
		s.failFormat(w, r, f, err)
		return
	}

	// Raw source needs no archive: it is the bytes of one blob, and building a
	// whole-revision archive to hand them over would make the cheapest read the
	// most expensive one.
	if f == formatRaw {
		d, err := s.reader.ReadDocument(r.Context(), ref, rev, docPath)
		if err != nil {
			s.failFormat(w, r, f, err)
			return
		}
		w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
		w.Header().Set("X-Spec-Rev", d.Rev)
		w.Header().Set("X-Spec-Blob", d.Blob)
		_, _ = w.Write(d.Data)
		return
	}

	snap, err := s.reader.Snapshot(r.Context(), ref, rev)
	if err != nil {
		s.failFormat(w, r, f, err)
		return
	}
	page, ok := snap.Archive.ByPath(docPath)
	if !ok {
		// The address may be a document id rather than a path. Redirecting
		// rather than serving keeps one document at one canonical URL — and a
		// duplicated id resolves to neither document, so this cannot silently
		// pick a winner.
		if p, found := snap.Archive.Page(address); found {
			http.Redirect(w, r, snap.Archive.DocHref(p)+rq, http.StatusFound)
			return
		}
		s.failFormat(w, r, f, fmt.Errorf("%w: %s in %s at %s",
			service.ErrNotFound, docPath, ref, snap.Rev))
		return
	}

	body, ok := snap.Bodies[docPath]
	if !ok {
		s.failFormat(w, r, f, fmt.Errorf("web: %s is in the archive of %s at %s but has no body",
			docPath, ref, snap.Rev))
		return
	}
	front, mdBody := doc.ParseFront(body)

	if f == formatJSON {
		payload := docJSON{
			Space:      ref.String(),
			Path:       page.Path,
			Address:    address,
			ID:         page.ID,
			DocID:      page.DocID,
			Rev:        snap.Rev,
			Blob:       page.Blob,
			Kind:       string(page.Kind),
			Title:      page.Title,
			Status:     string(page.Status),
			Summary:    page.Summary,
			Type:       front.Type,
			Section:    page.Section,
			Tags:       page.Tags,
			Owners:     front.Owners,
			Supersedes: front.Supersedes,
			Props:      front.Props,
			Body:       string(mdBody),
		}
		w.Header().Set("Content-Type", "application/json; charset=utf-8")
		enc := json.NewEncoder(w)
		enc.SetIndent("", "  ")
		if err := enc.Encode(payload); err != nil {
			log.Printf("web: encoding %s.json: %v", docPath, err)
		}
		return
	}

	// Backlinks need the whole revision's link graph, and doc.Page.Links is
	// filled by a render pass rather than by Scan — so the pass happens here.
	if err := s.linkPass(snap); err != nil {
		s.fail(w, r, err)
		return
	}
	res := s.renderer.Render(mdBody, dirOf(docPath), pinned{inner: snap.Archive, rq: rq})

	data := docData{
		SpaceRef:  ref.String(),
		SpaceHref: "/" + ref.String() + rq,
		Path:      page.Path,
		Address:   address,
		ID:        page.ID,
		DocID:     page.DocID,
		Title:     page.Title,
		Status:    string(page.Status),
		Summary:   page.Summary,
		Type:      front.Type,
		Tags:      page.Tags,
		Owners:    front.Owners,
		Props:     front.Props,
		Rev:       snap.Rev,
		Blob:      page.Blob,
		Pinned:    rev != service.ApprovedRev,
		RevQuery:  rq,
		Body:      template.HTML(res.HTML),
		Headings:  res.Headings,
		Missing:   res.MissingWikilinks,
		WordCount: res.WordCount,
		RawHref:   snap.Archive.DocHref(page) + core.DocExt + rq,
		JSONHref:  snap.Archive.DocHref(page) + ".json" + rq,
	}
	for _, c := range snap.Archive.Children(page.ID) {
		data.Children = append(data.Children, docLink{Title: c.Title, Href: snap.Archive.DocHref(c) + rq})
	}
	for _, b := range snap.Archive.Backlinks(page.ID) {
		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.Data = data
	s.render(w, http.StatusOK, "document", vd)
}

// pinned wraps the archive's resolver so that every site-internal link a
// rendered document emits keeps the revision the reader is on.
//
// Without it, following a wikilink out of a page opened at ?rev=<sha> lands on
// the approved head — silently, and in the middle of reading a pinned
// revision. The pin is the read contract's whole point, so it has to survive
// one hop. External and unresolved destinations are handed back untouched: the
// first is not ours to rewrite, and the second is deliberately the text the
// author typed.
type pinned struct {
	inner doc.Resolver
	rq    string
}

func (p pinned) Resolve(fromDir, dest string) doc.Target {
	t := p.inner.Resolve(fromDir, dest)
	if p.rq == "" || t.IsExternal || t.Missing || !strings.HasPrefix(t.Href, "/") {
		return t
	}
	href, frag, hasFrag := strings.Cut(t.Href, "#")
	t.Href = href + p.rq
	if hasFrag {
		t.Href += "#" + frag
	}
	return t
}

// linkPass renders every document of the revision to fill in doc.Page.Links,
// which is what Archive.Backlinks reads. Scan deliberately does not do it —
// links come out of a render, not out of a frontmatter parse.
//
// A page with no body is an inconsistency between the archive and the bodies,
// which cannot happen while both are read at one resolved sha. It is reported
// rather than skipped: skipping it would silently drop that document's outbound
// links and quietly under-report backlinks everywhere else.
func (s *Server) linkPass(snap *Snapshot) error {
	for _, p := range snap.Archive.All() {
		raw, ok := snap.Bodies[p.Path]
		if !ok {
			return fmt.Errorf("web: %s is in the archive of %s at %s but has no body",
				p.Path, snap.Ref, snap.Rev)
		}
		_, mdBody := doc.ParseFront(raw)
		res := s.renderer.Render(mdBody, dirOf(p.Path), snap.Archive)
		p.Links = res.LinkedIDs
		p.WordCount = res.WordCount
	}
	return nil
}

// ---- search ---------------------------------------------------------------

type searchHit struct {
	Title   string
	Href    string
	Space   string
	Section string
	Score   float64
	Snippet template.HTML
}

type searchData struct {
	Query  string
	Space  string
	Total  uint64
	Took   string
	Hits   []searchHit
	Spaces []spaceLink
}

func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
	if !mayRead(r) {
		s.denyRead(w, r, formatHTML)
		return
	}
	q := strings.TrimSpace(r.URL.Query().Get("q"))
	spaceParam := strings.TrimSpace(r.URL.Query().Get("space"))

	data := searchData{Query: q, Space: spaceParam}
	refs, err := s.reader.ListSpaces(r.Context())
	if err != nil {
		s.fail(w, r, err)
		return
	}
	for _, ref := range refs {
		data.Spaces = append(data.Spaces, spaceLink{Ref: ref.String(), Href: "/" + ref.String()})
	}

	query := search.Query{Text: q, Limit: searchLimit}
	if spaceParam != "" {
		ref, err := core.ParseSpaceRef(spaceParam)
		if err != nil {
			s.fail(w, r, err)
			return
		}
		query.Spaces = []core.SpaceRef{ref}
	}

	if q != "" {
		res, err := s.searcher.Search(r.Context(), query)
		if err != nil {
			s.fail(w, r, err)
			return
		}
		data.Total = res.Total
		data.Took = res.Took.Round(100000).String()
		for _, h := range res.Hits {
			data.Hits = append(data.Hits, searchHit{
				Title:   h.Title,
				Href:    hitHref(h),
				Space:   h.Space.String(),
				Section: h.Section,
				Score:   h.Score,
				// bleve's formatter escapes everything around the <mark> tags
				// it inserts, so this fragment is HTML and must be rendered as
				// HTML — as text the marks show up literally.
				Snippet: template.HTML(h.Snippet),
			})
		}
	}

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

// hitHref turns a hit into the pinned URL the design specifies for it:
// /~owner/space/<address>?rev=<sha>#<anchor>, where the address is the tree
// path minus its extension because that is what a document's address is.
func hitHref(h search.Hit) string {
	href := "/" + h.Space.String() + "/" + strings.TrimSuffix(h.Path, core.DocExt)
	if h.Rev != "" {
		href += "?rev=" + h.Rev
	}
	if h.Anchor != "" {
		href += "#" + h.Anchor
	}
	return href
}

// ---- helpers --------------------------------------------------------------

// spaceRefFrom builds the space reference out of the route parameters. The '~'
// is routing decoration and is never part of the stored owner.
func spaceRefFrom(r *http.Request) (core.SpaceRef, error) {
	owner := chi.URLParam(r, "owner")
	name := chi.URLParam(r, "space")
	ref := core.SpaceRef{Owner: owner, Name: name}
	if err := core.ValidateOwner(ref.Owner); err != nil {
		return core.SpaceRef{}, err
	}
	if err := core.ValidateSpaceName(ref.Name); err != nil {
		return core.SpaceRef{}, err
	}
	return ref, nil
}

// revQuery renders the pin a page's own links must carry so that following one
// stays on the revision the reader is looking at. An unpinned read produces no
// query at all, which is what makes the approved head the default everywhere.
func revQuery(rev string) string {
	if rev == service.ApprovedRev {
		return ""
	}
	return "?rev=" + rev
}

// dirOf is the directory a document lives in, space-relative, with "" for the
// space root — the shape doc's resolver expects.
func dirOf(p string) string {
	d := path.Dir(p)
	if d == "." || d == "/" {
		return ""
	}
	return d
}

A web/reader.go => web/reader.go +109 -0
@@ 0,0 1,109 @@
package web

import (
	"context"
	"fmt"

	"sourcecraft.dev/bigbes/sr-ht-spec/core"
	"sourcecraft.dev/bigbes/sr-ht-spec/doc"
	"sourcecraft.dev/bigbes/sr-ht-spec/search"
	"sourcecraft.dev/bigbes/sr-ht-spec/service"
)

// Snapshot is one space at one *resolved* revision: the addressable document
// set plus every document's bytes.
//
// Rev is always a commit sha, never a branch name, even when the request
// carried no ?rev= at all. The read plane resolves first and renders second, so
// a merge landing mid-request cannot make one page describe two revisions —
// and the sha it resolved to is the value the page offers as its permalink.
//
// Bodies is keyed by tree path, the same key doc.Page.Path carries. It is
// present because doc.Page.Links is filled by a render pass rather than by
// Scan, so backlinks require rendering every document in the space; see
// linkPass.
type Snapshot struct {
	Ref     core.SpaceRef
	Rev     string
	Archive *doc.Archive
	Bodies  map[string][]byte
}

// Reader is the read surface this package needs. *service.Service provides it
// through NewReader.
//
// It is an interface for the same reason compare.sr.ht's authz.Authorizer is:
// so the handlers can be tested against a document set rather than against a
// Postgres instance and a tree of bare repositories.
type Reader interface {
	// ListSpaces returns every space on the instance. Single-user with no
	// visibility levels means there is nothing to filter — the list is the
	// whole corpus.
	ListSpaces(ctx context.Context) ([]core.SpaceRef, error)

	// Snapshot resolves rev (service.ApprovedRev for the approved head) and
	// returns the space at that revision.
	Snapshot(ctx context.Context, ref core.SpaceRef, rev string) (*Snapshot, error)

	// ReadDocument reads one document by tree path at a revision.
	ReadDocument(ctx context.Context, ref core.SpaceRef, rev, path string) (service.Document, error)
}

// Searcher is the keyword index. *search.Index satisfies it as declared.
type Searcher interface {
	Search(ctx context.Context, q search.Query) (search.Results, error)
}

// NewReader adapts a *service.Service to Reader.
func NewReader(svc *service.Service) Reader { return serviceReader{svc: svc} }

// serviceReader is the production Reader: everything goes through service/,
// which is the layer that is allowed to touch gitx and db.
type serviceReader struct{ svc *service.Service }

func (r serviceReader) ListSpaces(ctx context.Context) ([]core.SpaceRef, error) {
	spaces, err := r.svc.ListSpaces(ctx)
	if err != nil {
		return nil, err
	}
	refs := make([]core.SpaceRef, 0, len(spaces))
	for _, sp := range spaces {
		refs = append(refs, sp.Ref)
	}
	return refs, nil
}

func (r serviceReader) Snapshot(ctx context.Context, ref core.SpaceRef, rev string) (*Snapshot, error) {
	sp, err := r.svc.OpenSpace(ctx, ref)
	if err != nil {
		return nil, err
	}
	resolved, err := r.svc.ResolveRev(ctx, sp, rev)
	if err != nil {
		return nil, err
	}
	// Both reads below are issued against the resolved sha rather than against
	// the caller's rev, so the archive and the bodies are the same revision by
	// construction and cannot disagree if a merge lands between them.
	arc, err := doc.Scan(ctx, sp.Repo, ref, resolved)
	if err != nil {
		return nil, fmt.Errorf("web: scan %s at %s: %w", ref, resolved, err)
	}
	docs, err := r.svc.ListDocuments(ctx, sp, resolved)
	if err != nil {
		return nil, err
	}
	bodies := make(map[string][]byte, len(docs))
	for _, d := range docs {
		bodies[d.Path] = d.Data
	}
	return &Snapshot{Ref: ref, Rev: resolved, Archive: arc, Bodies: bodies}, nil
}

func (r serviceReader) ReadDocument(ctx context.Context, ref core.SpaceRef, rev, p string) (service.Document, error) {
	sp, err := r.svc.OpenSpace(ctx, ref)
	if err != nil {
		return service.Document{}, err
	}
	return r.svc.ReadDocument(ctx, sp, rev, p)
}

A web/router.go => web/router.go +86 -0
@@ 0,0 1,86 @@
package web

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

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

// Handler returns a router with everything this package needs already
// installed: panic recovery and the authn principal middleware, then the
// routes. The daemon mounts it at "/".
//
// A caller that owns its own middleware stack — and has already applied
// authn.Resolver.Middleware to it — uses Register instead. Installing the
// principal middleware twice is harmless but pointless: it is idempotent.
func (s *Server) Handler() http.Handler {
	r := chi.NewRouter()
	r.Use(middleware.Recoverer)
	r.Use(s.resolver.Middleware())
	s.Register(r)
	return r
}

// Register mounts every spec.sr.ht read-plane route onto r. It installs no
// middleware of its own; the router it is handed must already resolve a
// principal into the request context (authn.Resolver.Middleware), or every
// viewer looks anonymous.
//
// The document route is a single wildcard because the format selector lives in
// the *extension* and the document's address does not have one: ".md" and
// ".json" are stripped from the tail by the handler, never routed on, so a
// document called "notes/2026.json.md" is still reachable and a request for
// "notes/2026.json" still means "the JSON of notes/2026".
func (s *Server) Register(r chi.Router) {
	r.Get("/", s.handleIndex)
	r.Get("/healthz", s.handleHealthz)
	r.Get("/static/*", s.handleStatic)
	r.Get("/search", s.handleSearch)

	r.Get("/~{owner}/{space}", s.handleSpace)
	r.Get("/~{owner}/{space}/*", s.handleDocument)
}

// handleHealthz is a dependency-free liveness probe.
func (s *Server) handleHealthz(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
	_, _ = w.Write([]byte("ok\n"))
}

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

// unescapePath decodes a chi wildcard back into a tree path.
//
// chi routes on r.URL.RawPath when the request had one, so the wildcard arrives
// percent-encoded — which it must, since doc.Archive escapes every href segment
// so spaces and Cyrillic survive. Decoding is per segment on purpose: a %2F
// inside a segment is not a path separator and must not become one.
func unescapePath(raw string) (string, bool) {
	if raw == "" {
		return "", true
	}
	segs := strings.Split(raw, "/")
	for i, seg := range segs {
		dec, err := url.PathUnescape(seg)
		if err != nil {
			return "", false
		}
		segs[i] = dec
	}
	return strings.Join(segs, "/"), true
}

A web/server.go => web/server.go +186 -0
@@ 0,0 1,186 @@
// 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.
//
// 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.
//
// # URL grammar
//
// The design pins this, so it is spelled out here rather than left to the
// router: a document's address carries no extension. The extension is a format
// selector and never part of the document's identity.
//
//	/~user/space/specs/0007-storage          rendered HTML
//	/~user/space/specs/0007-storage.md       raw source (frontmatter + body)
//	/~user/space/specs/0007-storage.json     metadata + body
//	…?rev=<sha>                              any of the three, pinned
//
// An absent ?rev= means the approved head — service.ApprovedRev — because
// serving drafts by default would poison every downstream agent context with
// unreviewed text.
//
// # Who may read
//
// The instance has one human. There are no visibility levels, so the read ACL
// is one line: the owner and its agents may read, and everyone else may not.
// An anonymous browser asking for a page is redirected to meta.sr.ht's login
// (there is no login flow of our own); an anonymous client asking for .md or
// .json gets a 401, because redirecting a bot to an HTML login page tells it
// nothing.
//
// # What the cmd layer must wire
//
// [Server.Handler] returns a router with everything this package needs already
// installed, so the daemon can mount it at "/". A caller that owns its own
// router and middleware stack uses [Server.Register] instead; it installs
// routes only, and assumes authn.Resolver.Middleware is already applied.
//
// # Assets are embedded
//
// static/ is compiled into the binary by //go:embed. `make css` rewrites
// web/static/main.min.<hash>.css on disk and a *running* daemon will not notice
// — the CSS is baked in at `go build` time, so the build order is css then
// build then restart. A binary built with no stylesheet present logs a loud
// warning at startup and renders unstyled rather than refusing to start:
// missing CSS degrades presentation, not correctness.
package web

import (
	"fmt"
	"io/fs"
	"log"
	"net/http"
	"path"
	"regexp"

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

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

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

// 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 ini.File

	// Reader is the read surface over spaces and documents. NewReader adapts a
	// *service.Service to it.
	Reader Reader

	// Searcher is the keyword index. *search.Index satisfies it directly.
	Searcher Searcher

	// Resolver turns the unified-login cookie or an agent bearer token into a
	// principal. Handler installs its middleware; Register does not.
	Resolver *authn.Resolver
}

// Server holds the immutable configuration a request handler needs. It is built
// once at startup and is safe for concurrent use.
type Server struct {
	reader   Reader
	searcher Searcher
	resolver *authn.Resolver
	renderer *doc.Renderer

	siteName    string
	environment string
	metaOrigin  string
	origin      string
	hubOrigin   string
	cssHref     string

	nav              []navItem
	staticFileServer http.Handler
}

// New assembles a Server from the shared SourceHut config.
//
// [spec.sr.ht] origin and [meta.sr.ht] origin are required: without the first
// there is no return_to to hand meta, and without the second there is no login
// at all. A missing key is a clear error rather than a panic, so the daemon can
// fail startup loudly.
func New(opts Options) (*Server, error) {
	if opts.Conf == nil {
		return nil, fmt.Errorf("web: config is required")
	}
	if opts.Reader == nil {
		return nil, fmt.Errorf("web: Reader is required")
	}
	if opts.Searcher == nil {
		return nil, fmt.Errorf("web: Searcher is required")
	}
	if opts.Resolver == nil {
		return nil, fmt.Errorf("web: authn Resolver is required")
	}

	origin := config.GetOrigin(opts.Conf, authn.ConfigSection, true)
	if origin == "" {
		return nil, fmt.Errorf("web: [%s] origin is required", authn.ConfigSection)
	}
	metaOrigin := config.GetOrigin(opts.Conf, "meta.sr.ht", true)
	if metaOrigin == "" {
		return nil, fmt.Errorf("web: [meta.sr.ht] origin is required")
	}

	cssHref, err := resolveCSSHref()
	if err != nil {
		return nil, err
	}
	if cssHref == "" {
		log.Printf("web: no main.min.*.css embedded in this binary — pages will " +
			"render unstyled; run `make css` before `go build`")
	}

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

	return &Server{
		reader:           opts.Reader,
		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),
		cssHref:          cssHref,
		nav:              buildNav(opts.Conf),
		staticFileServer: http.StripPrefix("/static/", http.FileServer(http.FS(staticSub))),
	}, nil
}

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

A web/static/logo.svg => web/static/logo.svg +14 -0
@@ 0,0 1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128" viewBox="0 0 128 128">
	<style>
		@media (prefers-color-scheme: light) {
			#outline { display: none }
			#logo { stroke: black }
		}
		@media (prefers-color-scheme: dark) {
			#outline { display: none }
			#logo { stroke: white }
		}
	</style>
	<circle id="outline" cx="64" cy="64" r="50" fill="none" stroke-width="14" stroke="#888" />
	<circle id="logo" cx="64" cy="64" r="50" fill="none" stroke-width="10" stroke="white" />
</svg>

A web/templates.go => web/templates.go +101 -0
@@ 0,0 1,101 @@
package web

import (
	"bytes"
	"embed"
	"html/template"
	"log"
	"net/http"
	"strings"
)

// tmplFS holds the page templates. Each page is parsed together with the shared
// layout into its own template set so that per-page "content" defines do not
// collide across pages.
//
//go:embed templates/*.html
var tmplFS embed.FS

// staticFS holds the built assets: the hashed stylesheet produced by `make css`
// and the logo. It is compiled into the binary, which is why `make css` alone
// does not restyle a running daemon — see the package doc.
//
//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
	},
	// 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 {
		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 },
}

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

// pages maps a page name to its parsed template set (layout + that page).
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 = template.Must(t.ParseFS(tmplFS, "templates/layout.html", "templates/"+name+".html"))
		m[name] = t
	}
	return m
}()

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

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

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

A web/templates/document.html => web/templates/document.html +96 -0
@@ 0,0 1,96 @@
{{define "content"}}
<div class="row">
  <div class="col-md-12">
    <p class="text-muted">
      <a href="{{.Data.SpaceHref}}">{{.Data.SpaceRef}}</a> / <code>{{.Data.Path}}</code>
    </p>
    <h2>{{.Data.Title}}</h2>
    {{if .Data.Summary}}<p class="lead">{{.Data.Summary}}</p>{{end}}
  </div>
</div>

<div class="row">
  <div class="col-md-9">
    <article class="spec-doc">
      {{.Data.Body}}
    </article>

    {{if .Data.Missing}}
    <div class="alert alert-warning">
      Unresolved wikilinks:
      {{range .Data.Missing}}<code>{{.}}</code> {{end}}
    </div>
    {{end}}
  </div>

  <aside class="col-md-3">
    <h4>Metadata</h4>
    <dl>
      <dt>Address</dt>
      <dd><code>{{.Data.Address}}</code></dd>
      <dt>ID</dt>
      <dd><code>{{if .Data.DocID}}{{.Data.DocID}}{{else}}{{.Data.ID}}{{end}}</code></dd>
      {{if .Data.Status}}<dt>Status</dt><dd>{{.Data.Status}}</dd>{{end}}
      {{if .Data.Type}}<dt>Type</dt><dd>{{.Data.Type}}</dd>{{end}}
      {{if .Data.Tags}}
      <dt>Tags</dt>
      <dd>{{range .Data.Tags}}<span class="badge badge-secondary">{{.}}</span> {{end}}</dd>
      {{end}}
      {{if .Data.Owners}}<dt>Owners</dt><dd>{{range .Data.Owners}}{{.}} {{end}}</dd>{{end}}
      <dt>Revision</dt>
      <dd>
        <code title="{{.Data.Rev}}">{{shortsha .Data.Rev}}</code>
        {{if .Data.Pinned}}<span class="badge badge-secondary">pinned</span>
        {{else}}<span class="badge badge-secondary">approved</span>{{end}}
      </dd>
      <dt>Blob</dt>
      <dd><code title="{{.Data.Blob}}">{{shortsha .Data.Blob}}</code></dd>
      <dt>Words</dt>
      <dd>{{.Data.WordCount}}</dd>
    </dl>

    <h4>Formats</h4>
    <ul class="list-unstyled">
      <li><a href="{{.Data.RawHref}}">raw markdown (.md)</a></li>
      <li><a href="{{.Data.JSONHref}}">metadata + body (.json)</a></li>
      {{if not .Data.Pinned}}
      <li><a href="?rev={{.Data.Rev}}">permalink to this revision</a></li>
      {{else}}
      <li><a href="{{.Data.SpaceHref}}">back to the approved revision</a></li>
      {{end}}
    </ul>

    {{if .Data.Props}}
    <h4>Frontmatter</h4>
    <dl>
      {{range .Data.Props}}<dt>{{.Name}}</dt><dd>{{.Value}}</dd>{{end}}
    </dl>
    {{end}}

    {{if .Data.Headings}}
    <h4>Contents</h4>
    <ul class="list-unstyled">
      {{range .Data.Headings}}
      <li>{{indent (dec .Level)}}<a href="#{{.ID}}">{{.Text}}</a></li>
      {{end}}
    </ul>
    {{end}}

    {{if .Data.Children}}
    <h4>Children</h4>
    <ul class="list-unstyled">
      {{range .Data.Children}}<li><a href="{{.Href}}">{{.Title}}</a></li>{{end}}
    </ul>
    {{end}}

    <h4>Backlinks</h4>
    {{if .Data.Backlinks}}
    <ul class="list-unstyled">
      {{range .Data.Backlinks}}<li><a href="{{.Href}}">{{.Title}}</a></li>{{end}}
    </ul>
    {{else}}
    <p class="text-muted">Nothing links here.</p>
    {{end}}
  </aside>
</div>
{{end}}

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

A web/templates/index.html => web/templates/index.html +54 -0
@@ 0,0 1,54 @@
{{define "content"}}
<div class="row">
  <div class="col-md-12">
    <h2>{{.SiteName}} <span class="text-danger">spec</span></h2>
    <p>
      Reviewable document storage: agents propose, you curate, agents read back
      the approved text. Reads default to the approved revision; add
      <code>?rev=&lt;sha&gt;</code> to pin any page to an immutable one.
    </p>
  </div>
</div>

{{if .Data.LoggedIn}}
<div class="row">
  <div class="col-md-12">
    <form method="GET" action="/search" class="form-inline">
      <div class="form-group">
        <label class="sr-only" for="q">Query</label>
        <input class="form-control" type="text" id="q" name="q"
               placeholder="search every space" autocomplete="off">
      </div>
      <button class="btn btn-primary" type="submit">Search</button>
    </form>
  </div>
</div>

<div class="row">
  <div class="col-md-12">
    <h3>Your spaces</h3>
    {{if .Data.Spaces}}
    <ul class="list-unstyled">
      {{range .Data.Spaces}}
      <li><a href="{{.Href}}"><strong>{{.Ref}}</strong></a></li>
      {{end}}
    </ul>
    {{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}}
  </div>
</div>
{{else}}
<div class="row">
  <div class="col-md-12">
    <p>
      <a href="{{.LoginURL}}" rel="nofollow">Log in</a> to browse the spaces on
      this instance. Agents authenticate with a bearer token instead.
    </p>
  </div>
</div>
{{end}}
{{end}}

A web/templates/layout.html => web/templates/layout.html +60 -0
@@ 0,0 1,60 @@
<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <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}}
    {{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}}
    <nav class="container navbar navbar-light navbar-expand-sm">
      {{if .HubOrigin}}
      <span class="navbar-brand">
        <a href="{{.HubOrigin}}">{{.SiteName}}</a>
      </span>
      {{else}}
      <span class="navbar-brand">
        <a class="navbar-brand" href="/">
          {{.SiteName}}
          <span class="text-danger">spec</span>
        </a>
      </span>
      {{end}}
      <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>
    </nav>
    <div class="{{.ContainerClass}}">
      {{template "content" .}}
    </div>
  </body>
</html>

A web/templates/search.html => web/templates/search.html +48 -0
@@ 0,0 1,48 @@
{{define "content"}}
<div class="row">
  <div class="col-md-12">
    <h2>Search</h2>
    <form method="GET" action="/search" class="form-inline">
      <div class="form-group">
        <label class="sr-only" for="q">Query</label>
        <input class="form-control" type="text" id="q" name="q"
               value="{{.Data.Query}}" placeholder="query" autocomplete="off">
      </div>
      <div class="form-group">
        <label class="sr-only" for="space">Space</label>
        <select class="form-control" id="space" name="space">
          <option value="">every space</option>
          {{$sel := .Data.Space}}
          {{range .Data.Spaces}}
          <option value="{{.Ref}}" {{if eq .Ref $sel}}selected{{end}}>{{.Ref}}</option>
          {{end}}
        </select>
      </div>
      <button class="btn btn-primary" type="submit">Search</button>
    </form>
  </div>
</div>

<div class="row">
  <div class="col-md-12">
    {{if .Data.Query}}
    <p class="text-muted">{{.Data.Total}} match{{if ne .Data.Total 1}}es{{end}} in {{.Data.Took}}</p>
    {{if .Data.Hits}}
    <ul class="list-unstyled">
      {{range .Data.Hits}}
      <li class="search-hit">
        <a href="{{.Href}}"><strong>{{.Title}}</strong></a>
        <span class="text-muted">— {{.Space}}{{if .Section}} / {{.Section}}{{end}}</span>
        {{if .Snippet}}<div class="search-snippet">{{.Snippet}}</div>{{end}}
      </li>
      {{end}}
    </ul>
    {{else}}
    <p class="text-muted">No documents matched.</p>
    {{end}}
    {{else}}
    <p class="text-muted">Enter a query.</p>
    {{end}}
  </div>
</div>
{{end}}

A web/templates/space.html => web/templates/space.html +54 -0
@@ 0,0 1,54 @@
{{define "content"}}
<div class="row">
  <div class="col-md-12">
    <h2>{{.Data.Ref}}</h2>
    <p class="text-muted">
      {{.Data.Count}} document{{if ne .Data.Count 1}}s{{end}} at
      <code title="{{.Data.Rev}}">{{shortsha .Data.Rev}}</code>
      {{if .Data.Pinned}}
      <span class="badge badge-secondary">pinned</span>
      &mdash; <a href="/{{.Data.Ref}}">back to the approved revision</a>
      {{else}}
      <span class="badge badge-secondary">approved</span>
      &mdash; <a href="/{{.Data.Ref}}?rev={{.Data.Rev}}">permalink to this revision</a>
      {{end}}
    </p>
    <form method="GET" action="/search" class="form-inline">
      <input type="hidden" name="space" value="{{.Data.Ref}}">
      <div class="form-group">
        <label class="sr-only" for="q">Query</label>
        <input class="form-control" type="text" id="q" name="q"
               placeholder="search {{.Data.Ref}}" autocomplete="off">
      </div>
      <button class="btn btn-primary" type="submit">Search</button>
    </form>
  </div>
</div>

<div class="row">
  <div class="col-md-12">
    {{if .Data.Items}}
    <table class="table">
      <thead>
        <tr><th>Document</th><th>ID</th><th>Status</th><th>Section</th></tr>
      </thead>
      <tbody>
        {{range .Data.Items}}
        <tr>
          <td>
            {{indent .Depth}}<a href="{{.Href}}">{{.Title}}</a>
            {{if .Summary}}<div class="text-muted"><small>{{indent .Depth}}{{.Summary}}</small></div>{{end}}
          </td>
          <td><code>{{if .DocID}}{{.DocID}}{{else}}&mdash;{{end}}</code></td>
          <td>{{if .Status}}<span class="badge badge-secondary">{{.Status}}</span>{{end}}</td>
          <td class="text-muted">{{.Section}}</td>
        </tr>
        {{end}}
      </tbody>
    </table>
    {{else}}
    <p class="text-muted">This space has no documents at this revision.</p>
    {{end}}
  </div>
</div>
{{end}}

A web/web_test.go => web/web_test.go +719 -0
@@ 0,0 1,719 @@
package web

import (
	"context"
	"crypto/rand"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"net/http"
	"net/http/httptest"
	"os"
	"sort"
	"strings"
	"testing"

	"github.com/fernet/fernet-go"
	"github.com/go-git/go-git/v5/plumbing"
	"github.com/vaughan0/go-ini"
	"sourcecraft.dev/bigbes/sr-ht-core/crypto"

	"sourcecraft.dev/bigbes/sr-ht-spec/authn"
	"sourcecraft.dev/bigbes/sr-ht-spec/core"
	"sourcecraft.dev/bigbes/sr-ht-spec/doc"
	"sourcecraft.dev/bigbes/sr-ht-spec/gitx"
	"sourcecraft.dev/bigbes/sr-ht-spec/search"
	"sourcecraft.dev/bigbes/sr-ht-spec/service"
)

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

func TestMain(m *testing.M) {
	var fk fernet.Key
	if err := fk.Generate(); err != nil {
		panic("generate fernet key: " + err.Error())
	}
	seed := make([]byte, 32)
	if _, err := rand.Read(seed); err != nil {
		panic("generate webhook seed: " + err.Error())
	}
	testConf = ini.File{
		"sr.ht":    ini.Section{"network-key": fk.Encode()},
		"webhooks": ini.Section{"private-key": base64.StdEncoding.EncodeToString(seed)},
	}
	crypto.InitCrypto(testConf)
	os.Exit(m.Run())
}

// ---- fixtures -------------------------------------------------------------

const (
	headRev = "1111111111111111111111111111111111111111"
	oldRev  = "2222222222222222222222222222222222222222"
	agentTk = "test-agent-token"
)

var demoSpace = core.SpaceRef{Owner: "bigbes", Name: "rfcs"}

// headDocs is the space at its approved head. SPEC-0007 links to SPEC-0003, so
// SPEC-0003 has a backlink; notes/plain.md has no frontmatter at all and is
// therefore addressed by its path, per the design's addressing rule.
var headDocs = map[string]string{
	"specs/0007-storage.md": `---
id: SPEC-0007
title: Proposal storage model
status: draft
tags: [storage, review]
summary: How proposals are stored.
---

# Proposal storage model

Git is authoritative, and this supersedes [[SPEC-0003]].
`,
	"specs/0003-old.md": `---
id: SPEC-0003
title: Older storage sketch
status: superseded
---

# Older storage sketch

Superseded by the storage model.
`,
	"notes/plain.md": `# Just a note

No frontmatter here at all.
`,
}

// oldDocs is the same space at a pinned, older revision: the title differs, so
// a test can prove ?rev= actually reached a different tree.
var oldDocs = map[string]string{
	"specs/0007-storage.md": `---
id: SPEC-0007
title: Storage, first draft
status: draft
---

# Storage, first draft

An earlier sketch.
`,
}

// fakeReader is an in-memory Reader: a space is a revision-keyed set of
// documents. It exists so the handlers can be tested against a document set
// rather than against Postgres plus a tree of bare repositories.
type fakeReader struct {
	revs map[string]map[string]string // rev -> path -> content
	head string
}

func newFakeReader() *fakeReader {
	return &fakeReader{
		revs: map[string]map[string]string{headRev: headDocs, oldRev: oldDocs},
		head: headRev,
	}
}

func (f *fakeReader) ListSpaces(context.Context) ([]core.SpaceRef, error) {
	return []core.SpaceRef{demoSpace}, nil
}

// at resolves a revision the way service.ResolveRev does: ApprovedRev means the
// approved head, anything else must name a revision that exists.
func (f *fakeReader) at(ref core.SpaceRef, rev string) (string, map[string]string, error) {
	if ref != demoSpace {
		return "", nil, fmt.Errorf("%w: space %s", service.ErrNotFound, ref)
	}
	if rev == service.ApprovedRev {
		rev = f.head
	}
	docs, ok := f.revs[rev]
	if !ok {
		return "", nil, fmt.Errorf("%w: revision %q in %s", service.ErrNotFound, rev, ref)
	}
	return rev, docs, nil
}

func (f *fakeReader) Snapshot(_ context.Context, ref core.SpaceRef, rev string) (*Snapshot, error) {
	resolved, docs, err := f.at(ref, rev)
	if err != nil {
		return nil, err
	}
	paths := make([]string, 0, len(docs))
	for p := range docs {
		paths = append(paths, p)
	}
	sort.Strings(paths)

	gd := make([]gitx.Document, 0, len(paths))
	bodies := make(map[string][]byte, len(paths))
	for _, p := range paths {
		data := []byte(docs[p])
		gd = append(gd, gitx.Document{
			Path: p,
			Blob: plumbing.ComputeHash(plumbing.BlobObject, data),
			Data: data,
		})
		bodies[p] = data
	}
	return &Snapshot{
		Ref:     ref,
		Rev:     resolved,
		Archive: doc.FromDocuments(ref, resolved, gd),
		Bodies:  bodies,
	}, nil
}

func (f *fakeReader) ReadDocument(_ context.Context, ref core.SpaceRef, rev, p string) (service.Document, error) {
	resolved, docs, err := f.at(ref, rev)
	if err != nil {
		return service.Document{}, err
	}
	content, ok := docs[p]
	if !ok {
		return service.Document{}, fmt.Errorf("%w: %s in %s at %s", service.ErrNotFound, p, ref, resolved)
	}
	return service.Document{
		Path: p,
		Blob: plumbing.ComputeHash(plumbing.BlobObject, []byte(content)).String(),
		Rev:  resolved,
		Data: []byte(content),
	}, nil
}

// fakeSearcher returns one fixed hit whose snippet carries the <mark> tags
// bleve's highlighter emits.
type fakeSearcher struct {
	last search.Query
	err  error
}

func (s *fakeSearcher) Search(_ context.Context, q search.Query) (search.Results, error) {
	s.last = q
	if s.err != nil {
		return search.Results{}, s.err
	}
	return search.Results{
		Total: 1,
		Hits: []search.Hit{{
			Space:   demoSpace,
			ID:      "SPEC-0007",
			Rev:     headRev,
			Path:    "specs/0007-storage.md",
			Title:   "Proposal storage model",
			Section: "specs",
			Score:   1.5,
			Snippet: `Git is <mark>authoritative</mark> &amp; boring`,
		}},
	}, nil
}

// stubTokenStore knows exactly one live agent token.
type stubTokenStore struct{}

func (stubTokenStore) LookupAgentToken(_ context.Context, hash []byte) (authn.AgentToken, error) {
	want := authn.HashToken(agentTk)
	if string(hash) != string(want) {
		return authn.AgentToken{}, authn.ErrUnknownToken
	}
	return authn.AgentToken{ID: 1, Name: "test", Hash: want}, nil
}

// testServer wires a Server with the fake reader/searcher behind the same
// middleware the daemon installs, and returns the handler.
func testServer(t *testing.T) (http.Handler, *fakeSearcher) {
	t.Helper()
	conf := ini.File{
		"sr.ht": ini.Section{
			"network-key": testConf.Section("sr.ht")["network-key"],
			"site-name":   "sourcehut",
			"environment": "development",
			"owner-name":  "bigbes",
		},
		"webhooks":   ini.Section{"private-key": testConf.Section("webhooks")["private-key"]},
		"spec.sr.ht": ini.Section{"origin": "https://spec.example"},
		"meta.sr.ht": ini.Section{"origin": "https://meta.example"},
		"git.sr.ht":  ini.Section{"origin": "https://git.example"},
		// Extra service sections to exercise nav ordering/exclusions.
		"todo.sr.ht":  ini.Section{"origin": "https://todo.example"},
		"paste.sr.ht": ini.Section{"origin": "https://paste.example"},
		"pages.sr.ht": ini.Section{"origin": "https://pages.example"},
		"hub.sr.ht":   ini.Section{"origin": "https://hub.example"},
	}
	resolver, err := authn.NewResolver("bigbes", stubTokenStore{})
	if err != nil {
		t.Fatalf("NewResolver: %v", err)
	}
	searcher := &fakeSearcher{}
	srv, err := New(Options{
		Conf:     conf,
		Reader:   newFakeReader(),
		Searcher: searcher,
		Resolver: resolver,
	})
	if err != nil {
		t.Fatalf("New: %v", err)
	}
	return srv.Handler(), searcher
}

// login seals a unified-login cookie for the given user onto a request — the
// same shape meta.sr.ht writes, sealed with the shared network key.
func login(req *http.Request, user string) {
	payload, _ := json.Marshal(map[string]string{"name": user})
	req.AddCookie(&http.Cookie{Name: authn.CookieName, Value: string(crypto.Encrypt(payload))})
}

func get(t *testing.T, h http.Handler, target, user string) *httptest.ResponseRecorder {
	t.Helper()
	req := httptest.NewRequest(http.MethodGet, target, nil)
	if user != "" {
		login(req, user)
	}
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	return rec
}

func getAgent(t *testing.T, h http.Handler, target, token string) *httptest.ResponseRecorder {
	t.Helper()
	req := httptest.NewRequest(http.MethodGet, target, nil)
	req.Header.Set("Authorization", "Bearer "+token)
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, req)
	return rec
}

// ---- URL grammar ----------------------------------------------------------

func TestDocumentAddressHasNoExtension(t *testing.T) {
	h, _ := testServer(t)
	rec := get(t, h, "/~bigbes/rfcs/specs/0007-storage", "bigbes")
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200\n%s", rec.Code, rec.Body.String())
	}
	if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/html") {
		t.Fatalf("content-type = %q, want text/html", ct)
	}
	body := rec.Body.String()
	if !strings.Contains(body, "Proposal storage model") {
		t.Fatal("rendered page missing the document title")
	}
	if !strings.Contains(body, "<h1") {
		t.Fatalf("body was not rendered as markdown:\n%s", body)
	}
	// The rendered wikilink must point at the target document's own
	// extensionless address.
	if !strings.Contains(body, `href="/~bigbes/rfcs/specs/0003-old"`) {
		t.Fatalf("wikilink not resolved to an extensionless address:\n%s", body)
	}
}

func TestDocumentRawFormat(t *testing.T) {
	h, _ := testServer(t)
	rec := get(t, h, "/~bigbes/rfcs/specs/0007-storage.md", "bigbes")
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200", rec.Code)
	}
	if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "text/markdown") {
		t.Fatalf("content-type = %q, want text/markdown", ct)
	}
	body := rec.Body.String()
	if body != headDocs["specs/0007-storage.md"] {
		t.Fatalf(".md is not the verbatim source:\n%s", body)
	}
	if !strings.HasPrefix(body, "---\n") {
		t.Fatal(".md must include the frontmatter")
	}
	if rec.Header().Get("X-Spec-Rev") != headRev {
		t.Fatalf("X-Spec-Rev = %q, want %q", rec.Header().Get("X-Spec-Rev"), headRev)
	}
}

func TestDocumentJSONFormat(t *testing.T) {
	h, _ := testServer(t)
	rec := get(t, h, "/~bigbes/rfcs/specs/0007-storage.json", "bigbes")
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200\n%s", rec.Code, rec.Body.String())
	}
	if ct := rec.Header().Get("Content-Type"); !strings.HasPrefix(ct, "application/json") {
		t.Fatalf("content-type = %q, want application/json", ct)
	}
	var payload docJSON
	if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
		t.Fatalf("decode: %v\n%s", err, rec.Body.String())
	}
	if payload.ID != "SPEC-0007" || payload.DocID != "SPEC-0007" {
		t.Fatalf("id = %q / doc_id = %q, want SPEC-0007", payload.ID, payload.DocID)
	}
	if payload.Address != "specs/0007-storage" {
		t.Fatalf("address = %q, want the extensionless address", payload.Address)
	}
	if payload.Path != "specs/0007-storage.md" {
		t.Fatalf("path = %q, want the tree path", payload.Path)
	}
	if payload.Rev != headRev {
		t.Fatalf("rev = %q, want %q", payload.Rev, headRev)
	}
	if !strings.Contains(payload.Body, "# Proposal storage model") {
		t.Fatalf("body missing:\n%s", payload.Body)
	}
	if strings.Contains(payload.Body, "id: SPEC-0007") {
		t.Fatal("body must be the body, with the frontmatter lifted into metadata")
	}
	if payload.Status != "draft" || len(payload.Tags) != 2 {
		t.Fatalf("metadata not carried: %+v", payload)
	}
}

func TestPinnedRevReachesAnotherTree(t *testing.T) {
	h, _ := testServer(t)

	head := get(t, h, "/~bigbes/rfcs/specs/0007-storage", "bigbes")
	if !strings.Contains(head.Body.String(), "Proposal storage model") {
		t.Fatal("approved head did not render the current title")
	}

	for _, target := range []string{
		"/~bigbes/rfcs/specs/0007-storage?rev=" + oldRev,
		"/~bigbes/rfcs/specs/0007-storage.md?rev=" + oldRev,
		"/~bigbes/rfcs/specs/0007-storage.json?rev=" + oldRev,
		"/~bigbes/rfcs?rev=" + oldRev,
	} {
		rec := get(t, h, target, "bigbes")
		if rec.Code != http.StatusOK {
			t.Fatalf("%s: status = %d\n%s", target, rec.Code, rec.Body.String())
		}
		if !strings.Contains(rec.Body.String(), "Storage, first draft") {
			t.Fatalf("%s: pinned read did not reach the older revision:\n%s", target, rec.Body.String())
		}
	}
}

func TestPinnedPageKeepsThePinOnItsLinks(t *testing.T) {
	h, _ := testServer(t)
	rec := get(t, h, "/~bigbes/rfcs?rev="+headRev, "bigbes")
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d", rec.Code)
	}
	if !strings.Contains(rec.Body.String(), "/~bigbes/rfcs/specs/0007-storage?rev="+headRev) {
		t.Fatalf("space listing dropped the pin:\n%s", rec.Body.String())
	}

	rec = get(t, h, "/~bigbes/rfcs/specs/0007-storage?rev="+headRev, "bigbes")
	if !strings.Contains(rec.Body.String(), `href="/~bigbes/rfcs/specs/0003-old?rev=`+headRev+`"`) {
		t.Fatalf("a wikilink out of a pinned page dropped the pin:\n%s", rec.Body.String())
	}
}

func TestUnknownRevIs404(t *testing.T) {
	h, _ := testServer(t)
	rec := get(t, h, "/~bigbes/rfcs/specs/0007-storage?rev=deadbeef", "bigbes")
	if rec.Code != http.StatusNotFound {
		t.Fatalf("status = %d, want 404", rec.Code)
	}
}

func TestDocumentIDRedirectsToItsPath(t *testing.T) {
	h, _ := testServer(t)
	rec := get(t, h, "/~bigbes/rfcs/SPEC-0007", "bigbes")
	if rec.Code != http.StatusFound {
		t.Fatalf("status = %d, want 302\n%s", rec.Code, rec.Body.String())
	}
	if loc := rec.Header().Get("Location"); loc != "/~bigbes/rfcs/specs/0007-storage" {
		t.Fatalf("location = %q", loc)
	}
}

// ---- addressing rule ------------------------------------------------------

func TestDocumentWithoutFrontmatterIsAddressedByPath(t *testing.T) {
	h, _ := testServer(t)
	rec := get(t, h, "/~bigbes/rfcs/notes/plain.json", "bigbes")
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d\n%s", rec.Code, rec.Body.String())
	}
	var payload docJSON
	if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil {
		t.Fatal(err)
	}
	if payload.DocID != "" {
		t.Fatalf("doc_id = %q, want empty for a document with no id:", payload.DocID)
	}
	if payload.ID != "notes/plain" {
		t.Fatalf("id = %q, want the path minus its extension", payload.ID)
	}
	if payload.Title != "Just a note" {
		t.Fatalf("title = %q, want the first H1", payload.Title)
	}
}

func TestBacklinksAreRendered(t *testing.T) {
	h, _ := testServer(t)
	rec := get(t, h, "/~bigbes/rfcs/specs/0003-old", "bigbes")
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d\n%s", rec.Code, rec.Body.String())
	}
	body := rec.Body.String()
	i := strings.Index(body, "<h4>Backlinks</h4>")
	if i < 0 {
		t.Fatal("no backlinks section")
	}
	if !strings.Contains(body[i:], "/~bigbes/rfcs/specs/0007-storage") {
		t.Fatalf("SPEC-0007 links to SPEC-0003 but is not listed as a backlink:\n%s", body[i:])
	}
}

// ---- not found ------------------------------------------------------------

func TestMissingDocumentIs404(t *testing.T) {
	h, _ := testServer(t)
	for _, target := range []string{
		"/~bigbes/rfcs/specs/nope",
		"/~bigbes/rfcs/specs/nope.md",
		"/~bigbes/rfcs/specs/nope.json",
	} {
		rec := get(t, h, target, "bigbes")
		if rec.Code != http.StatusNotFound {
			t.Fatalf("%s: status = %d, want 404\n%s", target, rec.Code, rec.Body.String())
		}
	}
}

func TestMissingSpaceIs404(t *testing.T) {
	h, _ := testServer(t)
	rec := get(t, h, "/~bigbes/nope", "bigbes")
	if rec.Code != http.StatusNotFound {
		t.Fatalf("status = %d, want 404", rec.Code)
	}
}

func TestTrailingSlashRedirectsToTheSpace(t *testing.T) {
	h, _ := testServer(t)
	rec := get(t, h, "/~bigbes/rfcs/", "bigbes")
	if rec.Code != http.StatusFound {
		t.Fatalf("status = %d, want 302", rec.Code)
	}
	if loc := rec.Header().Get("Location"); loc != "/~bigbes/rfcs" {
		t.Fatalf("location = %q", loc)
	}
}

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

func TestForgedCookieYieldsLoggedInNav(t *testing.T) {
	h, _ := testServer(t)
	rec := get(t, h, "/", "bigbes")
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d", 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")
	}
}

// A cookie sealed for somebody who is not the instance owner carries no
// authority: authn resolves it to anonymous, and the nav 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")
	}
}

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)
	}
	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")
	}
}

func TestAnonymousContentRedirectsToLogin(t *testing.T) {
	h, _ := testServer(t)
	for _, target := range []string{
		"/~bigbes/rfcs",
		"/~bigbes/rfcs/specs/0007-storage",
		"/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())
		}
		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)
		}
	}
}

func TestAnonymousMachineFormatsAre401(t *testing.T) {
	h, _ := testServer(t)
	for _, target := range []string{
		"/~bigbes/rfcs/specs/0007-storage.md",
		"/~bigbes/rfcs/specs/0007-storage.json",
	} {
		rec := get(t, h, target, "")
		if rec.Code != http.StatusUnauthorized {
			t.Fatalf("%s: status = %d, want 401", target, rec.Code)
		}
		if strings.Contains(rec.Body.String(), "Proposal storage model") {
			t.Fatalf("%s: content leaked to an anonymous client", target)
		}
	}
}

func TestAgentTokenReads(t *testing.T) {
	h, _ := testServer(t)
	rec := getAgent(t, h, "/~bigbes/rfcs/specs/0007-storage.md", agentTk)
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d, want 200\n%s", rec.Code, rec.Body.String())
	}
	if !strings.Contains(rec.Body.String(), "id: SPEC-0007") {
		t.Fatal("agent read did not return the document")
	}
}

func TestUnknownAgentTokenIs401(t *testing.T) {
	h, _ := testServer(t)
	rec := getAgent(t, h, "/~bigbes/rfcs/specs/0007-storage.md", "not-a-token")
	if rec.Code != http.StatusUnauthorized {
		t.Fatalf("status = %d, want 401", rec.Code)
	}
}

// ---- search ---------------------------------------------------------------

func TestSearchRendersSnippetAsHTML(t *testing.T) {
	h, searcher := testServer(t)
	rec := get(t, h, "/search?q=authoritative&space=~bigbes/rfcs", "bigbes")
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d\n%s", rec.Code, rec.Body.String())
	}
	if searcher.last.Text != "authoritative" {
		t.Fatalf("query text = %q", searcher.last.Text)
	}
	if len(searcher.last.Spaces) != 1 || searcher.last.Spaces[0] != demoSpace {
		t.Fatalf("space filter = %+v", searcher.last.Spaces)
	}
	body := rec.Body.String()
	if !strings.Contains(body, "<mark>authoritative</mark>") {
		t.Fatalf("snippet was escaped instead of rendered as HTML:\n%s", body)
	}
	// The hit's URL is the pinned, extensionless address.
	if !strings.Contains(body, "/~bigbes/rfcs/specs/0007-storage?rev="+headRev) {
		t.Fatalf("hit href is not a pinned extensionless address:\n%s", body)
	}
}

func TestSearchWithoutQueryDoesNotSearch(t *testing.T) {
	h, searcher := testServer(t)
	rec := get(t, h, "/search", "bigbes")
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d", rec.Code)
	}
	if searcher.last.Text != "" {
		t.Fatal("an empty query must not reach the index")
	}
}

// ---- static and health ----------------------------------------------------

func TestHealthz(t *testing.T) {
	h, _ := testServer(t)
	rec := get(t, h, "/healthz", "")
	if rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "ok") {
		t.Fatalf("healthz = %d %q", rec.Code, rec.Body.String())
	}
}

func TestStaticLogoIsServed(t *testing.T) {
	h, _ := testServer(t)
	rec := get(t, h, "/static/logo.svg", "")
	if rec.Code != http.StatusOK {
		t.Fatalf("status = %d", rec.Code)
	}
	if cc := rec.Header().Get("Cache-Control"); !strings.Contains(cc, "max-age") {
		t.Fatalf("cache-control = %q", cc)
	}
}

// TestHashedCSSIsImmutable checks the cache policy without depending on a built
// stylesheet: `make css` needs sassc and the shared sourcehut partials, neither
// of which a test may assume.
func TestHashedCSSIsImmutable(t *testing.T) {
	for _, name := range []string{"main.min.79713f25.css", "main.min.abc123.css"} {
		if !hashedCSSRe.MatchString(name) {
			t.Fatalf("%s should be recognised as a hashed stylesheet", name)
		}
	}
	for _, name := range []string{"main.css", "main.min.css", "logo.svg"} {
		if hashedCSSRe.MatchString(name) {
			t.Fatalf("%s should not be recognised as a hashed stylesheet", name)
		}
	}
}

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

func TestSplitFormat(t *testing.T) {
	cases := []struct {
		in   string
		addr string
		f    format
	}{
		{"specs/0007-storage", "specs/0007-storage", formatHTML},
		{"specs/0007-storage.md", "specs/0007-storage", formatRaw},
		{"specs/0007-storage.json", "specs/0007-storage", formatJSON},
		// A document whose own name ends in ".json" is still addressable: the
		// selector is peeled off once, from the tail.
		{"notes/2026.json.md", "notes/2026.json", formatRaw},
		{"notes/report", "notes/report", formatHTML},
	}
	for _, c := range cases {
		addr, f := splitFormat(c.in)
		if addr != c.addr || f != c.f {
			t.Fatalf("splitFormat(%q) = %q/%v, want %q/%v", c.in, addr, f, c.addr, c.f)
		}
	}
}