~bigbes/sr-ht-spec

fe9136564fce231f2b4d9e4c2613856ad7796761 — bigbes 27 days ago ba61af7
feat(authn): unified-login identity, agent token validation, provenance trailers

Cookie identity is fail-open to anonymous: a missing, tampered or
foreign-key cookie yields no user rather than an error, so anonymous
browsing never breaks on a bad cookie.

Agent tokens are the opposite. A bad bearer is a permanent 401, a store
outage a transient 503, and revoked rows are returned so operators can
distinguish a killed token from one that never existed.

TokenStore is declared here rather than imported from db/, so the two
packages can be built in parallel and service/ wires them together.
A authn/authn_test.go => authn/authn_test.go +186 -0
@@ 0,0 1,186 @@
package authn

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

	"github.com/fernet/fernet-go"
	"github.com/vaughan0/go-ini"
	"sourcecraft.dev/bigbes/sr-ht-core/auth"
	"sourcecraft.dev/bigbes/sr-ht-core/crypto"
)

// testConf is the synthesized instance config every test runs against: a fresh
// random Fernet network-key, a random ed25519 webhook seed (crypto.InitCrypto
// fatally requires one even though v1 emits no webhooks), the owner identity
// the provenance builder reads, and our own section's origin.
var testConf ini.File

// rotatedKey stands in for a Fernet key we no longer hold — a cookie sealed
// with it is indistinguishable from a forgery, which is exactly the point.
var rotatedKey fernet.Key

// TestMain builds that config in memory and runs crypto.InitCrypto once, so
// crypto.Encrypt / DecryptWithoutExpiration share a keyset across the package.
// No network, no Postgres, no files.
func TestMain(m *testing.M) {
	var fk fernet.Key
	if err := fk.Generate(); err != nil {
		panic("generate fernet network key: " + err.Error())
	}
	if err := rotatedKey.Generate(); err != nil {
		panic("generate rotated 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(),
			"owner-name":  "bigbes",
			"owner-email": "bigbes@gmail.com",
		},
		"webhooks": ini.Section{
			"private-key": base64.StdEncoding.EncodeToString(seed),
		},
		ConfigSection: ini.Section{
			"origin": "https://spec.srht.bigb.es",
		},
	}
	crypto.InitCrypto(testConf)

	os.Exit(m.Run())
}

// sealCookie forges a valid unified-login cookie value carrying name, sealed
// with the instance network key — byte for byte what meta.sr.ht would set.
func sealCookie(t *testing.T, name string) string {
	t.Helper()
	payload, err := json.Marshal(auth.AuthCookie{Name: name})
	if err != nil {
		t.Fatalf("marshal cookie claims: %v", err)
	}
	return string(crypto.Encrypt(payload))
}

// sealCookieWithKey forges a cookie under an arbitrary Fernet key, used to
// stand in for a rotated key or an attacker's own.
func sealCookieWithKey(t *testing.T, key *fernet.Key, name string) string {
	t.Helper()
	payload, err := json.Marshal(auth.AuthCookie{Name: name})
	if err != nil {
		t.Fatalf("marshal cookie claims: %v", err)
	}
	tok, err := fernet.EncryptAndSign(payload, key)
	if err != nil {
		t.Fatalf("seal cookie: %v", err)
	}
	return string(tok)
}

// tamper flips one character in the middle of a Fernet token, leaving it
// well-formed base64 so that the HMAC check — not the decoder — is what
// rejects it.
func tamper(t *testing.T, token string) string {
	t.Helper()
	if len(token) < 8 {
		t.Fatalf("token too short to tamper with: %q", token)
	}
	b := []byte(token)
	i := len(b) / 2
	if b[i] == 'A' {
		b[i] = 'B'
	} else {
		b[i] = 'A'
	}
	if string(b) == token {
		t.Fatal("tamper produced an identical token")
	}
	return string(b)
}

// testInstance is the provenance identity set built from testConf.
func testInstance(t *testing.T) Instance {
	t.Helper()
	inst, err := InstanceFromConfig(testConf)
	if err != nil {
		t.Fatalf("InstanceFromConfig: %v", err)
	}
	return inst
}

// stubStore is an in-memory TokenStore keyed by hex(sha256(token)). It can be
// told to fail with an arbitrary error to exercise the transient path, and
// counts calls so tests can assert the cookie path never touches it.
type stubStore struct {
	rows  map[string]AgentToken
	err   error
	calls int
}

func newStubStore() *stubStore { return &stubStore{rows: map[string]AgentToken{}} }

func (s *stubStore) LookupAgentToken(ctx context.Context, hash []byte) (AgentToken, error) {
	s.calls++
	if s.err != nil {
		return AgentToken{}, s.err
	}
	tok, ok := s.rows[hex.EncodeToString(hash)]
	if !ok {
		// The contract db/ must honour: no row means ErrUnknownToken.
		return AgentToken{}, fmt.Errorf("agent_token: %w", ErrUnknownToken)
	}
	return tok, nil
}

// add stores a live token row for the given secret.
func (s *stubStore) add(secret, name string) AgentToken {
	hash := HashToken(secret)
	tok := AgentToken{
		ID:      int64(len(s.rows) + 1),
		Name:    name,
		Hash:    hash,
		Created: time.Date(2026, 7, 22, 10, 0, 0, 0, time.UTC),
	}
	s.rows[hex.EncodeToString(hash)] = tok
	return tok
}

// revoke stores a revoked token row for the given secret.
func (s *stubStore) revoke(secret, name string) AgentToken {
	tok := s.add(secret, name)
	when := time.Date(2026, 7, 22, 11, 0, 0, 0, time.UTC)
	tok.Revoked = &when
	s.rows[hex.EncodeToString(tok.Hash)] = tok
	return tok
}

// put stores an arbitrary row under an arbitrary lookup key, for the case where
// the store returns a row whose hash does not match what was presented.
func (s *stubStore) put(hash []byte, tok AgentToken) {
	s.rows[hex.EncodeToString(hash)] = tok
}

// request builds a GET / carrying the given cookie value and headers. An empty
// cookie value means no cookie at all.
func request(cookie string, headers map[string]string) *http.Request {
	r := httptest.NewRequest(http.MethodGet, "/", nil)
	if cookie != "" {
		r.AddCookie(&http.Cookie{Name: CookieName, Value: cookie})
	}
	for k, v := range headers {
		r.Header.Set(k, v)
	}
	return r
}

A authn/cookie.go => authn/cookie.go +72 -0
@@ 0,0 1,72 @@
package authn

import (
	"encoding/json"
	"net/http"
	"strings"

	"sourcecraft.dev/bigbes/sr-ht-core/auth"
	"sourcecraft.dev/bigbes/sr-ht-core/crypto"

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

// CookieName is the unified-login cookie shared by every service on the
// instance. Its value is a Fernet token sealed by meta.sr.ht with the
// [sr.ht] network-key, which is why spec.srht.bigb.es must live under the
// shared *.srht.bigb.es cookie domain — otherwise the cookie is never sent to
// us and every viewer looks anonymous.
const CookieName = "sr.ht.unified-login.v1"

// UsernameFromRequest returns the username carried by the unified-login cookie,
// or "" for an anonymous viewer.
//
// Every failure path — no cookie, forged or truncated ciphertext, a payload
// that is not JSON, a payload with no name — returns "" rather than an error.
// This service never serves an error page on identity grounds; it decides what
// an anonymous viewer may see instead. Returning an error here would turn a
// stale cookie from a browser tab left open over a key rotation into a broken
// site rather than a logged-out one.
//
// Requires crypto.InitCrypto to have run (server.New does it at startup).
func UsernameFromRequest(r *http.Request) string {
	c, err := r.Cookie(CookieName)
	if err != nil {
		return "" // no cookie: anonymous
	}
	return UsernameFromCookie(c.Value)
}

// UsernameFromCookie is UsernameFromRequest for a cookie value already in hand
// — the form the hooks' RPC path and tests need, where there is no
// *http.Request to read from.
//
// Note the deliberate use of DecryptWithoutExpiration, matching core-go's own
// cookieAuth: the unified-login cookie carries no service-side TTL, and its
// lifetime is the browser cookie's Expires plus meta.sr.ht's ability to rotate
// the network key. Adding a TTL here would log the owner out of this one
// service on a schedule no other service shares.
func UsernameFromCookie(value string) string {
	if value == "" {
		return ""
	}
	payload := crypto.DecryptWithoutExpiration([]byte(value))
	if payload == nil {
		return "" // forged, tampered, or sealed with a key we no longer hold
	}

	var claims auth.AuthCookie
	if err := json.Unmarshal(payload, &claims); err != nil {
		return "" // well-sealed but malformed payload
	}

	// Cookies carry the bare username; strip a leading '~' defensively in case
	// something upstream stored the canonical "~user" form.
	name := strings.TrimPrefix(claims.Name, "~")
	if err := core.ValidateOwner(name); err != nil {
		// An unusable username is not an identity. Rejecting here keeps a
		// hostile cookie payload out of path construction and log lines.
		return ""
	}
	return name
}

A authn/cookie_test.go => authn/cookie_test.go +92 -0
@@ 0,0 1,92 @@
package authn

import (
	"testing"

	"sourcecraft.dev/bigbes/sr-ht-core/crypto"
)

func TestUsernameFromRequest_ValidCookieRoundTrips(t *testing.T) {
	got := UsernameFromRequest(request(sealCookie(t, "bigbes"), nil))
	if got != "bigbes" {
		t.Fatalf("username = %q, want %q", got, "bigbes")
	}
}

func TestUsernameFromRequest_StripsTilde(t *testing.T) {
	got := UsernameFromRequest(request(sealCookie(t, "~bigbes"), nil))
	if got != "bigbes" {
		t.Fatalf("username = %q, want %q", got, "bigbes")
	}
}

// A cookie whose ciphertext has been altered must fail the Fernet HMAC and read
// as anonymous — not as an error, and certainly not as an identity.
func TestUsernameFromRequest_TamperedCookieIsAnonymous(t *testing.T) {
	got := UsernameFromRequest(request(tamper(t, sealCookie(t, "bigbes")), nil))
	if got != "" {
		t.Fatalf("tampered cookie yielded %q, want anonymous", got)
	}
}

func TestUsernameFromRequest_AbsentCookieIsAnonymous(t *testing.T) {
	if got := UsernameFromRequest(request("", nil)); got != "" {
		t.Fatalf("absent cookie yielded %q, want anonymous", got)
	}
}

func TestUsernameFromRequest_GarbageIsAnonymous(t *testing.T) {
	for name, value := range map[string]string{
		"not base64":       "not-a-valid-fernet-token",
		"empty":            "",
		"truncated fernet": sealCookie(t, "bigbes")[:10],
	} {
		t.Run(name, func(t *testing.T) {
			if got := UsernameFromCookie(value); got != "" {
				t.Fatalf("garbage cookie yielded %q, want anonymous", got)
			}
		})
	}
}

// A cookie sealed under a key we no longer hold — the shape of both a rotated
// network key and an outright forgery. It must expire the session, not the
// request.
func TestUsernameFromCookie_ForeignKeyIsAnonymous(t *testing.T) {
	value := sealCookieWithKey(t, &rotatedKey, "bigbes")
	if got := UsernameFromCookie(value); got != "" {
		t.Fatalf("cookie under a foreign key yielded %q, want anonymous", got)
	}
	// Sanity: the same payload under the live key does resolve, so the test
	// above is proving the key check and not a broken helper.
	if got := UsernameFromCookie(sealCookie(t, "bigbes")); got != "bigbes" {
		t.Fatalf("control cookie yielded %q, want %q", got, "bigbes")
	}
}

func TestUsernameFromCookie_NonJSONPayloadIsAnonymous(t *testing.T) {
	value := string(crypto.Encrypt([]byte("plain text, well sealed")))
	if got := UsernameFromCookie(value); got != "" {
		t.Fatalf("non-JSON payload yielded %q, want anonymous", got)
	}
}

// A well-sealed cookie can still carry a name we must refuse to treat as an
// identity: empty, or something that would not survive being used as a path
// segment or a log field.
func TestUsernameFromCookie_UnusableNameIsAnonymous(t *testing.T) {
	for _, name := range []string{
		"",
		"..",
		"../../etc/passwd",
		"has space",
		"Uppercase",
		"-leading-dash",
	} {
		t.Run(name, func(t *testing.T) {
			if got := UsernameFromCookie(sealCookie(t, name)); got != "" {
				t.Fatalf("cookie name %q yielded %q, want anonymous", name, got)
			}
		})
	}
}

A authn/doc.go => authn/doc.go +104 -0
@@ 0,0 1,104 @@
// Package authn answers one question for spec.sr.ht — "who is making this
// request?" — and builds the git provenance that answers "who made this write?"
// forever after.
//
// The design has exactly two principals that carry authority:
//
//   - the human owner, recognised by the shared `sr.ht.unified-login.v1`
//     cookie carrying the instance's [sr.ht] owner-name;
//   - an agent, recognised by an opaque bearer token stored (hashed) in the
//     agent_token table.
//
// Everything else is anonymous. Single-user does not mean "no authorization";
// it relocates it onto agents, which is why Principal distinguishes those two
// and nothing finer. Per the confirmed v1 decision there is one agent token and
// no per-space scoping: the boundary that matters is the refs rule (agents may
// only write proposals/*), and that lives in gitx.
//
// The two credentials are deliberately asymmetric:
//
//   - A cookie that is missing, forged, expired or unreadable yields an
//     anonymous principal and never an error. Browsing must keep working.
//   - A bearer token that is present but unknown, revoked or corrupt is a hard
//     failure. An agent that presented an explicit credential must not be
//     silently downgraded to a reader; it would then fail confusingly at the
//     write instead of clearly at the door.
//
// Provenance is the other half. Agent identity and session ID are mandatory on
// every agent write, and are recorded in the commit itself so that a plain
// `git log` on any clone carries the audit trail:
//
//	Author:    claude-code/spec-writer (for bigbes) <agent@spec.srht.bigb.es>
//	Committer: bigbes <bigbes@gmail.com>
//
//	    Add storage model section
//
//	    X-Agent-Session: 8fb9c9a4-b078-4af1-89eb-d97c522f9921
//	    X-Agent-Base: 1f0c1d1a1e2b3c4d5e6f708192a3b4c5d6e7f809
//
// A write missing either field is rejected rather than defaulted: a commit
// stamped with a guessed session is worse than no commit, because it launders
// unattributable output as attributed.
//
// This package owns no storage. The agent_token table lives in db/, which is
// injected through the TokenStore interface declared here — authn never imports
// db, so the dependency arrow keeps pointing downward.
package authn

import "errors"

// Sentinel errors. Callers compare with errors.Is. The split that matters is
// permanent (the credential is bad — 401/403) versus transient (the store could
// not answer — 503); IsAuthFailure draws it.
var (
	// ErrNoToken is returned when a bearer credential was expected but the
	// request carried no Authorization header, or one in another scheme.
	ErrNoToken = errors.New("no agent token presented")

	// ErrUnknownToken is the contract a TokenStore must honour: it is what
	// LookupAgentToken returns (possibly wrapped) when no row matches the
	// presented hash. Any other error is treated as transient, so a Postgres
	// outage reads as "try again", never as "your token is bad".
	ErrUnknownToken = errors.New("unknown agent token")

	// ErrInvalidToken marks a presented credential that is malformed, or a
	// stored row whose hash does not actually match what was presented.
	ErrInvalidToken = errors.New("invalid agent token")

	// ErrRevokedToken marks a token that resolved to a real row which has been
	// revoked. Distinct from ErrUnknownToken so operators can tell "you are
	// using a token I deliberately killed" from "that token never existed".
	ErrRevokedToken = errors.New("revoked agent token")

	// ErrNotAgent is returned when agent provenance is demanded of a principal
	// that is not an agent — the human push path builds no trailers.
	ErrNotAgent = errors.New("principal is not an agent")

	// ErrMissingProvenance marks an agent write that omits the agent identity,
	// the session ID, or the base revision. Mandatory on every agent write; the
	// design is explicit that these are not defaultable.
	ErrMissingProvenance = errors.New("missing agent provenance")

	// ErrInvalidProvenance marks provenance whose values are present but
	// unusable: control characters or angle brackets that would forge a git
	// signature line or inject an extra trailer, an over-long field, or a base
	// revision that is not a hex object name.
	ErrInvalidProvenance = errors.New("invalid agent provenance")

	// ErrMissingConfig is returned by InstanceFromConfig when the instance
	// config lacks a key the provenance identities are built from. It is a
	// startup failure, not a request failure.
	ErrMissingConfig = errors.New("missing instance config key")
)

// IsAuthFailure reports whether err is a permanent credential failure — the
// caller should answer 401/403 — as opposed to a transient backend failure,
// which should answer 503 and be retried. Everything not in this set is
// transient by definition, which is the fail-closed direction: a store outage
// never reads as a valid credential.
func IsAuthFailure(err error) bool {
	return errors.Is(err, ErrNoToken) ||
		errors.Is(err, ErrUnknownToken) ||
		errors.Is(err, ErrInvalidToken) ||
		errors.Is(err, ErrRevokedToken)
}

A authn/principal.go => authn/principal.go +145 -0
@@ 0,0 1,145 @@
package authn

import (
	"context"
	"fmt"
)

// Kind enumerates the principals spec.sr.ht distinguishes. There are three
// values but only two of them carry authority: the design's "authorization is
// about agents, not people" collapses every human other than the instance owner
// into the anonymous case, because there is no second human in the model to
// grant anything to.
type Kind string

const (
	// KindAnonymous is an unauthenticated request — no cookie, an unreadable
	// cookie, or a cookie belonging to somebody who is not the instance owner.
	// It is a normal, expected state: the read plane is anonymous-capable.
	KindAnonymous Kind = "anonymous"

	// KindOwner is bigbes: the unified-login cookie resolved to the username in
	// [sr.ht] owner-name. This is the principal whose git push *is* the
	// approval, and the only one that may approve a proposal.
	KindOwner Kind = "owner"

	// KindAgent is a bot holding the agent bearer token. It may propose and it
	// may read; the refs rule in gitx is what stops it touching the approved
	// branch.
	KindAgent Kind = "agent"
)

// Principal is the resolved identity of a request. It is a value type with no
// pointers into request state, so it can be stashed in a context, logged, and
// passed to service/ without aliasing surprises.
//
// This is what the API layer and gitx's refs rule branch on, and it is
// deliberately the narrowest thing that supports both: which kind, and — for an
// agent — the two provenance fields that every agent write must carry.
type Principal struct {
	// Kind is which of the three principals this is. The zero value is the
	// anonymous case, so a Principal read out of a context that never had one
	// set is safe rather than privileged.
	Kind Kind

	// Owner is the instance owner username (no leading '~') this principal acts
	// as or on behalf of: itself for KindOwner, the human an agent writes for
	// for KindAgent. Empty for KindAnonymous.
	Owner string

	// Agent is the agent identity string, e.g. "claude-code/spec-writer".
	// KindAgent only. It may be empty on a read — it is demanded at the write,
	// which is the only place the design requires it.
	Agent string

	// Session is the agent's session ID, e.g. a UUID. KindAgent only, with the
	// same read/write asymmetry as Agent.
	Session string

	// TokenName is the human-readable name of the agent_token row that
	// authenticated this request. KindAgent only, diagnostics only — with one
	// token and no scopes it grants nothing.
	TokenName string

	// CookieUser is whatever username the unified-login cookie carried, even
	// when that user was not the instance owner and Kind is therefore
	// KindAnonymous. Display and logging only: never an authorization input.
	CookieUser string
}

// Anonymous returns the principal for an unauthenticated request.
func Anonymous() Principal { return Principal{Kind: KindAnonymous} }

// IsAnonymous reports whether the principal carries no authority. Written as
// "not one of the two that do" so that an unrecognised or zero Kind is denied
// rather than accidentally admitted.
func (p Principal) IsAnonymous() bool { return p.Kind != KindOwner && p.Kind != KindAgent }

// IsOwner reports whether this is the human owner — the principal that may
// approve proposals and whose pushes need no review.
func (p Principal) IsOwner() bool { return p.Kind == KindOwner }

// IsAgent reports whether this is an agent — the principal gitx confines to
// proposals/*.
func (p Principal) IsAgent() bool { return p.Kind == KindAgent }

// String renders the principal for logs. It never includes the token name's
// secret (there is none — the name is not the token) and never includes the
// cookie value.
func (p Principal) String() string {
	switch p.Kind {
	case KindOwner:
		return "owner ~" + p.Owner
	case KindAgent:
		agent := p.Agent
		if agent == "" {
			agent = "(unnamed)"
		}
		session := p.Session
		if session == "" {
			session = "(no session)"
		}
		return fmt.Sprintf("agent %s session %s for ~%s", agent, session, p.Owner)
	default:
		if p.CookieUser != "" {
			return "anonymous (cookie user ~" + p.CookieUser + ")"
		}
		return "anonymous"
	}
}

// AgentWriteFor builds the provenance inputs for an agent write at the given
// base revision, enforcing that the mandatory fields are present. It fails for
// a non-agent principal: the human write path goes through native
// receive-pack and constructs no commit here.
func (p Principal) AgentWriteFor(base string) (AgentWrite, error) {
	if !p.IsAgent() {
		return AgentWrite{}, fmt.Errorf("%w: %s", ErrNotAgent, p)
	}
	w := AgentWrite{Agent: p.Agent, Session: p.Session, Base: base}
	if err := w.Validate(); err != nil {
		return AgentWrite{}, err
	}
	return w, nil
}

type contextKey struct{ name string }

var principalCtxKey = &contextKey{"authn.principal"}

// WithPrincipal returns a copy of ctx carrying p.
func WithPrincipal(ctx context.Context, p Principal) context.Context {
	return context.WithValue(ctx, principalCtxKey, p)
}

// PrincipalFromContext returns the principal stored by WithPrincipal, or the
// anonymous principal when none was stored. It never panics: an
// unauthenticated request is ordinary here, and a handler reached without the
// middleware must degrade to *less* authority, not more.
func PrincipalFromContext(ctx context.Context) Principal {
	p, ok := ctx.Value(principalCtxKey).(Principal)
	if !ok {
		return Anonymous()
	}
	return p
}

A authn/provenance.go => authn/provenance.go +312 -0
@@ 0,0 1,312 @@
package authn

import (
	"fmt"
	"net/url"
	"strings"
	"unicode/utf8"

	"github.com/vaughan0/go-ini"

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

// ConfigSection is our config section. The literal ".sr.ht" suffix is what puts
// us in the nav network list and what other services look us up by, so it is a
// constant rather than a parameter.
const ConfigSection = "spec.sr.ht"

// Trailer keys recorded on every agent commit. Git trailers rather than a
// Postgres-only audit table, so provenance is visible in a plain `git log` on
// any clone and cannot drift from the content it describes.
//
// Only these two. The agent's own identity rides on the Author line, where git
// already puts "who wrote this", and duplicating it into a third trailer would
// create two spellings that can disagree.
const (
	TrailerAgentSession = "X-Agent-Session"
	TrailerAgentBase    = "X-Agent-Base"
)

// agentLocalPart is the mailbox of the synthetic address stamped on agent
// authorship. Agents have no mailbox; the address exists because git demands
// one, and it is made obviously non-human so nobody mails it.
const agentLocalPart = "agent"

const (
	// MaxAgentLen bounds the agent identity string. It becomes a git author
	// name, which is read by humans in a review UI.
	MaxAgentLen = 128

	// MaxSessionLen bounds the session ID. A UUID is 36 bytes; the slack is for
	// runners that prefix their own job identifiers.
	MaxSessionLen = 128

	// minRevLen and maxRevLen bound a git object name in the X-Agent-Base
	// trailer: an abbreviated sha at the low end, a full sha-256 at the high.
	minRevLen = 7
	maxRevLen = 64
)

// Signature is a git identity: the name and mailbox halves of an author or
// committer line.
type Signature struct {
	Name  string
	Email string
}

// String renders the identity in git's own "Name <email>" form.
func (s Signature) String() string { return s.Name + " <" + s.Email + ">" }

// Instance carries the identity facts an agent commit needs from the instance
// config. It is a value, so service/ can build it once at startup and hand
// copies around; every field is exported so a caller that has these facts from
// somewhere other than an ini file can construct it directly.
type Instance struct {
	// OwnerName and OwnerEmail are [sr.ht] owner-name / owner-email — the human
	// this instance belongs to. They become the committer of every agent write
	// and of every merge, which is what makes "an agent proposed it, bigbes'
	// service committed it" legible in `git log`.
	OwnerName  string
	OwnerEmail string

	// AgentEmail is the synthetic mailbox stamped on agent authorship.
	AgentEmail string
}

// InstanceFromConfig reads the provenance identities out of the instance
// config.
//
// It mirrors config.GetOwner without the panic: this is a library, and a
// missing key should fail the daemon's startup validation with a message that
// names the key, not unwind a request. Every failure wraps ErrMissingConfig.
//
// AgentEmail is derived as agent@<host of [spec.sr.ht] origin>, so no new
// config key exists to forget or to disagree with the origin. The design's
// worked example shows agent@srht.bigb.es (the bare cookie domain) rather than
// agent@spec.srht.bigb.es; the design never says where that address comes from,
// and deriving it from our own origin is the only rule that needs no operator
// input. A caller that wants the bare domain sets Instance.AgentEmail directly.
func InstanceFromConfig(conf ini.File) (Instance, error) {
	ownerName, ok := conf.Get("sr.ht", "owner-name")
	if !ok {
		return Instance{}, fmt.Errorf("%w: [sr.ht] owner-name", ErrMissingConfig)
	}
	ownerEmail, ok := conf.Get("sr.ht", "owner-email")
	if !ok {
		return Instance{}, fmt.Errorf("%w: [sr.ht] owner-email", ErrMissingConfig)
	}
	origin, ok := conf.Get(ConfigSection, "origin")
	if !ok {
		return Instance{}, fmt.Errorf("%w: [%s] origin", ErrMissingConfig, ConfigSection)
	}

	u, err := url.Parse(origin)
	if err != nil {
		return Instance{}, fmt.Errorf("%w: [%s] origin %q is not a URL: %v",
			ErrMissingConfig, ConfigSection, origin, err)
	}
	host := u.Hostname()
	if host == "" {
		return Instance{}, fmt.Errorf("%w: [%s] origin %q has no host",
			ErrMissingConfig, ConfigSection, origin)
	}

	inst := Instance{
		OwnerName:  strings.TrimPrefix(ownerName, "~"),
		OwnerEmail: ownerEmail,
		AgentEmail: agentLocalPart + "@" + host,
	}
	if err := inst.Validate(); err != nil {
		return Instance{}, err
	}
	return inst, nil
}

// Validate reports whether the instance identities are usable in a git
// signature line.
func (i Instance) Validate() error {
	if err := core.ValidateOwner(i.OwnerName); err != nil {
		return fmt.Errorf("%w: [sr.ht] owner-name: %v", ErrMissingConfig, err)
	}
	if err := validateSigField("[sr.ht] owner-email", i.OwnerEmail, MaxAgentLen); err != nil {
		return fmt.Errorf("%w: %v", ErrMissingConfig, err)
	}
	if err := validateSigField("agent email", i.AgentEmail, MaxAgentLen); err != nil {
		return fmt.Errorf("%w: %v", ErrMissingConfig, err)
	}
	return nil
}

// OwnerSignature is the human this instance belongs to: the committer of every
// agent write and of every merge commit.
func (i Instance) OwnerSignature() Signature {
	return Signature{Name: i.OwnerName, Email: i.OwnerEmail}
}

// AgentWrite is the provenance an agent must supply with every write. All three
// fields are mandatory — see Validate.
type AgentWrite struct {
	// Agent is the agent identity string, e.g. "claude-code/spec-writer".
	Agent string

	// Session is the agent's session ID, e.g. a UUID.
	Session string

	// Base is the approved-head revision the agent read the document at — the
	// If-Match value, and the same value that becomes the proposal's base B.
	// Recorded as X-Agent-Base so the claim is auditable against a pinned
	// ?rev= read rather than decorative.
	Base string
}

// Validate enforces that an agent write carries complete, usable provenance.
//
// Missing fields are rejected, never defaulted. The design is explicit that
// agent identity and session ID are mandatory on every write, and a commit
// stamped with a synthesised session is worse than a rejected write: it
// launders unattributable output as attributed, which is the one failure the
// whole provenance mechanism exists to prevent. Base is held to the same
// standard for the same reason — an empty X-Agent-Base trailer is a claim with
// nothing behind it.
//
// The character rules are not cosmetic. A newline in the agent string would
// break the git author line in two; a newline in the session would inject an
// arbitrary extra trailer; angle brackets would forge the mailbox. All three
// are rejected outright rather than escaped, because there is no legitimate
// agent name that needs them.
func (w AgentWrite) Validate() error {
	if w.Agent == "" {
		return fmt.Errorf("%w: agent identity is required on every agent write", ErrMissingProvenance)
	}
	if w.Session == "" {
		return fmt.Errorf("%w: agent session id is required on every agent write", ErrMissingProvenance)
	}
	if w.Base == "" {
		return fmt.Errorf("%w: base revision is required on every agent write", ErrMissingProvenance)
	}
	if err := validateSigField("agent identity", w.Agent, MaxAgentLen); err != nil {
		return fmt.Errorf("%w: %v", ErrInvalidProvenance, err)
	}
	if err := validateSigField("agent session id", w.Session, MaxSessionLen); err != nil {
		return fmt.Errorf("%w: %v", ErrInvalidProvenance, err)
	}
	if err := validateRev(w.Base); err != nil {
		return fmt.Errorf("%w: %v", ErrInvalidProvenance, err)
	}
	return nil
}

// Provenance is the fully-resolved authorship of one agent commit: who git will
// record as author and committer, and the trailers that carry the rest.
type Provenance struct {
	Author    Signature
	Committer Signature
	Session   string
	Base      string
}

// Provenance builds the authorship of an agent commit, per the design:
//
//	Author:    claude-code/spec-writer (for bigbes) <agent@spec.srht.bigb.es>
//	Committer: bigbes <bigbes@gmail.com>
//
// The author is the agent, annotated with the human it acted for; the committer
// is the instance owner, because the service — running as bigbes — is what
// actually wrote the object. An invalid or incomplete AgentWrite is an error,
// never a commit with a hole in it.
func (i Instance) Provenance(w AgentWrite) (Provenance, error) {
	if err := i.Validate(); err != nil {
		return Provenance{}, err
	}
	if err := w.Validate(); err != nil {
		return Provenance{}, err
	}
	return Provenance{
		Author: Signature{
			Name:  w.Agent + " (for " + i.OwnerName + ")",
			Email: i.AgentEmail,
		},
		Committer: i.OwnerSignature(),
		Session:   w.Session,
		Base:      w.Base,
	}, nil
}

// TrailerBlock renders the trailers as their own paragraph, each line
// newline-terminated:
//
//	X-Agent-Session: 8fb9c9a4-b078-4af1-89eb-d97c522f9921
//	X-Agent-Base: 1f0c1d1a1e2b3c4d5e6f708192a3b4c5d6e7f809
func (p Provenance) TrailerBlock() string {
	var b strings.Builder
	b.WriteString(TrailerAgentSession)
	b.WriteString(": ")
	b.WriteString(p.Session)
	b.WriteByte('\n')
	b.WriteString(TrailerAgentBase)
	b.WriteString(": ")
	b.WriteString(p.Base)
	b.WriteByte('\n')
	return b.String()
}

// CommitMessage appends the trailer block to an agent-supplied message,
// separated by a blank line so git parses it as the trailer paragraph — and so
// that anything trailer-shaped inside the agent's own text stays part of the
// body rather than becoming the last block.
//
// An empty message is rejected: a commit whose only content is provenance
// records that something happened without saying what.
func (p Provenance) CommitMessage(message string) (string, error) {
	msg := strings.TrimRight(message, " \t\r\n")
	if msg == "" {
		return "", fmt.Errorf("%w: empty commit message", ErrInvalidProvenance)
	}
	return msg + "\n\n" + p.TrailerBlock(), nil
}

// validateSigField holds the rules shared by every string that ends up inside a
// git identity or trailer line: present, trimmed, bounded, valid UTF-8, and
// free of the bytes that would let it escape its line or its field.
func validateSigField(kind, s string, maxLen int) error {
	if s == "" {
		return fmt.Errorf("%s is empty", kind)
	}
	if len(s) > maxLen {
		return fmt.Errorf("%s is too long (%d > %d)", kind, len(s), maxLen)
	}
	if !utf8.ValidString(s) {
		return fmt.Errorf("%s is not valid UTF-8", kind)
	}
	if strings.TrimSpace(s) != s {
		return fmt.Errorf("%s %q has leading or trailing whitespace", kind, s)
	}
	for _, r := range s {
		switch {
		case r < 0x20 || r == 0x7f:
			return fmt.Errorf("%s %q contains a control character %U", kind, s, r)
		case r == '<' || r == '>':
			return fmt.Errorf("%s %q contains %q", kind, s, string(r))
		}
	}
	return nil
}

// validateRev reports whether s is a plausible git object name. Strict enough
// that nothing can be smuggled into the trailer line, loose enough to accept
// both an abbreviated name and a full sha-256 one.
func validateRev(s string) error {
	if len(s) < minRevLen || len(s) > maxRevLen {
		return fmt.Errorf("base revision %q must be %d-%d hex characters, got %d",
			s, minRevLen, maxRevLen, len(s))
	}
	for i := 0; i < len(s); i++ {
		c := s[i]
		if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') {
			continue
		}
		return fmt.Errorf("base revision %q contains a non-hex byte %q", s, c)
	}
	return nil
}

A authn/provenance_test.go => authn/provenance_test.go +277 -0
@@ 0,0 1,277 @@
package authn

import (
	"errors"
	"strings"
	"testing"

	"github.com/vaughan0/go-ini"
)

const testBase = "1f0c1d1a1e2b3c4d5e6f708192a3b4c5d6e7f809"

func TestInstanceFromConfig(t *testing.T) {
	inst := testInstance(t)
	if inst.OwnerName != "bigbes" {
		t.Fatalf("OwnerName = %q, want %q", inst.OwnerName, "bigbes")
	}
	if inst.OwnerEmail != "bigbes@gmail.com" {
		t.Fatalf("OwnerEmail = %q, want %q", inst.OwnerEmail, "bigbes@gmail.com")
	}
	// Derived from [spec.sr.ht] origin, so there is no second key to forget.
	if inst.AgentEmail != "agent@spec.srht.bigb.es" {
		t.Fatalf("AgentEmail = %q, want %q", inst.AgentEmail, "agent@spec.srht.bigb.es")
	}
	if got := inst.OwnerSignature().String(); got != "bigbes <bigbes@gmail.com>" {
		t.Fatalf("OwnerSignature = %q", got)
	}
}

func TestInstanceFromConfig_MissingKeysAreStartupErrors(t *testing.T) {
	full := func() ini.File {
		return ini.File{
			"sr.ht":       ini.Section{"owner-name": "bigbes", "owner-email": "bigbes@gmail.com"},
			ConfigSection: ini.Section{"origin": "https://spec.srht.bigb.es"},
		}
	}
	cases := map[string]func(ini.File){
		"no owner-name":  func(c ini.File) { delete(c["sr.ht"], "owner-name") },
		"no owner-email": func(c ini.File) { delete(c["sr.ht"], "owner-email") },
		"no origin":      func(c ini.File) { delete(c[ConfigSection], "origin") },
		"hostless origin": func(c ini.File) {
			c[ConfigSection]["origin"] = "not-a-url"
		},
		"unusable owner": func(c ini.File) { c["sr.ht"]["owner-name"] = "Not A Name" },
		"unparseable origin": func(c ini.File) {
			c[ConfigSection]["origin"] = "https://[::1"
		},
		"unusable owner-email": func(c ini.File) {
			c["sr.ht"]["owner-email"] = "bigbes <root>@gmail.com"
		},
		"host that cannot be a mailbox": func(c ini.File) {
			c[ConfigSection]["origin"] = "https://" + strings.Repeat("h", MaxAgentLen)
		},
	}
	for name, mutate := range cases {
		t.Run(name, func(t *testing.T) {
			conf := full()
			mutate(conf)
			if _, err := InstanceFromConfig(conf); !errors.Is(err, ErrMissingConfig) {
				t.Fatalf("error = %v, want ErrMissingConfig", err)
			}
		})
	}
}

// An Instance assembled by hand rather than from config is held to the same
// standard, and Provenance refuses to build a commit on top of a broken one.
func TestInstance_ValidateRejectsHandBuiltGarbage(t *testing.T) {
	good := AgentWrite{Agent: "a", Session: "s-1", Base: testBase}
	cases := map[string]Instance{
		"no owner name":       {OwnerName: "", OwnerEmail: "b@example.com", AgentEmail: "agent@example.com"},
		"unusable owner name": {OwnerName: "Not A Name", OwnerEmail: "b@example.com", AgentEmail: "agent@example.com"},
		"no owner email":      {OwnerName: "bigbes", OwnerEmail: "", AgentEmail: "agent@example.com"},
		"owner email injects": {OwnerName: "bigbes", OwnerEmail: "b@example.com>\nAuthor: root", AgentEmail: "agent@example.com"},
		"no agent email":      {OwnerName: "bigbes", OwnerEmail: "b@example.com", AgentEmail: ""},
		"agent email injects": {OwnerName: "bigbes", OwnerEmail: "b@example.com", AgentEmail: "agent@example.com>x"},
	}
	for name, inst := range cases {
		t.Run(name, func(t *testing.T) {
			if err := inst.Validate(); !errors.Is(err, ErrMissingConfig) {
				t.Fatalf("Validate error = %v, want ErrMissingConfig", err)
			}
			if _, err := inst.Provenance(good); !errors.Is(err, ErrMissingConfig) {
				t.Fatalf("Provenance error = %v, want ErrMissingConfig", err)
			}
		})
	}
}

// The worked example from the design, byte for byte apart from the agent
// mailbox, which is derived from our own origin.
func TestProvenance_MatchesTheDesign(t *testing.T) {
	inst := testInstance(t)
	prov, err := inst.Provenance(AgentWrite{
		Agent:   "claude-code/spec-writer",
		Session: "8fb9c9a4-b078-4af1-89eb-d97c522f9921",
		Base:    testBase,
	})
	if err != nil {
		t.Fatalf("Provenance: %v", err)
	}

	wantAuthor := "claude-code/spec-writer (for bigbes) <agent@spec.srht.bigb.es>"
	if got := prov.Author.String(); got != wantAuthor {
		t.Fatalf("Author = %q, want %q", got, wantAuthor)
	}
	wantCommitter := "bigbes <bigbes@gmail.com>"
	if got := prov.Committer.String(); got != wantCommitter {
		t.Fatalf("Committer = %q, want %q", got, wantCommitter)
	}

	wantTrailers := "X-Agent-Session: 8fb9c9a4-b078-4af1-89eb-d97c522f9921\n" +
		"X-Agent-Base: " + testBase + "\n"
	if got := prov.TrailerBlock(); got != wantTrailers {
		t.Fatalf("TrailerBlock = %q, want %q", got, wantTrailers)
	}

	msg, err := prov.CommitMessage("Add storage model section")
	if err != nil {
		t.Fatalf("CommitMessage: %v", err)
	}
	want := "Add storage model section\n\n" + wantTrailers
	if msg != want {
		t.Fatalf("CommitMessage = %q, want %q", msg, want)
	}
}

// Agent identity and session are mandatory on every agent write. A write that
// omits either is rejected, never defaulted — a commit stamped with a
// synthesised session launders unattributable output as attributed.
func TestProvenance_RejectsMissingAgentIdentity(t *testing.T) {
	inst := testInstance(t)
	full := AgentWrite{
		Agent:   "claude-code/spec-writer",
		Session: "8fb9c9a4-b078-4af1-89eb-d97c522f9921",
		Base:    testBase,
	}
	cases := map[string]AgentWrite{
		"no agent":   {Agent: "", Session: full.Session, Base: full.Base},
		"no session": {Agent: full.Agent, Session: "", Base: full.Base},
		"no base":    {Agent: full.Agent, Session: full.Session, Base: ""},
		"nothing":    {},
	}
	for name, w := range cases {
		t.Run(name, func(t *testing.T) {
			if err := w.Validate(); !errors.Is(err, ErrMissingProvenance) {
				t.Fatalf("Validate error = %v, want ErrMissingProvenance", err)
			}
			if _, err := inst.Provenance(w); !errors.Is(err, ErrMissingProvenance) {
				t.Fatalf("Provenance error = %v, want ErrMissingProvenance", err)
			}
		})
	}
	// Control: the complete write is accepted, so the cases above are failing
	// on the missing field and not on something incidental.
	if err := full.Validate(); err != nil {
		t.Fatalf("complete write rejected: %v", err)
	}
}

// A newline in the agent identity would split the git author line; a newline in
// the session would inject an arbitrary extra trailer. Both are refused
// outright rather than escaped.
func TestProvenance_RejectsInjection(t *testing.T) {
	inst := testInstance(t)
	cases := map[string]AgentWrite{
		"newline in agent":     {Agent: "evil\nAuthor: root", Session: "s-1", Base: testBase},
		"newline in session":   {Agent: "a", Session: "s\nX-Agent-Base: deadbeef", Base: testBase},
		"cr in session":        {Agent: "a", Session: "s\rX-Agent-Base: deadbeef", Base: testBase},
		"angle in agent":       {Agent: "a <root@example.com>", Session: "s-1", Base: testBase},
		"angle in session":     {Agent: "a", Session: "<s>", Base: testBase},
		"padded agent":         {Agent: " a ", Session: "s-1", Base: testBase},
		"nul in agent":         {Agent: "a\x00b", Session: "s-1", Base: testBase},
		"over-long agent":      {Agent: strings.Repeat("a", MaxAgentLen+1), Session: "s-1", Base: testBase},
		"over-long session":    {Agent: "a", Session: strings.Repeat("s", MaxSessionLen+1), Base: testBase},
		"base with a space":    {Agent: "a", Session: "s-1", Base: "dead beef"},
		"base not hex":         {Agent: "a", Session: "s-1", Base: "proposals/42"},
		"base uppercase hex":   {Agent: "a", Session: "s-1", Base: strings.ToUpper(testBase)},
		"base too short":       {Agent: "a", Session: "s-1", Base: "abc"},
		"base too long":        {Agent: "a", Session: "s-1", Base: strings.Repeat("a", maxRevLen+1)},
		"base with a newline":  {Agent: "a", Session: "s-1", Base: testBase + "\nX-Agent-Session: forged"},
		"invalid utf-8 agent":  {Agent: "a\xff", Session: "s-1", Base: testBase},
		"invalid utf-8 sessid": {Agent: "a", Session: "s\xff", Base: testBase},
	}
	for name, w := range cases {
		t.Run(name, func(t *testing.T) {
			if err := w.Validate(); !errors.Is(err, ErrInvalidProvenance) {
				t.Fatalf("Validate error = %v, want ErrInvalidProvenance", err)
			}
			if _, err := inst.Provenance(w); !errors.Is(err, ErrInvalidProvenance) {
				t.Fatalf("Provenance error = %v, want ErrInvalidProvenance", err)
			}
		})
	}
}

// An abbreviated object name is accepted; the trailer stays auditable and
// nothing can hide in it.
func TestProvenance_AcceptsAbbreviatedBase(t *testing.T) {
	inst := testInstance(t)
	for _, base := range []string{"1f0c1d1", testBase, strings.Repeat("a", 64)} {
		if _, err := inst.Provenance(AgentWrite{Agent: "a", Session: "s-1", Base: base}); err != nil {
			t.Fatalf("base %q rejected: %v", base, err)
		}
	}
}

func TestCommitMessage_RejectsEmpty(t *testing.T) {
	inst := testInstance(t)
	prov, err := inst.Provenance(AgentWrite{Agent: "a", Session: "s-1", Base: testBase})
	if err != nil {
		t.Fatal(err)
	}
	for _, msg := range []string{"", "   ", "\n\n\t"} {
		if _, err := prov.CommitMessage(msg); !errors.Is(err, ErrInvalidProvenance) {
			t.Fatalf("CommitMessage(%q) error = %v, want ErrInvalidProvenance", msg, err)
		}
	}
}

// Trailer-shaped text inside the agent's own message must stay in the body:
// our block is the last paragraph, which is the one git parses as trailers.
func TestCommitMessage_OurTrailersAreTheLastParagraph(t *testing.T) {
	inst := testInstance(t)
	prov, err := inst.Provenance(AgentWrite{Agent: "a", Session: "real-session", Base: testBase})
	if err != nil {
		t.Fatal(err)
	}
	msg, err := prov.CommitMessage("Subject\n\nX-Agent-Session: forged\n")
	if err != nil {
		t.Fatal(err)
	}
	paras := strings.Split(strings.TrimRight(msg, "\n"), "\n\n")
	last := paras[len(paras)-1]
	if !strings.HasPrefix(last, "X-Agent-Session: real-session\n") {
		t.Fatalf("last paragraph = %q, want it to start with the real session", last)
	}
	if strings.Contains(last, "forged") {
		t.Fatalf("forged trailer leaked into the trailer paragraph: %q", last)
	}
}

// Provenance for a non-agent principal is an error: the human write path goes
// through native receive-pack and builds no commit here.
func TestAgentWriteFor_RejectsNonAgents(t *testing.T) {
	for _, p := range []Principal{
		Anonymous(),
		{Kind: KindOwner, Owner: "bigbes"},
	} {
		if _, err := p.AgentWriteFor(testBase); !errors.Is(err, ErrNotAgent) {
			t.Fatalf("%s: error = %v, want ErrNotAgent", p, err)
		}
	}
}

func TestAgentWriteFor_Agent(t *testing.T) {
	p := Principal{
		Kind:    KindAgent,
		Owner:   "bigbes",
		Agent:   "claude-code/spec-writer",
		Session: "8fb9c9a4-b078-4af1-89eb-d97c522f9921",
	}
	w, err := p.AgentWriteFor(testBase)
	if err != nil {
		t.Fatalf("AgentWriteFor: %v", err)
	}
	if w.Agent != p.Agent || w.Session != p.Session || w.Base != testBase {
		t.Fatalf("AgentWrite = %+v", w)
	}

	// An agent that authenticated but sent no provenance headers reads fine and
	// is refused at the write, which is where the design requires the fields.
	bare := Principal{Kind: KindAgent, Owner: "bigbes"}
	if _, err := bare.AgentWriteFor(testBase); !errors.Is(err, ErrMissingProvenance) {
		t.Fatalf("error = %v, want ErrMissingProvenance", err)
	}
}

A authn/resolver.go => authn/resolver.go +115 -0
@@ 0,0 1,115 @@
package authn

import (
	"context"
	"fmt"
	"log"
	"net/http"
	"strings"

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

// Resolver turns a request into a Principal. It holds the instance owner
// username — the one name a cookie has to match to carry authority — and the
// TokenStore agents are checked against.
type Resolver struct {
	owner string
	store TokenStore
}

// NewResolver builds a Resolver for the instance owner named in
// [sr.ht] owner-name.
//
// A nil store is rejected rather than tolerated: with no store every agent
// token would resolve as unknown, which looks exactly like a mass revocation
// and is a miserable thing to debug at 2am. Wire a store or do not build a
// resolver.
func NewResolver(owner string, store TokenStore) (*Resolver, error) {
	owner = strings.TrimPrefix(owner, "~")
	if err := core.ValidateOwner(owner); err != nil {
		return nil, fmt.Errorf("authn: instance owner: %w", err)
	}
	if store == nil {
		return nil, fmt.Errorf("authn: nil TokenStore")
	}
	return &Resolver{owner: owner, store: store}, nil
}

// Owner returns the instance owner username this resolver recognises.
func (rs *Resolver) Owner() string { return rs.owner }

// Resolve determines who is making a request.
//
// A bearer token wins over a cookie when both are present: an agent that went
// to the trouble of presenting a credential is asking to be treated as an
// agent, and letting a stale browser cookie promote it to the owner would hand
// it the approved branch. The two credentials are checked in that order and
// never merged.
//
// The error contract is asymmetric on purpose:
//
//   - No bearer token: never an error. The cookie decides between KindOwner and
//     KindAnonymous, and any cookie problem is anonymity, not failure.
//   - A bearer token that fails: an error. IsAuthFailure separates the 401 case
//     (unknown, revoked, malformed) from the 503 case (store unreachable).
//
// The agent identity and session headers are read here but not required: they
// are demanded at the write, by AgentWrite.Validate, which is the only place
// the design requires them and the only place a missing one can do harm.
func (rs *Resolver) Resolve(ctx context.Context, r *http.Request) (Principal, error) {
	if presented := BearerFromRequest(r); presented != "" {
		tok, err := ResolveAgentToken(ctx, rs.store, presented)
		if err != nil {
			return Anonymous(), err
		}
		return Principal{
			Kind:      KindAgent,
			Owner:     rs.owner,
			Agent:     strings.TrimSpace(r.Header.Get(HeaderAgent)),
			Session:   strings.TrimSpace(r.Header.Get(HeaderAgentSession)),
			TokenName: tok.Name,
		}, nil
	}

	username := UsernameFromRequest(r)
	if username == "" {
		return Anonymous(), nil
	}
	if username != rs.owner {
		// A real user of the instance who is not bigbes. Single-user means
		// there is nothing to grant them, so they read exactly as an anonymous
		// viewer does; the name is kept for the log line and the "you are
		// signed in as" affordance only.
		return Principal{Kind: KindAnonymous, CookieUser: username}, nil
	}
	return Principal{Kind: KindOwner, Owner: username, CookieUser: username}, nil
}

// Middleware attaches the resolved Principal to the request context, where
// PrincipalFromContext reads it.
//
// It rejects only a failed bearer token — 401 for a bad credential, 503 for a
// store that could not answer. Everything else, including every cookie
// problem, flows through as anonymous: the read plane is anonymous-capable and
// must never answer an error page on identity grounds.
func (rs *Resolver) Middleware() func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			p, err := rs.Resolve(r.Context(), r)
			if err != nil {
				if IsAuthFailure(err) {
					http.Error(w, "invalid agent token", http.StatusUnauthorized)
					return
				}
				// Fail closed and loudly. The alternative — degrading to
				// anonymous — would turn a Postgres blip into agents silently
				// losing their write access.
				log.Printf("authn: resolving agent token: %v", err)
				http.Error(w, "authentication backend unavailable", http.StatusServiceUnavailable)
				return
			}
			next.ServeHTTP(w, r.WithContext(WithPrincipal(r.Context(), p)))
		})
	}
}

A authn/resolver_test.go => authn/resolver_test.go +330 -0
@@ 0,0 1,330 @@
package authn

import (
	"context"
	"errors"
	"net/http"
	"net/http/httptest"
	"testing"
)

func newTestResolver(t *testing.T, store TokenStore) *Resolver {
	t.Helper()
	rs, err := NewResolver("bigbes", store)
	if err != nil {
		t.Fatalf("NewResolver: %v", err)
	}
	return rs
}

func TestNewResolver_RejectsBadWiring(t *testing.T) {
	if _, err := NewResolver("bigbes", nil); err == nil {
		t.Fatal("nil store must be rejected")
	}
	if _, err := NewResolver("", newStubStore()); err == nil {
		t.Fatal("empty owner must be rejected")
	}
	if _, err := NewResolver("Not A Name", newStubStore()); err == nil {
		t.Fatal("unusable owner must be rejected")
	}
	// The canonical "~user" spelling is accepted and normalised.
	rs, err := NewResolver("~bigbes", newStubStore())
	if err != nil {
		t.Fatalf("NewResolver(~bigbes): %v", err)
	}
	if rs.Owner() != "bigbes" {
		t.Fatalf("Owner() = %q, want %q", rs.Owner(), "bigbes")
	}
}

func TestResolve_OwnerCookie(t *testing.T) {
	store := newStubStore()
	rs := newTestResolver(t, store)

	p, err := rs.Resolve(context.Background(), request(sealCookie(t, "bigbes"), nil))
	if err != nil {
		t.Fatalf("Resolve: %v", err)
	}
	if !p.IsOwner() {
		t.Fatalf("principal = %+v, want the owner", p)
	}
	if p.IsAnonymous() || p.IsAgent() {
		t.Fatalf("owner classified as anonymous/agent: %+v", p)
	}
	if p.Owner != "bigbes" {
		t.Fatalf("Owner = %q, want %q", p.Owner, "bigbes")
	}
	if store.calls != 0 {
		t.Fatalf("cookie path consulted the token store %d times, want 0", store.calls)
	}
}

func TestResolve_AbsentCookieIsAnonymousNotAnError(t *testing.T) {
	rs := newTestResolver(t, newStubStore())
	p, err := rs.Resolve(context.Background(), request("", nil))
	if err != nil {
		t.Fatalf("an anonymous request must never error: %v", err)
	}
	if !p.IsAnonymous() {
		t.Fatalf("principal = %+v, want anonymous", p)
	}
}

func TestResolve_BrokenCookiesAreAnonymousNotErrors(t *testing.T) {
	rs := newTestResolver(t, newStubStore())
	for name, value := range map[string]string{
		"tampered":    tamper(t, sealCookie(t, "bigbes")),
		"garbage":     "not-a-valid-fernet-token",
		"foreign key": sealCookieWithKey(t, &rotatedKey, "bigbes"),
	} {
		t.Run(name, func(t *testing.T) {
			p, err := rs.Resolve(context.Background(), request(value, nil))
			if err != nil {
				t.Fatalf("a broken cookie must never error: %v", err)
			}
			if !p.IsAnonymous() {
				t.Fatalf("principal = %+v, want anonymous", p)
			}
		})
	}
}

// Single-user: a real user who is not bigbes has nothing granted to them, so
// they read exactly as an anonymous viewer does. The name survives for logs
// only, and must not be mistaken for authority.
func TestResolve_NonOwnerCookieIsAnonymous(t *testing.T) {
	rs := newTestResolver(t, newStubStore())
	p, err := rs.Resolve(context.Background(), request(sealCookie(t, "someone"), nil))
	if err != nil {
		t.Fatalf("Resolve: %v", err)
	}
	if !p.IsAnonymous() {
		t.Fatalf("principal = %+v, want anonymous", p)
	}
	if p.IsOwner() {
		t.Fatal("a non-owner cookie must not yield the owner")
	}
	if p.Owner != "" {
		t.Fatalf("Owner = %q, want empty for a non-owner", p.Owner)
	}
	if p.CookieUser != "someone" {
		t.Fatalf("CookieUser = %q, want %q", p.CookieUser, "someone")
	}
}

func TestResolve_AgentTokenAccepted(t *testing.T) {
	store := newStubStore()
	store.add("live-token", "laptop")
	rs := newTestResolver(t, store)

	p, err := rs.Resolve(context.Background(), request("", map[string]string{
		"Authorization":    "Bearer live-token",
		HeaderAgent:        "claude-code/spec-writer",
		HeaderAgentSession: "8fb9c9a4-b078-4af1-89eb-d97c522f9921",
	}))
	if err != nil {
		t.Fatalf("Resolve: %v", err)
	}
	if !p.IsAgent() {
		t.Fatalf("principal = %+v, want an agent", p)
	}
	if p.IsOwner() || p.IsAnonymous() {
		t.Fatalf("agent classified as owner/anonymous: %+v", p)
	}
	if p.Agent != "claude-code/spec-writer" || p.Session != "8fb9c9a4-b078-4af1-89eb-d97c522f9921" {
		t.Fatalf("provenance not carried onto the principal: %+v", p)
	}
	if p.Owner != "bigbes" {
		t.Fatalf("agent acts for %q, want %q", p.Owner, "bigbes")
	}
	if p.TokenName != "laptop" {
		t.Fatalf("TokenName = %q, want %q", p.TokenName, "laptop")
	}
}

func TestResolve_AgentTokenRevokedAndUnknown(t *testing.T) {
	store := newStubStore()
	store.add("live-token", "laptop")
	store.revoke("dead-token", "cron")
	rs := newTestResolver(t, store)

	cases := map[string]struct {
		token string
		want  error
	}{
		"revoked": {"dead-token", ErrRevokedToken},
		"unknown": {"never-issued", ErrUnknownToken},
	}
	for name, c := range cases {
		t.Run(name, func(t *testing.T) {
			p, err := rs.Resolve(context.Background(), request("",
				map[string]string{"Authorization": "Bearer " + c.token}))
			if !errors.Is(err, c.want) {
				t.Fatalf("error = %v, want %v", err, c.want)
			}
			if !p.IsAnonymous() {
				t.Fatalf("a refused token must yield no authority: %+v", p)
			}
		})
	}
}

// A bearer token wins over a cookie. Letting a stale browser cookie promote a
// token-bearing request to the owner would hand an agent the approved branch.
func TestResolve_BearerBeatsCookie(t *testing.T) {
	store := newStubStore()
	store.add("live-token", "laptop")
	rs := newTestResolver(t, store)

	p, err := rs.Resolve(context.Background(), request(sealCookie(t, "bigbes"),
		map[string]string{"Authorization": "Bearer live-token"}))
	if err != nil {
		t.Fatalf("Resolve: %v", err)
	}
	if !p.IsAgent() {
		t.Fatalf("principal = %+v, want an agent", p)
	}
	if p.IsOwner() {
		t.Fatal("an owner cookie must not promote a token-bearing request")
	}
}

// A bad token is a hard failure even when a valid owner cookie is present: an
// agent silently downgraded to a reader fails confusingly at its first write
// instead of clearly at the door.
func TestResolve_BadTokenFailsEvenWithOwnerCookie(t *testing.T) {
	store := newStubStore()
	rs := newTestResolver(t, store)

	_, err := rs.Resolve(context.Background(), request(sealCookie(t, "bigbes"),
		map[string]string{"Authorization": "Bearer never-issued"}))
	if !errors.Is(err, ErrUnknownToken) {
		t.Fatalf("error = %v, want ErrUnknownToken", err)
	}
}

func runMiddleware(t *testing.T, rs *Resolver, r *http.Request) (Principal, int, bool) {
	t.Helper()
	var got Principal
	var reached bool
	h := rs.Middleware()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		reached = true
		got = PrincipalFromContext(r.Context())
	}))
	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, r)
	return got, rec.Code, reached
}

func TestMiddleware_AttachesPrincipal(t *testing.T) {
	store := newStubStore()
	store.add("live-token", "laptop")
	rs := newTestResolver(t, store)

	t.Run("owner", func(t *testing.T) {
		p, code, reached := runMiddleware(t, rs, request(sealCookie(t, "bigbes"), nil))
		if !reached || code != http.StatusOK {
			t.Fatalf("reached=%v code=%d, want true/200", reached, code)
		}
		if !p.IsOwner() {
			t.Fatalf("principal = %+v, want the owner", p)
		}
	})

	t.Run("anonymous", func(t *testing.T) {
		p, code, reached := runMiddleware(t, rs, request("", nil))
		if !reached || code != http.StatusOK {
			t.Fatalf("reached=%v code=%d, want true/200", reached, code)
		}
		if !p.IsAnonymous() {
			t.Fatalf("principal = %+v, want anonymous", p)
		}
	})

	t.Run("agent", func(t *testing.T) {
		p, code, reached := runMiddleware(t, rs, request("", map[string]string{
			"Authorization":    "Bearer live-token",
			HeaderAgent:        "claude-code/spec-writer",
			HeaderAgentSession: "sess-1",
		}))
		if !reached || code != http.StatusOK {
			t.Fatalf("reached=%v code=%d, want true/200", reached, code)
		}
		if !p.IsAgent() || p.Agent != "claude-code/spec-writer" {
			t.Fatalf("principal = %+v, want the agent", p)
		}
	})
}

func TestMiddleware_RejectsBadToken(t *testing.T) {
	rs := newTestResolver(t, newStubStore())
	_, code, reached := runMiddleware(t, rs, request("",
		map[string]string{"Authorization": "Bearer never-issued"}))
	if reached {
		t.Fatal("a refused token must not reach the handler")
	}
	if code != http.StatusUnauthorized {
		t.Fatalf("code = %d, want 401", code)
	}
}

func TestMiddleware_StoreOutageIs503(t *testing.T) {
	store := newStubStore()
	store.err = errors.New("connection refused")
	rs := newTestResolver(t, store)

	_, code, reached := runMiddleware(t, rs, request("",
		map[string]string{"Authorization": "Bearer live-token"}))
	if reached {
		t.Fatal("a store outage must fail closed, not reach the handler")
	}
	if code != http.StatusServiceUnavailable {
		t.Fatalf("code = %d, want 503", code)
	}
}

// A handler reached without the middleware must degrade to less authority, not
// more.
func TestPrincipalFromContext_BareContextIsAnonymous(t *testing.T) {
	p := PrincipalFromContext(context.Background())
	if !p.IsAnonymous() || p.IsOwner() || p.IsAgent() {
		t.Fatalf("bare context yielded %+v, want anonymous", p)
	}
	if p.Kind != KindAnonymous {
		t.Fatalf("Kind = %q, want %q", p.Kind, KindAnonymous)
	}
}

func TestPrincipalFromContext_RoundTrip(t *testing.T) {
	want := Principal{Kind: KindAgent, Owner: "bigbes", Agent: "a", Session: "s"}
	if got := PrincipalFromContext(WithPrincipal(context.Background(), want)); got != want {
		t.Fatalf("round trip = %+v, want %+v", got, want)
	}
}

// The zero Principal must be the anonymous one — an unrecognised Kind is denied
// rather than accidentally admitted.
func TestPrincipal_ZeroValueIsAnonymous(t *testing.T) {
	var p Principal
	if !p.IsAnonymous() || p.IsOwner() || p.IsAgent() {
		t.Fatalf("zero Principal = %+v, want anonymous", p)
	}
	if p2 := (Principal{Kind: Kind("nonsense")}); !p2.IsAnonymous() {
		t.Fatal("an unrecognised Kind must be anonymous")
	}
}

func TestPrincipal_String(t *testing.T) {
	cases := map[Principal]string{
		Anonymous(): "anonymous",
		{Kind: KindAnonymous, CookieUser: "someone"}:                                         "anonymous (cookie user ~someone)",
		{Kind: KindOwner, Owner: "bigbes"}:                                                   "owner ~bigbes",
		{Kind: KindAgent, Owner: "bigbes", Agent: "claude-code/spec-writer", Session: "s-1"}: "agent claude-code/spec-writer session s-1 for ~bigbes",
		{Kind: KindAgent, Owner: "bigbes"}:                                                   "agent (unnamed) session (no session) for ~bigbes",
	}
	for p, want := range cases {
		if got := p.String(); got != want {
			t.Fatalf("String() = %q, want %q", got, want)
		}
	}
}

A authn/token.go => authn/token.go +158 -0
@@ 0,0 1,158 @@
package authn

import (
	"context"
	"crypto/sha256"
	"crypto/subtle"
	"database/sql"
	"errors"
	"fmt"
	"net/http"
	"strings"
	"time"
)

// bearerScheme is the Authorization scheme agents present the token under.
// Matched case-insensitively, as RFC 7235 requires.
const bearerScheme = "bearer"

// HeaderAgent and HeaderAgentSession carry the mandatory provenance an agent
// must send alongside its token. They are named after the git trailers they end
// up in, so that what an agent sends and what a reviewer reads in `git log` are
// spelled the same way.
//
// There is deliberately no X-Agent-Base header: the base revision is the
// `If-Match` value the write plane already defines, and giving it a second
// spelling is exactly how REST and MCP end up disagreeing about what it means.
const (
	HeaderAgent        = "X-Agent"
	HeaderAgentSession = "X-Agent-Session"
)

// AgentToken is one row of the agent_token table, in the shape this package
// needs. It is declared here rather than in db/ so that authn owns its own
// input contract and the dependency arrow keeps pointing downward.
type AgentToken struct {
	// ID is the agent_token primary key. Diagnostics and audit only.
	ID int64

	// Name is the operator-facing label of the token ("laptop", "cron"). With
	// one token and no scopes it grants nothing; it exists so a revocation can
	// be aimed at something a human recognises.
	Name string

	// Hash is the stored sha256 of the token as issued. ResolveAgentToken
	// re-checks it against the presented token in constant time rather than
	// trusting that the store's lookup was an exact match.
	Hash []byte

	// Created is when the token was issued.
	Created time.Time

	// Revoked is when the token was revoked, or nil while it is live. A
	// revoked row still resolves from the store — it is this package that
	// turns it into a refusal, so the refusal can say "revoked" rather than
	// "unknown".
	Revoked *time.Time
}

// IsRevoked reports whether the token has been revoked.
func (t AgentToken) IsRevoked() bool { return t.Revoked != nil }

// TokenStore is the sliver of db/ that authn needs: look up an agent token row
// by the hash of the presented secret. service/ wires the real Postgres
// implementation in; tests wire a map.
//
// Contract:
//
//   - hash is the value HashToken returned; the implementation must match it
//     against agent_token.token_hash exactly, never by prefix.
//   - When no row matches, return an error satisfying
//     errors.Is(err, ErrUnknownToken). sql.ErrNoRows is accepted as an
//     equivalent spelling, since that is what a bare QueryRow().Scan() yields.
//   - Any other error is taken to be transient (Postgres down, context
//     cancelled) and is surfaced as such, never as a bad credential.
//
// The interface takes no scope or space argument on purpose: v1 has one agent
// token and no per-space scoping, and the boundary that actually bounds damage
// is the refs rule in gitx. Adding scopes later is a column here and a filter
// clause there, not a reshaping of this interface.
type TokenStore interface {
	LookupAgentToken(ctx context.Context, hash []byte) (AgentToken, error)
}

// HashToken returns the sha256 of a presented agent token — the value stored in
// agent_token.token_hash and the only form of the secret this service keeps.
// The token itself is opaque and high-entropy, so a plain hash is sufficient:
// there is no low-entropy password here for a KDF to slow down guessing of.
func HashToken(token string) []byte {
	sum := sha256.Sum256([]byte(token))
	return sum[:]
}

// BearerFromRequest returns the token from an "Authorization: Bearer <token>"
// header, or "" when the header is absent or uses another scheme. Anything
// after the scheme is returned verbatim apart from surrounding whitespace: the
// token is opaque and this package is not the place to guess at its grammar.
func BearerFromRequest(r *http.Request) string {
	h := r.Header.Get("Authorization")
	if h == "" {
		return ""
	}
	scheme, rest, ok := strings.Cut(h, " ")
	if !ok || !strings.EqualFold(scheme, bearerScheme) {
		return ""
	}
	return strings.TrimSpace(rest)
}

// ResolveAgentToken validates a presented agent token against the store.
//
// Unlike the cookie path this fails loudly. An agent that presented a
// credential and got silently downgraded to an anonymous reader would sail
// through its reads and then fail incomprehensibly at its first propose; a 401
// at the door is the only useful answer.
//
// Permanent refusals (ErrNoToken, ErrUnknownToken, ErrRevokedToken,
// ErrInvalidToken) satisfy IsAuthFailure. A store failure is returned wrapped
// and does not, so callers answer 503 rather than 401 and agents retry rather
// than re-provision.
func ResolveAgentToken(ctx context.Context, store TokenStore, presented string) (AgentToken, error) {
	if store == nil {
		// A nil store is a wiring bug, not a credential problem. Refusing
		// loudly beats resolving every token as unknown, which would look like
		// a revocation storm.
		return AgentToken{}, errors.New("authn: nil TokenStore")
	}
	if presented == "" {
		return AgentToken{}, ErrNoToken
	}

	hash := HashToken(presented)
	tok, err := store.LookupAgentToken(ctx, hash)
	switch {
	case err == nil:
		// fall through
	case errors.Is(err, ErrUnknownToken), errors.Is(err, sql.ErrNoRows):
		// Two spellings of "no such row"; ErrUnknownToken is the contract,
		// sql.ErrNoRows is what an unwrapped Scan leaks.
		return AgentToken{}, fmt.Errorf("%w: no agent token matches the presented secret", ErrUnknownToken)
	default:
		return AgentToken{}, fmt.Errorf("looking up agent token: %w", err)
	}

	// Re-check the hash ourselves, in constant time. The store's WHERE clause
	// already did an equality match, but this is the one comparison that
	// decides authentication and it costs a memcmp to not depend on somebody
	// else getting it right.
	if subtle.ConstantTimeCompare(tok.Hash, hash) != 1 {
		return AgentToken{}, fmt.Errorf("%w: store returned a row whose hash does not match the presented token", ErrInvalidToken)
	}

	if tok.IsRevoked() {
		return AgentToken{}, fmt.Errorf("%w: token %q was revoked at %s",
			ErrRevokedToken, tok.Name, tok.Revoked.UTC().Format(time.RFC3339))
	}

	return tok, nil
}

A authn/token_test.go => authn/token_test.go +161 -0
@@ 0,0 1,161 @@
package authn

import (
	"bytes"
	"context"
	"database/sql"
	"errors"
	"net/http"
	"net/http/httptest"
	"testing"
)

func TestHashToken_IsSHA256AndStable(t *testing.T) {
	a := HashToken("s3cret")
	b := HashToken("s3cret")
	if len(a) != 32 {
		t.Fatalf("hash length = %d, want 32", len(a))
	}
	if !bytes.Equal(a, b) {
		t.Fatal("hashing the same token twice produced different values")
	}
	if bytes.Equal(a, HashToken("s3cres")) {
		t.Fatal("distinct tokens hashed to the same value")
	}
}

func TestBearerFromRequest(t *testing.T) {
	cases := map[string]struct{ header, want string }{
		"bearer":            {"Bearer abc123", "abc123"},
		"lowercase scheme":  {"bearer abc123", "abc123"},
		"mixed case scheme": {"BeArEr abc123", "abc123"},
		"padded":            {"Bearer   abc123  ", "abc123"},
		"basic is not ours": {"Basic dXNlcjpwYXNz", ""},
		"no scheme":         {"abc123", ""},
		"absent":            {"", ""},
	}
	for name, c := range cases {
		t.Run(name, func(t *testing.T) {
			r := httptest.NewRequest(http.MethodGet, "/", nil)
			if c.header != "" {
				r.Header.Set("Authorization", c.header)
			}
			if got := BearerFromRequest(r); got != c.want {
				t.Fatalf("BearerFromRequest = %q, want %q", got, c.want)
			}
		})
	}
}

func TestResolveAgentToken_Accepted(t *testing.T) {
	store := newStubStore()
	want := store.add("live-token", "laptop")

	got, err := ResolveAgentToken(context.Background(), store, "live-token")
	if err != nil {
		t.Fatalf("ResolveAgentToken: %v", err)
	}
	if got.ID != want.ID || got.Name != "laptop" {
		t.Fatalf("resolved %+v, want id %d name %q", got, want.ID, "laptop")
	}
	if got.IsRevoked() {
		t.Fatal("live token reported as revoked")
	}
}

func TestResolveAgentToken_Revoked(t *testing.T) {
	store := newStubStore()
	store.revoke("dead-token", "cron")

	_, err := ResolveAgentToken(context.Background(), store, "dead-token")
	if !errors.Is(err, ErrRevokedToken) {
		t.Fatalf("error = %v, want ErrRevokedToken", err)
	}
	// A revoked token must not be reported as unknown: the operator needs to
	// tell "I killed this" from "this never existed".
	if errors.Is(err, ErrUnknownToken) {
		t.Fatalf("revoked token also reported as unknown: %v", err)
	}
	if !IsAuthFailure(err) {
		t.Fatalf("revocation must be a permanent auth failure: %v", err)
	}
}

func TestResolveAgentToken_Unknown(t *testing.T) {
	store := newStubStore()
	store.add("live-token", "laptop")

	_, err := ResolveAgentToken(context.Background(), store, "never-issued")
	if !errors.Is(err, ErrUnknownToken) {
		t.Fatalf("error = %v, want ErrUnknownToken", err)
	}
	if !IsAuthFailure(err) {
		t.Fatalf("unknown token must be a permanent auth failure: %v", err)
	}
}

// db/ may hand back a bare sql.ErrNoRows from QueryRow().Scan(); it means the
// same thing as ErrUnknownToken and must not be mistaken for a store outage.
func TestResolveAgentToken_SQLNoRowsIsUnknown(t *testing.T) {
	store := newStubStore()
	store.err = sql.ErrNoRows

	_, err := ResolveAgentToken(context.Background(), store, "whatever")
	if !errors.Is(err, ErrUnknownToken) {
		t.Fatalf("error = %v, want ErrUnknownToken", err)
	}
}

func TestResolveAgentToken_EmptyIsNoToken(t *testing.T) {
	store := newStubStore()
	_, err := ResolveAgentToken(context.Background(), store, "")
	if !errors.Is(err, ErrNoToken) {
		t.Fatalf("error = %v, want ErrNoToken", err)
	}
	if store.calls != 0 {
		t.Fatalf("store consulted %d times for an absent token, want 0", store.calls)
	}
}

// A store outage must never read as a bad credential: fail closed, but tell the
// caller it is transient so it answers 503 and the agent retries.
func TestResolveAgentToken_StoreFailureIsTransient(t *testing.T) {
	boom := errors.New("connection refused")
	store := newStubStore()
	store.add("live-token", "laptop")
	store.err = boom

	_, err := ResolveAgentToken(context.Background(), store, "live-token")
	if !errors.Is(err, boom) {
		t.Fatalf("error = %v, want it to wrap the store error", err)
	}
	if IsAuthFailure(err) {
		t.Fatalf("store failure must not be a permanent auth failure: %v", err)
	}
}

// The constant-time re-check exists so that a store which matched loosely — by
// prefix, or on the wrong column — cannot authenticate anybody.
func TestResolveAgentToken_HashMismatchRejected(t *testing.T) {
	store := newStubStore()
	store.put(HashToken("presented"), AgentToken{
		ID:   7,
		Name: "sloppy-store",
		Hash: HashToken("something-else"),
	})

	_, err := ResolveAgentToken(context.Background(), store, "presented")
	if !errors.Is(err, ErrInvalidToken) {
		t.Fatalf("error = %v, want ErrInvalidToken", err)
	}
}

func TestResolveAgentToken_NilStoreIsNotAnAuthFailure(t *testing.T) {
	_, err := ResolveAgentToken(context.Background(), nil, "live-token")
	if err == nil {
		t.Fatal("nil store must be an error")
	}
	if IsAuthFailure(err) {
		t.Fatalf("a wiring bug must not read as a bad credential: %v", err)
	}
}