~bigbes/sr-ht-spec

c74776077006e81af82c2fd53cb186271b42bee9 — Eugene Blikh 10 days ago bb3d22d
authn: accept tokens.sr.ht working tokens beside the agent token

A second agent credential plane, next to the existing one rather than in
place of it. The agent_token table, every agent configured with it, and
the refs rule and provenance requirement around it are untouched; the
local plane is removed in a later phase, not this one.

The resolver tries the instance plane first and falls back to the local
store on exactly two refusals, bearer.ErrInvalid and bearer.ErrNotOurs.
spec's local token has no prefix to discriminate on — it is 32 random
bytes in base64, which is precisely what "did not decode as one of ours"
looks like — so the fallback replaces the shape test bench and cover can
afford. ErrRevoked, ErrForbidden and ErrUnavailable are terminal: a
withdrawn credential must not get a second chance at the old door, and an
unreachable daemon must not silently degrade into the legacy plane.

Grants ride on the principal and are checked where the action is known,
never in the middleware, which runs upstream of the router: spec:propose
in service.Propose, below both write surfaces, and spec:read in each read
surface's gate. /mcp checks per tool rather than at its Gate, because one
endpoint carries both kinds and a surface-wide read grant would refuse a
propose-only token at initialize. Principal.Authorize is a no-op off the
instance plane, which is what keeps the local token working.

The instance plane brings an owner where the local token had none, so a
working token belonging to anybody but [sr.ht] owner-name is refused
rather than admitted as a second identity: Principal.Owner is read by the
provenance committer, the refs rule's principal kind and the coreauth
AuthContext, all written for one human.

StatusFor is the one status table. ErrUnavailable is 503 and never 401 —
reading "I could not ask tokens.sr.ht" as "revoked" would refuse every
live instance token while a daemon that is deliberately off the hot path
restarts.

An instance with no [tokens.sr.ht] section builds no instance plane and
starts anyway, serving its own agent token as before.
M api/api_test.go => api/api_test.go +3 -1
@@ 116,7 116,9 @@ func TestPutOpensProposal(t *testing.T) {
	if len(g.Writes) != 1 || g.Writes[0].Path != "specs/0007-storage.md" || string(g.Writes[0].Content) != "the document" {
		t.Errorf("writes = %+v", g.Writes)
	}
	if g.Principal != agent() {
	// Principal is no longer comparable with == — it carries a grant set — so
	// its rendering stands in for it here.
	if g.Principal.String() != agent().String() {
		t.Errorf("principal = %+v, want the agent on the context", g.Principal)
	}
}

A api/bearer_test.go => api/bearer_test.go +239 -0
@@ 0,0 1,239 @@
package api_test

import (
	"context"
	"crypto/sha256"
	"encoding/hex"
	"net/http"
	"net/http/httptest"
	"os"
	"strings"
	"testing"
	"testing/fstest"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

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

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

// TestMain initialises the one piece of process-global core-go state this file
// needs: crypto.InitCrypto holds the key working tokens are signed with, derived
// from [webhooks] private-key. The keys are the ones core-go's own tests use.
// The rest of api_test does not need it, which is why it never had a TestMain.
func TestMain(m *testing.M) {
	config.FS = fstest.MapFS{
		"config.ini": &fstest.MapFile{Data: []byte(`
[webhooks]
private-key=ebzsjPaN6E13ln/FeNWly1C92q6bVMVdOnDo1HPl5fc=

[sr.ht]
network-key=tbuG-7Vh44vrDq1L_HKWkHnWrDOtJhEkPKPiauaLeuk=
`)},
	}
	crypto.InitCrypto(config.LoadConfig())
	os.Exit(m.Run())
}

// ---------------------------------------------------------------------------
// Fixtures: the two credential planes, in front of the real REST write route.
// ---------------------------------------------------------------------------

// localTokens is spec's own agent_token table, in memory.
type localTokens struct {
	rows  map[string]authn.AgentToken
	calls int
}

func newLocalTokens() *localTokens { return &localTokens{rows: map[string]authn.AgentToken{}} }

func (s *localTokens) add(secret, name string) {
	sum := sha256.Sum256([]byte(secret))
	s.rows[hex.EncodeToString(sum[:])] = authn.AgentToken{ID: 1, Name: name, Hash: sum[:]}
}

func (s *localTokens) LookupAgentToken(_ context.Context, hash []byte) (authn.AgentToken, error) {
	s.calls++
	tok, ok := s.rows[hex.EncodeToString(hash)]
	if !ok {
		return authn.AgentToken{}, authn.ErrUnknownToken
	}
	return tok, nil
}

// users resolves the instance owner to a local row.
type users struct{}

func (users) LookupUser(_ context.Context, username string) (authn.InstanceUser, error) {
	return authn.InstanceUser{ID: 1, Username: username}, nil
}

// sealToken mints a signed working token the way tokens.sr.ht does.
func sealToken(grantString string, expires time.Time) string {
	bt := &auth.BearerToken{
		Version:  auth.TokenVersion,
		Expires:  auth.ToTimestamp(expires),
		Grants:   grantString,
		ClientID: bearer.TokensClientID,
		Username: "bigbes",
	}
	return bt.Encode()
}

func liveToken(grantString string) string { return sealToken(grantString, time.Now().Add(time.Hour)) }

// planeFixture is the REST write plane behind the real resolver middleware,
// with both credential planes wired: the tokens.sr.ht validator pointed at a
// fake revocation daemon, and the local agent_token store.
type planeFixture struct {
	handler http.Handler
	writer  *fakeWriter
	local   *localTokens
}

func newPlaneFixture(t *testing.T, daemonStatus int, closeDaemon bool) *planeFixture {
	t.Helper()

	daemon := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(daemonStatus)
	}))
	origin := daemon.URL
	if closeDaemon {
		daemon.Close() // nothing answers there any more
	} else {
		t.Cleanup(daemon.Close)
	}

	v, err := bearer.New(bearer.Options{Origin: origin, ClientID: "spec.sr.ht", NodeID: "spec-test"})
	require.NoError(t, err)

	local := newLocalTokens()
	resolver, err := authn.NewResolver("bigbes", local, authn.WithInstancePlane(v, users{}))
	require.NoError(t, err)

	w := &fakeWriter{res: service.ProposeResult{
		Proposal: service.Proposal{ID: 42, Branch: "proposals/42", BaseRev: "deadbeef", State: core.StateOpen},
		URL:      "https://spec.srht.bigb.es/~bigbes/rfcs/p/42",
	}}
	srv, err := api.New(api.Options{Writer: w, Resolver: resolver})
	require.NoError(t, err)

	return &planeFixture{handler: srv.Handler(), writer: w, local: local}
}

func (f *planeFixture) put(token string) *httptest.ResponseRecorder {
	req := httptest.NewRequest(http.MethodPut,
		"/v1/spaces/~bigbes/rfcs/docs/specs/0007-storage.md?title=Storage&message=add+it",
		strings.NewReader("the document"))
	req.Header.Set("If-Match", "1f0c1d1a")
	req.Header.Set("Authorization", "Bearer "+token)
	req.Header.Set(authn.HeaderAgent, "claude-code/spec-writer")
	req.Header.Set(authn.HeaderAgentSession, "8fb9c9a4-b078-4af1-89eb-d97c522f9921")
	rec := httptest.NewRecorder()
	f.handler.ServeHTTP(rec, req)
	return rec
}

// ---------------------------------------------------------------------------

// The credential every agent on the instance is configured with today still
// reaches the write plane, with the tokens.sr.ht plane wired in front of it.
func TestPutWithTheLocalAgentTokenStillWorks(t *testing.T) {
	f := newPlaneFixture(t, http.StatusNoContent, false)
	f.local.add("live-token", "laptop")

	rec := f.put("live-token")
	require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body)

	p := f.writer.got.Principal
	assert.True(t, p.IsAgent())
	assert.Equal(t, authn.PlaneLocal, p.Plane)
	assert.Equal(t, "laptop", p.TokenName)
	assert.Equal(t, "claude-code/spec-writer", p.Agent)
	assert.NoError(t, p.Authorize(authn.ActionPropose),
		"the local plane carries no grants and is refused none")
}

// A tokens.sr.ht working token reaches the same route, and arrives carrying the
// grants service.Propose will check.
func TestPutWithAnInstanceToken(t *testing.T) {
	f := newPlaneFixture(t, http.StatusNoContent, false)

	rec := f.put(liveToken("spec:read spec:propose"))
	require.Equal(t, http.StatusCreated, rec.Code, "body: %s", rec.Body)

	p := f.writer.got.Principal
	assert.True(t, p.IsAgent())
	assert.Equal(t, authn.PlaneInstance, p.Plane)
	assert.Equal(t, "bigbes", p.Owner, "the agent still acts for the instance owner")
	assert.Equal(t, 1, p.UserID)
	assert.NoError(t, p.Authorize(authn.ActionPropose))
	assert.Zero(t, f.local.calls, "an accepted instance token must not reach the local store")
}

// A narrow token still authenticates here — the resolver runs before the router
// and knows no action — and is refused by service.Propose, which is where the
// action is known and where the rule is spelled once for both write surfaces.
func TestPutWithAnInstanceTokenMissingTheProposeGrant(t *testing.T) {
	f := newPlaneFixture(t, http.StatusNoContent, false)
	f.writer.err = service.ErrForbidden

	rec := f.put(liveToken("spec:read"))
	assert.Equal(t, http.StatusForbidden, rec.Code)
	assert.ErrorIs(t, f.writer.got.Principal.Authorize(authn.ActionPropose), authn.ErrMissingGrant)
}

// A revoked instance token is 401 at the door and never gets a second chance at
// the old one — the same secret is registered locally, so a fall-through would
// visibly succeed with a 201.
func TestPutWithARevokedInstanceTokenIs401AndDoesNotFallThrough(t *testing.T) {
	f := newPlaneFixture(t, http.StatusNotFound, false)
	tok := liveToken("spec:propose id:42")
	f.local.add(tok, "shadow")

	rec := f.put(tok)
	assert.Equal(t, http.StatusUnauthorized, rec.Code)
	assert.Zero(t, f.local.calls, "a revoked instance token must not reach the local store")
	assert.Empty(t, f.writer.got.Writes, "and must not reach the write plane")
}

// An unreachable tokens.sr.ht is 503, never 401 and never a downgrade to the
// legacy plane: refusing every live instance token because a daemon that is
// deliberately off the hot path is restarting is the outcome the 503 exists to
// prevent.
func TestPutWithAnUnreachableDaemonIs503(t *testing.T) {
	f := newPlaneFixture(t, http.StatusNoContent, true)
	tok := liveToken("spec:propose id:42")
	f.local.add(tok, "shadow")

	rec := f.put(tok)
	assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
	assert.Zero(t, f.local.calls)
	assert.Empty(t, f.writer.got.Writes)
}

// A token from another issuer — a meta.sr.ht PAT — is not this plane's to
// refuse, so it falls through to the local store, which does not know it.
func TestPutWithAForeignTokenFallsThroughAndIsRefusedLocally(t *testing.T) {
	f := newPlaneFixture(t, http.StatusNoContent, false)
	pat := &auth.BearerToken{
		Version:  auth.TokenVersion,
		Expires:  auth.ToTimestamp(time.Now().Add(time.Hour)),
		Grants:   "git.sr.ht/OBJECTS:RW",
		ClientID: "meta.sr.ht",
		Username: "bigbes",
	}

	rec := f.put(pat.Encode())
	assert.Equal(t, http.StatusUnauthorized, rec.Code)
	assert.Equal(t, 1, f.local.calls, "the local plane is what refuses a foreign token")
}

A authn/bearer.go => authn/bearer.go +198 -0
@@ 0,0 1,198 @@
package authn

import (
	"context"
	"errors"
	"fmt"
	"net/http"
	"strconv"
	"strings"

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

// The grant vocabulary spec.sr.ht declares for tokens.sr.ht working tokens.
//
// The daemon that mints them does not know these strings and must not: the
// tokens.sr.ht spec gives the vocabulary to the services, so that adding an
// action to spec.sr.ht is a change to spec.sr.ht. An unknown grant simply
// admits nobody. They are constants rather than literals at the check because a
// grant is compared byte for byte — a typo in one of the two places it is
// spelled is a silent widening or a silent refusal, and neither shows up until
// it matters.
const (
	// ActionPropose is what an instance token must carry to write: open a
	// proposal or add documents to one.
	ActionPropose = "spec:propose"

	// ActionRead is what an instance token must carry to read content through
	// any of the read surfaces (the web UI, /query, MCP).
	ActionRead = "spec:read"
)

// BearerValidator is the sliver of sr-ht-ecore's bearer.Validator this package
// needs: who a presented working token belongs to, what it permits, and whether
// it is still live.
//
// Inspect and not Validate, because the resolver runs in middleware upstream of
// the router and so does not know which action is being attempted. The grant
// check happens where the action is known — service.Propose for the write
// plane, the read gates for the read plane — through Principal.Authorize.
//
// It is an interface rather than a *bearer.Validator so that this package stays
// testable without a tokens.sr.ht to talk to, exactly as TokenStore keeps it
// testable without a Postgres.
type BearerValidator interface {
	Inspect(ctx context.Context, presented string) (*bearer.Token, error)
}

// InstanceUser is the local "user" row that the owner of an instance token
// resolves to. It is the whole of what this package needs from that row: the id
// other layers key user-scoped state by, and the name it was found under.
type InstanceUser struct {
	ID       int
	Username string
}

// UserLookup resolves the meta.sr.ht username an instance token names into this
// service's local user row — core-go's auth.LookupUser in production.
//
// It is declared here for the same reason TokenStore is: that function reads a
// database handle and a config out of the context and panics without either,
// which is service/'s business to supply and not something a package answering
// "who is making this request?" should carry. service/ wires the real one in;
// tests wire a map.
type UserLookup interface {
	LookupUser(ctx context.Context, username string) (InstanceUser, error)
}

// resolveInstanceToken runs the tokens.sr.ht plane against a presented bearer
// credential.
//
// The middle result says whether the caller should fall back to spec's own
// agent-token plane. Exactly two refusals fall through, and which two is the
// only interesting decision in this function:
//
//   - bearer.ErrInvalid — the string did not decode as a token this instance
//     sealed. spec's local token is 32 random bytes in base64, which is
//     precisely what that looks like.
//   - bearer.ErrNotOurs — a well-formed token from another issuer (a meta.sr.ht
//     PAT). spec accepts no such credential, but it is not this plane's to
//     refuse, and falling through costs one hash lookup that will miss.
//
// spec's local token carries no prefix to discriminate on — unlike bench's and
// cover's — so there is no shape test that could route a request to the right
// plane up front. Trying the instance plane first and falling back on those two
// sentinels is what replaces it.
//
// Every other refusal is terminal and must never reach the old door:
//
//   - bearer.ErrRevoked — the credential was withdrawn. Letting a revoked
//     instance token be re-tried as a local one would answer "unknown token" for
//     a token an operator deliberately killed, and would mean revocation has a
//     second door to be checked at.
//   - bearer.ErrForbidden — cannot arise from Inspect, which is given no action,
//     but is terminal for the same reason: the credential is good.
//   - bearer.ErrUnavailable — tokens.sr.ht could not be asked. Degrading to the
//     legacy plane when the daemon is unreachable is exactly the silent
//     downgrade the 503 of StatusFor exists to prevent.
func (rs *Resolver) resolveInstanceToken(
	ctx context.Context, r *http.Request, presented string,
) (Principal, bool, error) {
	tok, err := rs.bearer.Inspect(ctx, presented)
	switch {
	case err == nil:
		// fall through
	case errors.Is(err, bearer.ErrInvalid), errors.Is(err, bearer.ErrNotOurs):
		return Anonymous(), true, nil
	default:
		return Anonymous(), false, fmt.Errorf("authn: instance token: %w", err)
	}

	// The token names a meta.sr.ht account, and spec.sr.ht has exactly one that
	// means anything. This is the same rule the cookie plane already applies —
	// a real user who is not the instance owner reads as nobody — and applying
	// it here keeps every consumer of Principal.Owner honest: the provenance
	// committer, the refs rule's principal kind and coreauth's AuthContext all
	// assume the human an agent acts for is the instance owner, and a foreign
	// name would make each of them quietly wrong in a different way.
	//
	// It is a refusal rather than a downgrade to anonymous because a presented
	// credential that fails must fail at the door: the asymmetry this package's
	// doc comment draws between cookies and bearer tokens.
	username := strings.TrimPrefix(tok.Username, "~")
	if username != rs.owner {
		return Anonymous(), false, fmt.Errorf(
			"%w: the token belongs to ~%s, and this instance answers only to ~%s",
			ErrNotInstanceOwner, username, rs.owner)
	}

	// The owner is resolved to a local row even though single-user spec could
	// infer it: the row id is what user-scoped state keys off, and looking it up
	// here is what makes the instance plane's identity a fact about this
	// database rather than a name copied out of a signed blob.
	user, err := rs.users.LookupUser(ctx, username)
	if err != nil {
		// Unclassified, therefore transient, therefore 503: a database that
		// cannot answer must never read as a bad credential.
		return Anonymous(), false, fmt.Errorf("authn: resolve instance token owner ~%s: %w", username, err)
	}

	return Principal{
		Kind:      KindAgent,
		Owner:     rs.owner,
		Agent:     strings.TrimSpace(r.Header.Get(HeaderAgent)),
		Session:   strings.TrimSpace(r.Header.Get(HeaderAgentSession)),
		TokenName: instanceTokenLabel(tok),
		Plane:     PlaneInstance,
		Grants:    tok.Grants,
		UserID:    user.ID,
	}, false, nil
}

// instanceTokenLabel names the credential in a log line. A registered token has
// a row at tokens.sr.ht an operator can find and revoke, so its id is the useful
// thing to print; a stateless one was never written down, and saying so is more
// honest than printing "0".
func instanceTokenLabel(tok *bearer.Token) string {
	if tok.Registered() {
		return "tokens.sr.ht #" + strconv.Itoa(tok.TokenID)
	}
	return "tokens.sr.ht (stateless)"
}

// StatusFor maps an error out of Resolve — or out of a later Authorize — onto
// the status the surface must answer with. It is one function so that the three
// surfaces cannot each invent their own table.
//
// The mapping, and the one line of it that has to be defended:
//
//   - bearer.ErrUnavailable is 503 and never 401. Reading "I could not reach
//     tokens.sr.ht" as "your token is revoked" would refuse every live instance
//     token on the instance for as long as a daemon that is deliberately off the
//     hot path is restarting, and would tell a thousand clients their
//     credentials are bad when the truth is that one service is down. 503 says
//     the true thing and keeps the operator's attention where the fault is.
//   - ErrMissingGrant and ErrNotInstanceOwner are 403: the credential verifies
//     and the holder is who they say they are, so retrying is pointless and what
//     they need is a wider grant, not another login.
//   - Everything permanent about the credential itself — unknown, revoked,
//     malformed, on either plane — is 401.
//   - Everything else is transient by definition and answers 503, which is the
//     fail-closed direction: a backend outage never reads as a valid credential.
func StatusFor(err error) int {
	switch {
	case err == nil:
		return http.StatusOK
	case errors.Is(err, bearer.ErrUnavailable):
		return http.StatusServiceUnavailable
	case errors.Is(err, bearer.ErrForbidden),
		errors.Is(err, ErrMissingGrant),
		errors.Is(err, ErrNotInstanceOwner):
		return http.StatusForbidden
	case IsAuthFailure(err):
		return http.StatusUnauthorized
	default:
		return http.StatusServiceUnavailable
	}
}

A authn/bearer_test.go => authn/bearer_test.go +447 -0
@@ 0,0 1,447 @@
package authn

import (
	"context"
	"net/http"
	"net/http/httptest"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"sourcecraft.dev/bigbes/sr-ht-core/auth"
	"sourcecraft.dev/bigbes/sr-ht-ecore/bearer"
	"sourcecraft.dev/bigbes/sr-ht-ecore/grants"
)

// These tests run against the real sr-ht-ecore validator over real signed
// tokens, not a stub of it. The signature, the expiry, the ClientID
// discrimination and the revocation round trip are the whole of what this plane
// is, and a fake Inspect would assert only that the wiring calls something.
// crypto.InitCrypto has already run in TestMain, which is what makes minting one
// here possible at all.

// mustGrants parses a grant string or fails the test.
func mustGrants(t *testing.T, s string) grants.Grants {
	t.Helper()
	g, err := grants.Parse(s)
	require.NoError(t, err, "parse grants %q", s)
	return g
}

// seal mints a signed bearer token the way tokens.sr.ht does — or, with another
// clientID, the way meta.sr.ht does its PATs.
func seal(username, clientID, grantString string, expires time.Time) string {
	bt := &auth.BearerToken{
		Version:  auth.TokenVersion,
		Expires:  auth.ToTimestamp(expires),
		Grants:   grantString,
		ClientID: clientID,
		Username: username,
	}
	return bt.Encode()
}

// instanceToken is a live working token from this instance's tokens.sr.ht.
func instanceToken(grantString string) string {
	return seal("bigbes", bearer.TokensClientID, grantString, time.Now().Add(time.Hour))
}

// fakeDaemon stands in for tokens.sr.ht's revocation endpoint: 204 is live, 404
// is revoked, and anything else is the absence of an answer. It counts requests
// so a test can assert whether the daemon was asked at all.
type fakeDaemon struct {
	server *httptest.Server
	status int
	hits   int
}

func newFakeDaemon(t *testing.T, status int) *fakeDaemon {
	t.Helper()
	d := &fakeDaemon{status: status}
	d.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		d.hits++
		w.WriteHeader(d.status)
	}))
	t.Cleanup(d.server.Close)
	return d
}

// unreachableOrigin is a URL nothing answers on: a fake daemon that has already
// been shut down, which is what a restarting tokens.sr.ht looks like from here.
func unreachableOrigin(t *testing.T) string {
	t.Helper()
	srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
	origin := srv.URL
	srv.Close()
	return origin
}

// validatorFor builds the real ecore validator pointed at origin.
func validatorFor(t *testing.T, origin string) *bearer.Validator {
	t.Helper()
	v, err := bearer.New(bearer.Options{
		Origin:   origin,
		ClientID: ConfigSection,
		NodeID:   "spec-test",
	})
	require.NoError(t, err)
	return v
}

// stubUsers resolves usernames to fixed rows, and can be told to fail so the
// transient path is exercised.
type stubUsers struct {
	rows  map[string]InstanceUser
	err   error
	calls int
}

func newStubUsers() *stubUsers {
	return &stubUsers{rows: map[string]InstanceUser{"bigbes": {ID: 1, Username: "bigbes"}}}
}

func (s *stubUsers) LookupUser(_ context.Context, username string) (InstanceUser, error) {
	s.calls++
	if s.err != nil {
		return InstanceUser{}, s.err
	}
	row, ok := s.rows[username]
	if !ok {
		return InstanceUser{}, errNoSuchUser(username)
	}
	return row, nil
}

func errNoSuchUser(username string) error {
	return &noSuchUserError{username: username}
}

type noSuchUserError struct{ username string }

func (e *noSuchUserError) Error() string { return "no such user " + e.username }

// planeFixture wires a resolver with both planes: the real validator against
// daemonStatus, and the local agent-token store the old credential lives in.
type planeFixture struct {
	rs     *Resolver
	store  *stubStore
	users  *stubUsers
	daemon *fakeDaemon
}

func newPlaneFixture(t *testing.T, daemonStatus int) *planeFixture {
	t.Helper()
	d := newFakeDaemon(t, daemonStatus)
	f := &planeFixture{store: newStubStore(), users: newStubUsers(), daemon: d}
	rs, err := NewResolver("bigbes", f.store, WithInstancePlane(validatorFor(t, d.server.URL), f.users))
	require.NoError(t, err)
	f.rs = rs
	return f
}

// bearerRequest builds a request presenting token with full provenance headers.
func bearerRequest(token string) *http.Request {
	return request("", map[string]string{
		"Authorization":    "Bearer " + token,
		HeaderAgent:        "claude-code/spec-writer",
		HeaderAgentSession: "8fb9c9a4-b078-4af1-89eb-d97c522f9921",
	})
}

// The most important test in this change: the credential every agent on the
// instance is configured with today still authenticates, still resolves to an
// agent, and is still authorized to propose — with the instance plane wired in
// front of it.
func TestResolve_LocalAgentTokenStillWorksWithTheInstancePlaneWired(t *testing.T) {
	f := newPlaneFixture(t, http.StatusNoContent)
	f.store.add("live-token", "laptop")

	p, err := f.rs.Resolve(context.Background(), bearerRequest("live-token"))
	require.NoError(t, err)

	assert.True(t, p.IsAgent(), "the old agent token must still resolve to an agent")
	assert.Equal(t, PlaneLocal, p.Plane)
	assert.Equal(t, "bigbes", p.Owner)
	assert.Equal(t, "laptop", p.TokenName)
	assert.Equal(t, "claude-code/spec-writer", p.Agent)
	assert.Equal(t, "8fb9c9a4-b078-4af1-89eb-d97c522f9921", p.Session)

	// It carries no grants and is refused nothing: the local plane's boundary is
	// the refs rule, and this change does not move it.
	assert.NoError(t, p.Authorize(ActionPropose))
	assert.NoError(t, p.Authorize(ActionRead))

	// It never became a question for tokens.sr.ht, and never could: the daemon
	// is only asked about a token that decoded as one of its own.
	assert.Zero(t, f.daemon.hits, "the local plane must not talk to tokens.sr.ht")
	assert.Zero(t, f.users.calls, "the local plane has no owner to resolve")
}

func TestResolve_InstanceTokenAccepted(t *testing.T) {
	f := newPlaneFixture(t, http.StatusNoContent)
	tok := instanceToken("spec:propose spec:read")

	p, err := f.rs.Resolve(context.Background(), bearerRequest(tok))
	require.NoError(t, err)

	assert.True(t, p.IsAgent())
	assert.Equal(t, PlaneInstance, p.Plane)
	assert.Equal(t, "bigbes", p.Owner, "the agent still acts for the instance owner")
	assert.Equal(t, 1, p.UserID, "the token's owner was resolved to a local row")
	assert.Equal(t, "tokens.sr.ht (stateless)", p.TokenName)
	assert.NoError(t, p.Authorize(ActionPropose))
	assert.NoError(t, p.Authorize(ActionRead))

	// Provenance is read off the headers on this plane exactly as on the other.
	assert.Equal(t, "claude-code/spec-writer", p.Agent)
	assert.Equal(t, "8fb9c9a4-b078-4af1-89eb-d97c522f9921", p.Session)

	// A stateless token has no row, so step 4 costs nothing.
	assert.Zero(t, f.daemon.hits)
	assert.Zero(t, f.store.calls, "an accepted instance token must not reach the local store")
}

func TestResolve_InstanceTokenMissingAGrantStillAuthenticates(t *testing.T) {
	f := newPlaneFixture(t, http.StatusNoContent)

	// The resolver knows no action, so a narrow token authenticates here and is
	// refused later, where the action is known.
	p, err := f.rs.Resolve(context.Background(), bearerRequest(instanceToken("spec:read")))
	require.NoError(t, err)
	assert.True(t, p.IsAgent())
	assert.NoError(t, p.Authorize(ActionRead))
	assert.ErrorIs(t, p.Authorize(ActionPropose), ErrMissingGrant)
	assert.Equal(t, http.StatusForbidden, StatusFor(p.Authorize(ActionPropose)))
}

// A revoked instance token must be refused outright and must not get a second
// chance at the old door. The same string is registered as a local agent token
// so that a fall-through would visibly succeed.
func TestResolve_RevokedInstanceTokenDoesNotFallThroughToTheLocalPlane(t *testing.T) {
	f := newPlaneFixture(t, http.StatusNotFound)
	tok := instanceToken("spec:propose id:42")
	f.store.add(tok, "shadow")

	p, err := f.rs.Resolve(context.Background(), bearerRequest(tok))
	require.Error(t, err)
	assert.ErrorIs(t, err, bearer.ErrRevoked)
	assert.True(t, p.IsAnonymous())
	assert.Equal(t, http.StatusUnauthorized, StatusFor(err))
	assert.True(t, IsAuthFailure(err), "a revoked token is a permanent credential failure")
	assert.Equal(t, 1, f.daemon.hits, "a registered token is checked against the daemon")
	assert.Zero(t, f.store.calls, "a revoked instance token must never reach the local store")

	_, code, reached := runMiddleware(t, f.rs, bearerRequest(tok))
	assert.False(t, reached)
	assert.Equal(t, http.StatusUnauthorized, code)
}

// An unreachable tokens.sr.ht is 503 and never 401, and never a silent
// downgrade to the legacy plane. Reading "I could not ask" as "revoked" would
// refuse every live instance token while a daemon that is deliberately off the
// hot path restarts.
func TestResolve_UnreachableDaemonIs503AndDoesNotFallThrough(t *testing.T) {
	store := newStubStore()
	users := newStubUsers()
	rs, err := NewResolver("bigbes", store,
		WithInstancePlane(validatorFor(t, unreachableOrigin(t)), users))
	require.NoError(t, err)

	tok := instanceToken("spec:propose id:42")
	store.add(tok, "shadow")

	p, err := rs.Resolve(context.Background(), bearerRequest(tok))
	require.Error(t, err)
	assert.ErrorIs(t, err, bearer.ErrUnavailable)
	assert.True(t, p.IsAnonymous())
	assert.Equal(t, http.StatusServiceUnavailable, StatusFor(err))
	assert.False(t, IsAuthFailure(err), "an unreachable daemon is not a bad credential")
	assert.Zero(t, store.calls, "an unanswerable revocation must not fall back to the local store")

	_, code, reached := runMiddleware(t, rs, bearerRequest(tok))
	assert.False(t, reached)
	assert.Equal(t, http.StatusServiceUnavailable, code)
}

// The two refusals that do fall through. spec's local token has no prefix to
// discriminate on, so "did not decode as one of ours" is exactly what it looks
// like — which is why the order is instance-plane-first with a fallback rather
// than a shape test.
func TestResolve_ForeignAndUndecodableTokensFallThroughToTheLocalPlane(t *testing.T) {
	metaPAT := seal("bigbes", "meta.sr.ht", "git.sr.ht/OBJECTS:RW", time.Now().Add(time.Hour))

	for name, presented := range map[string]string{
		"opaque local secret": "live-token",
		"expired instance token": seal("bigbes", bearer.TokensClientID, "spec:propose",
			time.Now().Add(-time.Hour)),
		"meta.sr.ht PAT": metaPAT,
	} {
		t.Run(name, func(t *testing.T) {
			t.Run("registered locally", func(t *testing.T) {
				f := newPlaneFixture(t, http.StatusNoContent)
				f.store.add(presented, "laptop")

				p, err := f.rs.Resolve(context.Background(), bearerRequest(presented))
				require.NoError(t, err)
				assert.True(t, p.IsAgent())
				assert.Equal(t, PlaneLocal, p.Plane)
				assert.Equal(t, 1, f.store.calls)
			})

			t.Run("not registered", func(t *testing.T) {
				f := newPlaneFixture(t, http.StatusNoContent)

				_, err := f.rs.Resolve(context.Background(), bearerRequest(presented))
				assert.ErrorIs(t, err, ErrUnknownToken,
					"the local plane must be the one that refuses it")
				assert.Equal(t, http.StatusUnauthorized, StatusFor(err))
			})
		})
	}
}

// spec.sr.ht answers to one human. A working token belonging to somebody else is
// refused rather than admitted as a second identity: Principal.Owner is read by
// the provenance committer, the refs rule and the coreauth bridge, all of which
// are written for the instance owner.
func TestResolve_InstanceTokenOfAnotherOwnerIsRefused(t *testing.T) {
	f := newPlaneFixture(t, http.StatusNoContent)
	tok := seal("someone", bearer.TokensClientID, "spec:propose", time.Now().Add(time.Hour))

	p, err := f.rs.Resolve(context.Background(), bearerRequest(tok))
	require.Error(t, err)
	assert.ErrorIs(t, err, ErrNotInstanceOwner)
	assert.True(t, p.IsAnonymous())
	assert.Equal(t, http.StatusForbidden, StatusFor(err))
	assert.Zero(t, f.users.calls, "a foreign owner is refused before any lookup")
	assert.Zero(t, f.store.calls, "and never falls through to the local plane")

	_, code, reached := runMiddleware(t, f.rs, bearerRequest(tok))
	assert.False(t, reached)
	assert.Equal(t, http.StatusForbidden, code)
}

// A user lookup that cannot answer is transient: 503, never a bad credential.
func TestResolve_UserLookupFailureIs503(t *testing.T) {
	f := newPlaneFixture(t, http.StatusNoContent)
	f.users.err = errNoSuchUser("connection refused")

	_, err := f.rs.Resolve(context.Background(), bearerRequest(instanceToken("spec:read")))
	require.Error(t, err)
	assert.False(t, IsAuthFailure(err))
	assert.Equal(t, http.StatusServiceUnavailable, StatusFor(err))
}

// An instance with no [tokens.sr.ht] section builds no instance plane, starts,
// and serves its local agent token exactly as before.
func TestResolve_WithoutTheInstancePlaneOnlyTheLocalOneExists(t *testing.T) {
	store := newStubStore()
	rs, err := NewResolver("bigbes", store)
	require.NoError(t, err)
	assert.False(t, rs.HasInstancePlane())

	store.add("live-token", "laptop")
	p, err := rs.Resolve(context.Background(), bearerRequest("live-token"))
	require.NoError(t, err)
	assert.True(t, p.IsAgent())
	assert.Equal(t, PlaneLocal, p.Plane)

	// A perfectly good instance token is just an unknown secret here — there is
	// nothing on this instance that could validate it.
	_, err = rs.Resolve(context.Background(), bearerRequest(instanceToken("spec:propose")))
	assert.ErrorIs(t, err, ErrUnknownToken)
}

func TestWithInstancePlane_RejectsHalfWiring(t *testing.T) {
	v := validatorFor(t, "https://tokens.example")
	_, err := NewResolver("bigbes", newStubStore(), WithInstancePlane(nil, newStubUsers()))
	assert.Error(t, err, "a plane with no validator must be refused")
	_, err = NewResolver("bigbes", newStubStore(), WithInstancePlane(v, nil))
	assert.Error(t, err, "a plane with no user lookup must be refused")
}

// Provenance is mandatory on every agent write, on both planes. Grants do not
// replace it and do not excuse it.
func TestAgentWriteFor_ProvenanceRequiredOnBothPlanes(t *testing.T) {
	base := "1f0c1d1a1e2b3c4d5e6f708192a3b4c5d6e7f809"
	for _, plane := range []Plane{PlaneLocal, PlaneInstance} {
		t.Run(string(plane), func(t *testing.T) {
			complete := Principal{
				Kind: KindAgent, Owner: "bigbes", Agent: "a", Session: "s-1",
				Plane: plane, Grants: mustGrants(t, "*"),
			}
			_, err := complete.AgentWriteFor(base)
			require.NoError(t, err)

			noSession := complete
			noSession.Session = ""
			_, err = noSession.AgentWriteFor(base)
			assert.ErrorIs(t, err, ErrMissingProvenance)

			noAgent := complete
			noAgent.Agent = ""
			_, err = noAgent.AgentWriteFor(base)
			assert.ErrorIs(t, err, ErrMissingProvenance)
		})
	}
}

func TestAuthorize(t *testing.T) {
	t.Run("off the instance plane every action passes", func(t *testing.T) {
		for _, p := range []Principal{
			{Kind: KindOwner, Owner: "bigbes"},
			{Kind: KindAgent, Owner: "bigbes", Plane: PlaneLocal},
			{Kind: KindAgent, Owner: "bigbes"}, // an unset plane is the local one
		} {
			assert.NoError(t, p.Authorize(ActionPropose))
			assert.NoError(t, p.Authorize(ActionRead))
		}
	})

	t.Run("on the instance plane the grant set decides", func(t *testing.T) {
		narrow := Principal{
			Kind: KindAgent, Owner: "bigbes", Plane: PlaneInstance,
			Grants: mustGrants(t, "spec:read"),
		}
		assert.NoError(t, narrow.Authorize(ActionRead))
		assert.ErrorIs(t, narrow.Authorize(ActionPropose), ErrMissingGrant)

		universal := Principal{
			Kind: KindAgent, Owner: "bigbes", Plane: PlaneInstance,
			Grants: mustGrants(t, "*"),
		}
		assert.NoError(t, universal.Authorize(ActionPropose))

		// The zero grant set admits nothing, which is why Authorize checks the
		// plane before the set: a principal that never went through the
		// resolver must not be silently universal.
		empty := Principal{Kind: KindAgent, Owner: "bigbes", Plane: PlaneInstance}
		assert.ErrorIs(t, empty.Authorize(ActionRead), ErrMissingGrant)
	})
}

func TestStatusFor(t *testing.T) {
	cases := []struct {
		name string
		err  error
		want int
	}{
		{"nil", nil, http.StatusOK},
		{"unreachable daemon", bearer.ErrUnavailable, http.StatusServiceUnavailable},
		{"missing grant", ErrMissingGrant, http.StatusForbidden},
		{"foreign owner", ErrNotInstanceOwner, http.StatusForbidden},
		{"bearer forbidden", bearer.ErrForbidden, http.StatusForbidden},
		{"revoked instance token", bearer.ErrRevoked, http.StatusUnauthorized},
		{"undecodable instance token", bearer.ErrInvalid, http.StatusUnauthorized},
		{"unknown local token", ErrUnknownToken, http.StatusUnauthorized},
		{"revoked local token", ErrRevokedToken, http.StatusUnauthorized},
		{"store outage", errNoSuchUser("postgres"), http.StatusServiceUnavailable},
	}
	for _, c := range cases {
		t.Run(c.name, func(t *testing.T) {
			assert.Equal(t, c.want, StatusFor(c.err))
		})
	}
}

M authn/doc.go => authn/doc.go +74 -11
@@ 6,16 6,47 @@
//
//   - 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.
//   - an agent, recognised by a bearer token.
//
// 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.
// and nothing finer. The boundary that bounds an agent's damage is the refs rule
// (agents may only write proposals/*), and that lives in gitx; nothing here
// replaces it.
//
// The two credentials are deliberately asymmetric:
// # Two agent credential planes
//
// An agent is recognised on either of two planes, and Principal.Plane says
// which:
//
//   - PlaneLocal — spec's own agent_token row: one instance-wide shared secret,
//     hashed at rest, with no owner, no expiry and no grants. This is v1's
//     credential and it keeps working exactly as it did.
//   - PlaneInstance — a tokens.sr.ht working token: signed by the instance,
//     expiring, owned by a meta.sr.ht account, and carrying a grant set
//     (ActionPropose, ActionRead). It is validated by sr-ht-ecore's bearer
//     package and is present only when the instance config has a
//     [tokens.sr.ht] section; where it does not, the plane is absent and the
//     local one is the only door, which is a supported configuration.
//
// The instance plane is tried first and falls back to the local one on exactly
// two refusals — see Resolver.resolveInstanceToken, where the reasoning lives.
//
// The two planes are not interchangeable, and the difference this package has to
// carry is that only one of them has an owner. The local token is a secret with
// no user behind it; an instance token names one. Principal.Owner therefore
// keeps meaning "the human this agent acts for", which on this single-owner
// instance is always [sr.ht] owner-name — a token belonging to anybody else is
// refused rather than admitted as a second identity, because every consumer of
// that field (the provenance committer, the refs rule's principal kind, the
// coreauth AuthContext) is written for one human.
//
// Grants are orthogonal to the refs rule and to provenance, and replace neither.
// A grant says what an instance token was minted for; the refs rule still says
// where an agent may point a ref, and provenance is still mandatory on every
// agent write, on both planes.
//
// The cookie and the bearer planes are deliberately asymmetric:
//
//   - A cookie that is missing, forged, expired or unreadable yields an
//     anonymous principal and never an error. Browsing must keep working.


@@ 40,12 71,20 @@
// 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.
// This package owns no storage and opens no connections. The agent_token table
// lives in db/, injected through the TokenStore interface declared here; the
// "user" row an instance token's owner resolves to is reached through
// UserLookup, and the tokens.sr.ht validator through BearerValidator. authn
// never imports db and never calls core-go's auth.LookupUser itself, so the
// dependency arrow keeps pointing downward and the whole package stays testable
// with no Postgres and no daemon to talk to.
package authn

import "errors"
import (
	"errors"

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

// Sentinel errors. Callers compare with errors.Is. The split that matters is
// permanent (the credential is bad — 401/403) versus transient (the store could


@@ 89,6 128,22 @@ var (
	// 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")

	// ErrNotInstanceOwner marks a valid tokens.sr.ht working token whose owner is
	// somebody other than the instance owner. 403: the credential verifies and
	// the holder is who they say they are, there is simply nothing on this
	// single-owner instance to grant them. Deliberately not an IsAuthFailure —
	// presenting it again will not help and neither will logging in.
	ErrNotInstanceOwner = errors.New("token owner is not the instance owner")

	// ErrMissingGrant marks an instance token that authenticated fine but does
	// not carry the action being attempted. 403, for the reason
	// bearer.ErrForbidden is: what the holder needs is a wider grant, not another
	// login.
	//
	// It is raised by Principal.Authorize, at the layer that knows the action —
	// never by the resolver, which runs before the router and so knows none.
	ErrMissingGrant = errors.New("token does not grant this action")
)

// IsAuthFailure reports whether err is a permanent credential failure — the


@@ 96,9 151,17 @@ var (
// 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.
//
// The instance plane's two permanent refusals are in the set for the same
// reason the local plane's are: a signature that does not verify and a token
// tokens.sr.ht has withdrawn are both "this credential is bad", whichever door
// it was presented at. bearer.ErrUnavailable is pointedly absent — see
// StatusFor, which is what surfaces should map with.
func IsAuthFailure(err error) bool {
	return errors.Is(err, ErrNoToken) ||
		errors.Is(err, ErrUnknownToken) ||
		errors.Is(err, ErrInvalidToken) ||
		errors.Is(err, ErrRevokedToken)
		errors.Is(err, ErrRevokedToken) ||
		errors.Is(err, bearer.ErrInvalid) ||
		errors.Is(err, bearer.ErrRevoked)
}

M authn/principal.go => authn/principal.go +87 -8
@@ 3,6 3,8 @@ package authn
import (
	"context"
	"fmt"

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

// Kind enumerates the principals spec.sr.ht distinguishes. There are three


@@ 23,19 25,45 @@ const (
	// 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 is a bot holding an agent bearer token, on either plane. It may
	// propose and it may read; the refs rule in gitx is what stops it touching
	// the approved branch.
	KindAgent Kind = "agent"
)

// Plane names the credential plane an agent authenticated on.
//
// It exists because the two are not interchangeable and a check that reads
// Grants has to know whether there were any to read: only the instance plane
// carries a grant set, and only it names an owner. Empty for every principal
// that is not an agent.
type Plane string

const (
	// PlaneLocal is spec's own agent_token row: one instance-wide shared secret
	// with no owner, no expiry and no grants. Its whole boundary is the refs
	// rule, which is why v1 shipped it with mandatory provenance instead of
	// scopes.
	PlaneLocal Plane = "local"

	// PlaneInstance is a tokens.sr.ht working token: signed, expiring, owned by
	// a meta.sr.ht account, and carrying the grant set Authorize checks.
	PlaneInstance Plane = "instance"
)

// 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.
// agent — the two provenance fields that every agent write must carry, plus
// which credential plane it came in on and what that credential permits.
//
// It is not comparable with ==: Grants holds a set. Compare the fields that
// matter, or the String() rendering. The set itself is immutable once parsed —
// grants.Grants has no mutating method — so copies sharing it is not the
// aliasing this type's value semantics are guarding against.
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


@@ 56,15 84,31 @@ type Principal struct {
	// 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 names the credential that authenticated this request: the
	// agent_token row's operator-facing label on the local plane, and the
	// tokens.sr.ht row id (or "stateless") on the instance one. KindAgent only,
	// diagnostics only — it grants nothing on either plane.
	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

	// Plane is which credential plane authenticated an agent. Empty for every
	// other kind. Authorize reads it to decide whether Grants means anything.
	Plane Plane

	// Grants is what the instance token this request carried permits, parsed.
	// PlaneInstance only; the zero value on every other plane, which grants
	// nothing and is why Authorize checks Plane before it checks the set.
	Grants grants.Grants

	// UserID is the id of the local "user" row the instance token's owner
	// resolved to. PlaneInstance only, and zero on the local plane — which has
	// no owner at all, the asymmetry between the two planes that everything
	// reading this field has to respect.
	UserID int
}

// Anonymous returns the principal for an unauthenticated request.


@@ 91,6 135,34 @@ func (p Principal) IsAgent() bool { return p.Kind == KindAgent }
// surfaces with two spellings of it is how a corpus leaks.
func (p Principal) CanRead() bool { return p.IsOwner() || p.IsAgent() }

// Authorize reports whether the credential behind this principal covers action
// — one of the ActionPropose / ActionRead constants.
//
// It is a grant check and nothing else. It says nothing about who the principal
// is, so every caller must already have made the identity decision (IsAgent for
// the write plane, CanRead for the read plane); calling this alone would
// "authorize" an anonymous request, because an anonymous request carries no
// instance token and so has no grant to be missing. The two questions are
// separate on purpose: the resolver answers identity in middleware, upstream of
// the router, and only the layer that knows the action can ask this one.
//
// Every plane but PlaneInstance passes, and that is the compatibility contract
// of this whole change. The local agent token has no grants to check and will
// not grow any: it is one instance-wide shared secret whose boundary is the refs
// rule in gitx, and inventing a grant vocabulary for it now would refuse an
// agent a permission its operator was never asked to give. The owner's cookie
// passes for the same reason — grants describe machine credentials, not people.
func (p Principal) Authorize(action string) error {
	if p.Plane != PlaneInstance {
		return nil
	}
	if !p.Grants.Has(action) {
		return fmt.Errorf("%w: the instance token grants %q, which does not cover %q",
			ErrMissingGrant, p.Grants.String(), action)
	}
	return nil
}

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


@@ 107,7 179,14 @@ func (p Principal) String() string {
		if session == "" {
			session = "(no session)"
		}
		return fmt.Sprintf("agent %s session %s for ~%s", agent, session, p.Owner)
		line := fmt.Sprintf("agent %s session %s for ~%s", agent, session, p.Owner)
		// Only the instance plane is annotated, so the line a local agent logs
		// today reads the same tomorrow — and so that the annotation, when it
		// does appear, means something rather than being noise on every line.
		if p.Plane == PlaneInstance {
			line += " (tokens.sr.ht: " + p.Grants.String() + ")"
		}
		return line
	default:
		if p.CookieUser != "" {
			return "anonymous (cookie user ~" + p.CookieUser + ")"

M authn/resolver.go => authn/resolver.go +96 -16
@@ 11,11 11,48 @@ import (
)

// 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.
// username — the one name a cookie has to match to carry authority — the
// TokenStore local agent tokens are checked against, and, when the instance is
// configured for it, the tokens.sr.ht plane.
type Resolver struct {
	owner string
	store TokenStore

	// bearer and users are the instance plane, installed by WithInstancePlane.
	// Both are nil when the instance config has no [tokens.sr.ht] section, which
	// is a supported configuration and not a broken one: the plane is absent and
	// every bearer credential goes straight to the local store, exactly as it
	// did before this plane existed. Resolve tests bearer for presence, and
	// WithInstancePlane is what guarantees the two are wired together or not at
	// all.
	bearer BearerValidator
	users  UserLookup
}

// ResolverOption configures a Resolver at construction. Options rather than a
// second constructor because the instance plane is optional in production and
// not merely in tests: an instance without tokens.sr.ht must build the same
// resolver every other caller does.
type ResolverOption func(*Resolver) error

// WithInstancePlane wires the tokens.sr.ht bearer plane in: v validates a
// presented working token, users resolves its owner to a local row.
//
// Both are required together. A validator with no way to resolve an owner would
// authenticate a token and then have nothing to say about who presented it,
// which is the one thing the instance plane adds over the local one.
func WithInstancePlane(v BearerValidator, users UserLookup) ResolverOption {
	return func(rs *Resolver) error {
		if v == nil {
			return fmt.Errorf("authn: nil BearerValidator")
		}
		if users == nil {
			return fmt.Errorf("authn: nil UserLookup")
		}
		rs.bearer = v
		rs.users = users
		return nil
	}
}

// NewResolver builds a Resolver for the instance owner named in


@@ 25,7 62,11 @@ type Resolver struct {
// 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) {
//
// With no options the resolver knows only the local agent-token plane — what an
// instance with no [tokens.sr.ht] section gets, and what every caller got before
// that plane existed.
func NewResolver(owner string, store TokenStore, opts ...ResolverOption) (*Resolver, error) {
	owner = strings.TrimPrefix(owner, "~")
	if err := core.ValidateOwner(owner); err != nil {
		return nil, fmt.Errorf("authn: instance owner: %w", err)


@@ 33,9 74,20 @@ func NewResolver(owner string, store TokenStore) (*Resolver, error) {
	if store == nil {
		return nil, fmt.Errorf("authn: nil TokenStore")
	}
	return &Resolver{owner: owner, store: store}, nil
	rs := &Resolver{owner: owner, store: store}
	for _, opt := range opts {
		if err := opt(rs); err != nil {
			return nil, err
		}
	}
	return rs, nil
}

// HasInstancePlane reports whether this resolver tries tokens.sr.ht before the
// local agent-token store. Startup logging and tests only; never an
// authorization input.
func (rs *Resolver) HasInstancePlane() bool { return rs.bearer != nil }

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



@@ 47,6 99,11 @@ func (rs *Resolver) Owner() string { return rs.owner }
// it the approved branch. The two credentials are checked in that order and
// never merged.
//
// A presented bearer token is offered to the tokens.sr.ht plane first, when one
// is configured, and reaches the local agent-token store only if that plane
// says the string is not a token of the instance's. Which refusals mean that,
// and why the order is not the other way round, is in resolveInstanceToken.
//
// The error contract is asymmetric on purpose:
//
//   - No bearer token: never an error. The cookie decides between KindOwner and


@@ 59,6 116,12 @@ func (rs *Resolver) Owner() string { return rs.owner }
// 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 != "" {
		if rs.bearer != nil {
			p, fallBack, err := rs.resolveInstanceToken(ctx, r, presented)
			if !fallBack {
				return p, err
			}
		}
		tok, err := ResolveAgentToken(ctx, rs.store, presented)
		if err != nil {
			return Anonymous(), err


@@ 69,6 132,7 @@ func (rs *Resolver) Resolve(ctx context.Context, r *http.Request) (Principal, er
			Agent:     strings.TrimSpace(r.Header.Get(HeaderAgent)),
			Session:   strings.TrimSpace(r.Header.Get(HeaderAgentSession)),
			TokenName: tok.Name,
			Plane:     PlaneLocal,
		}, nil
	}



@@ 89,27 153,43 @@ func (rs *Resolver) Resolve(ctx context.Context, r *http.Request) (Principal, er
// 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.
// It rejects only a failed bearer token — 401 for a bad credential, 403 for a
// good one that this instance has nothing to grant, 503 for a backend that could
// not answer, per StatusFor. 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
				status := StatusFor(err)
				if status >= 500 {
					// Fail closed and loudly. The alternative — degrading to
					// anonymous — would turn a Postgres blip or an unreachable
					// tokens.sr.ht into agents silently losing their write
					// access.
					log.Printf("authn: resolving bearer credential: %v", err)
				}
				// 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)
				http.Error(w, resolveFailureMessage(status), status)
				return
			}
			next.ServeHTTP(w, r.WithContext(WithPrincipal(r.Context(), p)))
		})
	}
}

// resolveFailureMessage is what a refused caller is told. It is keyed on the
// status and not on the error, so that nothing about which plane refused, whose
// token it was, or whether a row exists leaks to a caller holding a credential
// this service did not accept.
func resolveFailureMessage(status int) string {
	switch status {
	case http.StatusUnauthorized:
		return "invalid agent token"
	case http.StatusForbidden:
		return "this token does not authorize requests to spec.sr.ht"
	default:
		return "authentication backend unavailable"
	}
}

M authn/resolver_test.go => authn/resolver_test.go +40 -12
@@ 5,6 5,7 @@ import (
	"errors"
	"net/http"
	"net/http/httptest"
	"reflect"
	"testing"
)



@@ 295,9 296,15 @@ func TestPrincipalFromContext_BareContextIsAnonymous(t *testing.T) {
	}
}

// Principal is not comparable with == — it carries a grant set — so the round
// trip is asserted with reflect.DeepEqual, which also covers the grants.
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 {
	want := Principal{
		Kind: KindAgent, Owner: "bigbes", Agent: "a", Session: "s",
		Plane: PlaneInstance, Grants: mustGrants(t, "spec:read spec:propose"), UserID: 3,
	}
	got := PrincipalFromContext(WithPrincipal(context.Background(), want))
	if !reflect.DeepEqual(got, want) {
		t.Fatalf("round trip = %+v, want %+v", got, want)
	}
}


@@ 315,16 322,37 @@ func TestPrincipal_ZeroValueIsAnonymous(t *testing.T) {
}

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 slice and not a map keyed by Principal: it carries a grant set and is
	// therefore not comparable.
	cases := []struct {
		p    Principal
		want string
	}{
		{Anonymous(), "anonymous"},
		{Principal{Kind: KindAnonymous, CookieUser: "someone"}, "anonymous (cookie user ~someone)"},
		{Principal{Kind: KindOwner, Owner: "bigbes"}, "owner ~bigbes"},
		{
			Principal{Kind: KindAgent, Owner: "bigbes", Agent: "claude-code/spec-writer", Session: "s-1"},
			"agent claude-code/spec-writer session s-1 for ~bigbes",
		},
		{Principal{Kind: KindAgent, Owner: "bigbes"}, "agent (unnamed) session (no session) for ~bigbes"},
		// The local plane renders exactly as it did before the instance plane
		// existed: the annotation appears only where there is something to say.
		{
			Principal{Kind: KindAgent, Owner: "bigbes", Agent: "a", Session: "s-1", Plane: PlaneLocal},
			"agent a session s-1 for ~bigbes",
		},
		{
			Principal{
				Kind: KindAgent, Owner: "bigbes", Agent: "a", Session: "s-1",
				Plane: PlaneInstance, Grants: mustGrants(t, "spec:propose"),
			},
			"agent a session s-1 for ~bigbes (tokens.sr.ht: spec:propose)",
		},
	}
	for _, c := range cases {
		if got := c.p.String(); got != c.want {
			t.Fatalf("String() = %q, want %q", got, c.want)
		}
	}
}

M cmd/specsrht/main.go => cmd/specsrht/main.go +7 -1
@@ 269,10 269,16 @@ func run(log *slog.Logger) error {
	}
	defer pool.Close()

	svc, err := service.New(cfg, pool)
	// WithInstanceTokens offers the daemon's resolver the tokens.sr.ht bearer
	// plane. It is offered, not required: an instance whose config has no
	// [tokens.sr.ht] section gets no such plane and keeps accepting its own
	// agent token, which is a supported configuration and not a degraded one.
	svc, err := service.New(cfg, pool, service.WithInstanceTokens(conf))
	if err != nil {
		return err
	}
	log.Info("agent credential planes",
		"local", true, "tokens.sr.ht", svc.Resolver().HasInstancePlane())

	// Seed the owner's user row before serving. core-go's auth.Middleware looks
	// a request's username up in the "user" table and, on a miss, calls out to

M go.mod => go.mod +1 -0
@@ 22,6 22,7 @@ require (
	go.bigb.es/auxilia v0.5.0
	gopkg.in/yaml.v3 v3.0.1
	sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260718185800-dd418a200152
	sourcecraft.dev/bigbes/sr-ht-ecore v0.0.0-20260808143603-174115990895
)

require (

M go.sum => go.sum +2 -0
@@ 421,3 421,5 @@ modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260718185800-dd418a200152 h1:9kQC+tDO2CO8avlKadb9Z0if4a6vJuEK80+4zcb6/fU=
sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260718185800-dd418a200152/go.mod h1:Mu1Vx39ws/OTKWGoVERXvkdRSPLBdhuFTYv0ftVV31c=
sourcecraft.dev/bigbes/sr-ht-ecore v0.0.0-20260808143603-174115990895 h1:OGZrtBtMoXhyZGXrPqMzmrNQnStoCLBVuegGo7yF1Us=
sourcecraft.dev/bigbes/sr-ht-ecore v0.0.0-20260808143603-174115990895/go.mod h1:KeoZjm+/nnsdtc1WxB7X/0EeC+Rggt2OkwJDEc6XWnw=

A graph/grant_test.go => graph/grant_test.go +92 -0
@@ 0,0 1,92 @@
package graph

import (
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

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

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

// mustGrants parses a grant string or fails the test.
func mustGrants(t *testing.T, s string) grants.Grants {
	t.Helper()
	g, err := grants.Parse(s)
	require.NoError(t, err, "parse grants %q", s)
	return g
}

// The gate asks two questions, and they have different answers when they fail.
// Identity — may you read at all — is 401 and unchanged. The grant — was the
// credential you used minted for reading — is 403, and applies only to a
// tokens.sr.ht working token, because it is the only credential that carries
// grants: the owner's cookie and spec's own agent token pass it untouched, which
// is what keeps every client that works today working.
func TestGateChecksIdentityThenGrant(t *testing.T) {
	cases := []struct {
		name      string
		principal authn.Principal
		wantCode  int
		wantNext  bool
	}{
		{"anonymous", authn.Anonymous(), http.StatusUnauthorized, false},
		{"owner cookie", authn.Principal{Kind: authn.KindOwner, Owner: "bigbes"}, http.StatusOK, true},
		{
			"local agent token",
			authn.Principal{Kind: authn.KindAgent, Owner: "bigbes", Plane: authn.PlaneLocal},
			http.StatusOK, true,
		},
		{
			"instance token with spec:read",
			authn.Principal{
				Kind: authn.KindAgent, Owner: "bigbes", Plane: authn.PlaneInstance,
				Grants: mustGrants(t, "spec:read"),
			},
			http.StatusOK, true,
		},
		{
			"instance token without spec:read",
			authn.Principal{
				Kind: authn.KindAgent, Owner: "bigbes", Plane: authn.PlaneInstance,
				Grants: mustGrants(t, "spec:propose"),
			},
			http.StatusForbidden, false,
		},
		{
			"instance token, universal grant",
			authn.Principal{
				Kind: authn.KindAgent, Owner: "bigbes", Plane: authn.PlaneInstance,
				Grants: mustGrants(t, "*"),
			},
			http.StatusOK, true,
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			var reached bool
			next := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
				reached = true
				w.WriteHeader(http.StatusOK)
			})

			req := httptest.NewRequest(http.MethodPost, "/query", nil)
			req = req.WithContext(authn.WithPrincipal(req.Context(), tc.principal))
			rec := httptest.NewRecorder()

			gate(next).ServeHTTP(rec, req)

			assert.Equal(t, tc.wantCode, rec.Code)
			assert.Equal(t, tc.wantNext, reached)
			if tc.wantCode == http.StatusForbidden {
				assert.Contains(t, rec.Body.String(), authn.ActionRead,
					"a refused caller must be told which grant it lacks")
			}
		})
	}
}

M graph/server.go => graph/server.go +17 -4
@@ 150,16 150,29 @@ func (s *Server) Endpoint() http.Handler {
// nobody else may — the same predicate web/ and mcpsrv/ apply, so the three read
// surfaces cannot drift into three policies, which is how a corpus leaks.
//
// The refusal is a 401 with a line of text and never a redirect to meta's
// login: every caller here is a machine, and handing a bot 200 and a page of
// login markup tells it nothing it can act on.
// A caller that clears it and authenticated with a tokens.sr.ht working token
// must also hold spec:read — the grant half of the same question, asked here
// because here is where the action ("read") is known. It is a no-op for the
// owner's cookie and for the local agent token, neither of which carries grants.
//
// The refusal is a 401 — or a 403 for the missing grant — with a line of text
// and never a redirect to meta's login: every caller here is a machine, and
// handing a bot 200 and a page of login markup tells it nothing it can act on.
// The two statuses stay apart because retrying is worth it for one and never for
// the other.
func gate(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if !authn.PrincipalFromContext(r.Context()).CanRead() {
		p := authn.PrincipalFromContext(r.Context())
		if !p.CanRead() {
			w.Header().Set("Content-Type", "text/plain; charset=utf-8")
			http.Error(w, "authentication required", http.StatusUnauthorized)
			return
		}
		if err := p.Authorize(authn.ActionRead); err != nil {
			w.Header().Set("Content-Type", "text/plain; charset=utf-8")
			http.Error(w, "this token does not grant "+authn.ActionRead, http.StatusForbidden)
			return
		}
		next.ServeHTTP(w, r)
	})
}

M hooks/server.go => hooks/server.go +5 -0
@@ 479,12 479,17 @@ func (s *Server) principal(ctx context.Context, space core.SpaceRef, cred Creden
			return authn.Principal{}, errorResponse(
				"spec.sr.ht could not check the agent token presented with this push: %v", err), false
		}
		// The local plane, and only it: a push arrives over SSH with a token in
		// a hook's environment, and this path checks it against agent_token and
		// nothing else. A tokens.sr.ht working token is not accepted here — the
		// two planes meet in authn.Resolver, which serves the HTTP surfaces.
		return authn.Principal{
			Kind:      authn.KindAgent,
			Owner:     owner,
			Agent:     cred.Agent,
			Session:   cred.Session,
			TokenName: tok.Name,
			Plane:     authn.PlaneLocal,
		}, Response{}, true
	default:
		// Request.Validate rejected every other spelling already.

M mcpsrv/comment.go => mcpsrv/comment.go +8 -0
@@ 93,6 93,14 @@ type commentOutput struct {
}

func commentHandler(ctx context.Context, c Commenter, in commentInput) (commentOutput, error) {
	// Both modes of this tool begin by reading the review conversation — the
	// reply path lists the threads before it can answer one — so it is a read
	// surface and carries the read grant. The grant vocabulary has no separate
	// action for commenting, and inventing one here would put a word in the
	// instance's dictionary that no token was ever minted with.
	if err := requireRead(ctx); err != nil {
		return commentOutput{}, err
	}
	if in.Proposal <= 0 {
		return commentOutput{}, fmt.Errorf("proposal must name a proposal id")
	}

M mcpsrv/comment_internal_test.go => mcpsrv/comment_internal_test.go +4 -1
@@ 206,7 206,10 @@ func TestCommentReplyCarriesTheAgentIdentityAndSession(t *testing.T) {
	if err != nil {
		t.Fatalf("commentHandler: %v", err)
	}
	if c.sawPrincipal != principal {
	// Principal is no longer comparable with == — it carries a grant set — so
	// its rendering stands in: that names the kind, the agent, the session and
	// the owner, which is everything this assertion is about.
	if c.sawPrincipal.String() != principal.String() {
		t.Errorf("principal = %+v, want the one on the context %+v", c.sawPrincipal, principal)
	}
	if c.sawThreadID != 2 || c.sawBody != "reworded in the next push" {

A mcpsrv/grant_internal_test.go => mcpsrv/grant_internal_test.go +201 -0
@@ 0,0 1,201 @@
package mcpsrv

import (
	"context"
	"database/sql"
	"path/filepath"
	"testing"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

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

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

// mustGrants parses a grant string or fails the test.
func mustGrants(t *testing.T, s string) grants.Grants {
	t.Helper()
	g, err := grants.Parse(s)
	require.NoError(t, err, "parse grants %q", s)
	return g
}

// instancePrincipal is agentPrincipal as a tokens.sr.ht working token resolves
// it: the same agent, with a grant set behind it.
func instancePrincipal(t *testing.T, grantString string) authn.Principal {
	t.Helper()
	p := agentPrincipal()
	p.Plane = authn.PlaneInstance
	p.Grants = mustGrants(t, grantString)
	return p
}

// localPrincipal is agentPrincipal as spec's own agent_token resolves it.
func localPrincipal() authn.Principal {
	p := agentPrincipal()
	p.Plane = authn.PlaneLocal
	return p
}

// Every tool that serves content asks for spec:read, and asks for it per tool
// rather than at the Gate — /mcp carries the write tools too, and a surface-wide
// read grant would refuse a propose-only token at `initialize`, before it named
// anything.
func TestReadToolsRequireTheReadGrant(t *testing.T) {
	refused := instancePrincipal(t, "spec:propose")

	t.Run("spec_read", func(t *testing.T) {
		ctx := authn.WithPrincipal(context.Background(), refused)
		_, err := readHandler(ctx, Backend{}, readInput{Space: "~bigbes/rfcs", Document: "SPEC-0007"})
		assert.ErrorIs(t, err, authn.ErrMissingGrant)
	})

	t.Run("spec_list", func(t *testing.T) {
		ctx := authn.WithPrincipal(context.Background(), refused)
		_, err := listHandler(ctx, Backend{}, listInput{})
		assert.ErrorIs(t, err, authn.ErrMissingGrant)
	})

	t.Run("spec_search", func(t *testing.T) {
		ctx := authn.WithPrincipal(context.Background(), refused)
		_, err := searchHandler(ctx, Backend{}, searchInput{Query: "storage"})
		assert.ErrorIs(t, err, authn.ErrMissingGrant)
	})

	t.Run("spec_comment", func(t *testing.T) {
		ctx := authn.WithPrincipal(context.Background(), refused)
		_, err := commentHandler(ctx, commentFixture(t), commentInput{Proposal: 7})
		assert.ErrorIs(t, err, authn.ErrMissingGrant)
	})
}

// And every credential that carries no grants passes untouched, which is what
// keeps the agents configured today working: the local agent token and the
// owner's cookie have nothing to check.
func TestReadToolsPassEveryUngrantedCredential(t *testing.T) {
	for name, p := range map[string]authn.Principal{
		"local agent token":            localPrincipal(),
		"owner cookie":                 {Kind: authn.KindOwner, Owner: "bigbes"},
		"instance token, spec:read":    instancePrincipal(t, "spec:read"),
		"instance token, universal":    instancePrincipal(t, "*"),
		"instance token, both actions": instancePrincipal(t, "spec:read spec:propose"),
	} {
		t.Run(name, func(t *testing.T) {
			ctx := authn.WithPrincipal(context.Background(), p)
			assert.NoError(t, requireRead(ctx))

			// Listing threads goes all the way through with a fake service.
			out, err := commentHandler(ctx, commentFixture(t), commentInput{Proposal: 7})
			require.NoError(t, err)
			assert.NotEmpty(t, out.Threads)
		})
	}
}

// realWriter builds an actual *service.Service over a database nothing answers
// on, so that spec_propose's grant check here is service.Propose's own and not a
// copy of it. Propose refuses a principal before it opens anything, which is
// what makes an unreachable pool enough.
func realWriter(t *testing.T) *service.Service {
	t.Helper()
	root := t.TempDir()
	pool, err := sql.Open("postgres",
		"postgres://nobody@127.0.0.1:1/nothing?sslmode=disable&connect_timeout=1")
	require.NoError(t, err)
	t.Cleanup(func() { pool.Close() })

	svc, err := service.New(service.Config{
		Repos:            filepath.Join(root, "repos"),
		Cache:            filepath.Join(root, "cache"),
		Origin:           "https://spec.srht.bigb.es",
		ConnectionString: "postgres://nobody@127.0.0.1:1/nothing",
		Instance: authn.Instance{
			OwnerName:  "bigbes",
			OwnerEmail: "bigbes@gmail.com",
			AgentEmail: "agent@spec.srht.bigb.es",
		},
	}, pool)
	require.NoError(t, err)
	return svc
}

// proposeCall is one well-formed spec_propose, so the only thing a test varies
// is the credential behind it.
func proposeCall() proposeInput {
	return proposeInput{
		Space:     "~bigbes/rfcs",
		IfMatch:   "1f0c1d1a1e2b3c4d5e6f708192a3b4c5d6e7f809",
		Title:     "Add a note",
		Message:   "write it",
		Documents: []proposeDoc{{Path: "notes/a.md", Content: "---\nid: N-1\n---\n"}},
	}
}

// spec_propose asks for spec:propose, through the same service.Propose the REST
// surface calls — the rule is spelled once, below both write surfaces, and MCP
// gets it by going through it rather than by repeating it here.
func TestProposeToolRequiresTheProposeGrant(t *testing.T) {
	svc := realWriter(t)

	for _, grantString := range []string{"spec:read", "bench:upload"} {
		t.Run(grantString, func(t *testing.T) {
			ctx := authn.WithPrincipal(context.Background(), instancePrincipal(t, grantString))
			_, err := proposeHandler(ctx, svc, proposeCall())
			require.Error(t, err)
			assert.ErrorIs(t, err, service.ErrForbidden,
				"the refusal is the one this surface already uses for an unauthorised call")
			assert.ErrorIs(t, err, authn.ErrMissingGrant)
		})
	}
}

// And every credential that may propose clears it — the local agent token above
// all, which carries no grants and must keep working exactly as it does today.
func TestProposeToolAcceptsTheGrantedCredentials(t *testing.T) {
	svc := realWriter(t)

	for name, p := range map[string]authn.Principal{
		"local agent token":            localPrincipal(),
		"instance token, spec:propose": instancePrincipal(t, "spec:propose"),
		"instance token, universal":    instancePrincipal(t, "*"),
	} {
		t.Run(name, func(t *testing.T) {
			ctx := authn.WithPrincipal(context.Background(), p)
			// The write cannot land — there is no space and no database — but it
			// must not be turned away at the ACL.
			_, err := proposeHandler(ctx, svc, proposeCall())
			require.Error(t, err, "the fixture has no space, so this cannot succeed")
			assert.NotErrorIs(t, err, service.ErrForbidden, "%s must clear the write ACL", name)
			assert.NotErrorIs(t, err, authn.ErrMissingGrant)
		})
	}
}

// A grant is not a substitute for provenance. An instance token with the widest
// grant there is still cannot write without saying who wrote it — the refusal
// changes from authorization to provenance, and does not go away.
func TestProposeToolStillDemandsProvenanceOnBothPlanes(t *testing.T) {
	svc := realWriter(t)

	for name, p := range map[string]authn.Principal{
		"local":    localPrincipal(),
		"instance": instancePrincipal(t, "*"),
	} {
		t.Run(name, func(t *testing.T) {
			noSession := p
			noSession.Session = ""
			ctx := authn.WithPrincipal(context.Background(), noSession)

			_, err := proposeHandler(ctx, svc, proposeCall())
			require.Error(t, err)
			assert.NotErrorIs(t, err, service.ErrForbidden,
				"a missing session is a provenance failure, not an authorization one")

			_, err = noSession.AgentWriteFor("1f0c1d1a1e2b3c4d5e6f708192a3b4c5d6e7f809")
			assert.ErrorIs(t, err, authn.ErrMissingProvenance)
		})
	}
}

M mcpsrv/list.go => mcpsrv/list.go +3 -0
@@ 50,6 50,9 @@ type listOutput struct {
}

func listHandler(ctx context.Context, b Backend, in listInput) (listOutput, error) {
	if err := requireRead(ctx); err != nil {
		return listOutput{}, err
	}
	if strings.TrimSpace(in.Space) == "" {
		// A rev with no space is a caller that meant to name one. Ignoring it
		// would answer a different question than was asked and look like it

M mcpsrv/mcpsrv.go => mcpsrv/mcpsrv.go +18 -0
@@ 280,6 280,13 @@ func allowHosts(next http.Handler, origin string) http.Handler {
// Host allowlist — spec_propose was already fail-closed in service.Propose, but
// spec_search/spec_read/spec_list checked nothing. The refusal is a 401 with a
// line of plain text and never a login redirect: every caller here is a machine.
//
// It checks identity and not grants, deliberately. This one endpoint carries
// both the read tools and the write ones, and the tool being called is in the
// JSON-RPC body, not the request — so a surface-wide spec:read would refuse a
// tokens.sr.ht token minted for spec:propose alone at `initialize`, before it
// ever named a tool. The grant is therefore checked per tool, by requireRead and
// by service.Propose, each of which knows what is being attempted.
func Gate(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if !authn.PrincipalFromContext(r.Context()).CanRead() {


@@ 291,6 298,17 @@ func Gate(next http.Handler) http.Handler {
	})
}

// requireRead is the grant half of the read ACL, for the tools that serve
// content: Gate has already established that the caller may read at all, and
// this asks whether the credential they used was minted for it.
//
// It is a no-op for the owner's cookie and for spec's own agent token, neither
// of which carries grants — so every client that works today keeps working — and
// refuses a tokens.sr.ht working token that lacks spec:read.
func requireRead(ctx context.Context) error {
	return authn.PrincipalFromContext(ctx).Authorize(authn.ActionRead)
}

// hostAllowed compares a request's Host against the expected hostname, ignoring
// any port and IPv6 brackets. Loopback names stay allowed so `make run-dev` and
// a local MCP client keep working.

M mcpsrv/propose_internal_test.go => mcpsrv/propose_internal_test.go +3 -2
@@ 59,8 59,9 @@ func TestProposeHandlerForwardsPrincipalAndArgs(t *testing.T) {
		t.Fatalf("proposeHandler: %v", err)
	}

	// The request the service saw.
	if w.got.Principal != principal {
	// The request the service saw. Principal is no longer comparable with == —
	// it carries a grant set — so its rendering stands in.
	if w.got.Principal.String() != principal.String() {
		t.Errorf("principal = %+v, want the one on the context %+v", w.got.Principal, principal)
	}
	if w.got.Space != (core.SpaceRef{Owner: "bigbes", Name: "rfcs"}) {

M mcpsrv/read.go => mcpsrv/read.go +3 -0
@@ 51,6 51,9 @@ type readOutput struct {
}

func readHandler(ctx context.Context, b Backend, in readInput) (readOutput, error) {
	if err := requireRead(ctx); err != nil {
		return readOutput{}, err
	}
	ref, err := parseSpace(in.Space)
	if err != nil {
		return readOutput{}, err

M mcpsrv/search.go => mcpsrv/search.go +3 -0
@@ 55,6 55,9 @@ type searchOutput struct {
}

func searchHandler(ctx context.Context, b Backend, in searchInput) (searchOutput, error) {
	if err := requireRead(ctx); err != nil {
		return searchOutput{}, err
	}
	text := strings.TrimSpace(in.Query)
	if text == "" {
		return searchOutput{}, errors.New("query must not be empty")

A service/bearer.go => service/bearer.go +144 -0
@@ 0,0 1,144 @@
package service

import (
	"context"
	"database/sql"
	"fmt"
	"os"

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

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

// TokensSection is tokens.sr.ht's config section, spelled literally because it
// is what the instance's config.ini says and what every other service on the
// instance looks the daemon up by.
const TokensSection = "tokens.sr.ht"

// Option configures a Service at construction.
type Option func(*options)

// options is what the Option functions accumulate.
type options struct {
	// conf is the instance config.ini, present only when WithInstanceTokens was
	// passed. It is held here rather than on Config because it carries the
	// instance's secrets — [sr.ht] network-key and [webhooks] private-key — and
	// Config is a value the daemon prints.
	conf     ini.File
	haveConf bool
}

// WithInstanceTokens offers the instance config to the tokens.sr.ht bearer
// plane. Whether a plane is actually built depends on what is in it: see
// instancePlane, which treats a missing [tokens.sr.ht] section as "there is no
// such daemon on this instance" rather than as a misconfiguration.
//
// It is an option rather than a parameter because the two CLI paths that build a
// Service — `specsrht token` and `specsrht doc` — authenticate nobody and have
// no use for a validator or the HTTP client behind it.
func WithInstanceTokens(conf ini.File) Option {
	return func(o *options) {
		o.conf = conf
		o.haveConf = true
	}
}

// instancePlane builds the tokens.sr.ht bearer plane from the instance config,
// or reports that there is none to build (a nil option, nil error).
//
// The absence is the case worth spelling out. An instance whose config.ini has
// no [tokens.sr.ht] section has no such daemon, and spec must start anyway and
// keep accepting its own agent token: the local plane is not a fallback for a
// broken instance plane, it is the plane this service shipped with. So a missing
// origin is an answer, not an error — while an origin that is present and
// unusable is an error, and fails startup where an operator is looking rather
// than one request at a time as an unexplained 503.
//
// The origin is read in its internal form (GetOrigin's external=false), so the
// revocation check of SPEC ch. 6 step 4 crosses the docker network directly
// instead of going out through the reverse proxy and back in.
func instancePlane(conf ini.File, q db.Querier) (authn.ResolverOption, error) {
	origin := config.GetOrigin(conf, TokensSection, false)
	if origin == "" {
		return nil, nil
	}

	// The node id is what the daemon's internal guard logs the caller as. The
	// hostname is the honest answer and needs no config key to be forgotten or
	// to drift; a host that cannot name itself is a startup failure rather than
	// a guessed label, because a fabricated node id is worse than none — it is
	// the wrong answer to the only question the revocation log can be asked.
	node, err := os.Hostname()
	if err != nil {
		return nil, fmt.Errorf("service: the tokens.sr.ht plane needs a node id and this host cannot name itself: %w", err)
	}

	v, err := bearer.New(bearer.Options{
		Origin:   origin,
		ClientID: ConfigSection,
		NodeID:   node,
	})
	if err != nil {
		return nil, fmt.Errorf("service: build the tokens.sr.ht validator: %w", err)
	}

	// auth.LookupUser opens its own transaction, so the pool itself is needed
	// and not the Querier interface a *sql.Tx also satisfies. Refusing loudly
	// beats silently leaving the plane out: an instance that configured
	// tokens.sr.ht and got no instance plane would look identical to one that
	// did not configure it, and the difference would only surface as every agent
	// token being refused.
	pool, ok := q.(*sql.DB)
	if !ok {
		return nil, fmt.Errorf(
			"service: the tokens.sr.ht plane needs the *sql.DB pool (auth.LookupUser opens its own transaction), got %T", q)
	}

	return authn.WithInstancePlane(v, metaUserLookup{pool: pool, conf: conf}), nil
}

// metaUserLookup resolves the owner of an instance token to the local "user"
// row, through core-go's auth.LookupUser — the same function dolt, cover and
// bench resolve their token owners with.
//
// The two context values it installs are not optional and not defensive.
// auth.LookupUser reads config.ServiceName out of the context on every call and
// opens a read-only transaction through core-go's database context, and both of
// those panic when absent. spec's resolver middleware runs on the *anonymous*
// router, which core-go's WithDefaultMiddleware does not decorate — it installs
// the config, database and auth middleware on the authenticated router only —
// so nothing upstream has put either there. This adapter is what supplies them,
// and it is the reason authn declares a UserLookup interface instead of calling
// core-go itself.
type metaUserLookup struct {
	pool *sql.DB
	conf ini.File
}

// LookupUser implements authn.UserLookup.
//
// A user this instance has never seen is resolved by core-go against
// meta.sr.ht and written down. In practice that path is not reached: the
// resolver refuses an instance token whose owner is not [sr.ht] owner-name, and
// the daemon seeds that row with EnsureOwnerUser before it serves anything.
func (l metaUserLookup) LookupUser(ctx context.Context, username string) (authn.InstanceUser, error) {
	ctx = database.Context(ctx, l.pool)
	ctx = config.Context(ctx, l.conf, ConfigSection)

	var ac auth.AuthContext
	if err := auth.LookupUser(ctx, username, &ac); err != nil {
		return authn.InstanceUser{}, fmt.Errorf("service: look up user %q: %w", username, err)
	}
	if ac.UserID == 0 {
		// A resolved user with no row id would key nothing and authenticate
		// everything; there is no sensible value to substitute.
		return authn.InstanceUser{}, fmt.Errorf("service: user %q resolved to no row id", username)
	}
	return authn.InstanceUser{ID: ac.UserID, Username: ac.Username}, nil
}

A service/bearer_test.go => service/bearer_test.go +294 -0
@@ 0,0 1,294 @@
package service

import (
	"context"
	"strings"
	"testing"

	"github.com/go-git/go-git/v5/plumbing"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"github.com/vaughan0/go-ini"

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

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

// mustGrants parses a grant string or fails the test.
func mustGrants(t *testing.T, s string) grants.Grants {
	t.Helper()
	g, err := grants.Parse(s)
	require.NoError(t, err, "parse grants %q", s)
	return g
}

// instanceAgent is agentPrincipal as a tokens.sr.ht working token resolves it:
// the same agent, acting for the same owner, with a grant set behind it.
func instanceAgent(t *testing.T, grantString string) authn.Principal {
	t.Helper()
	p := agentPrincipal()
	p.Plane = authn.PlaneInstance
	p.Grants = mustGrants(t, grantString)
	p.UserID = 1
	return p
}

// localAgent is agentPrincipal as spec's own agent_token resolves it: no owner
// of its own, no grants, nothing to authorize against.
func localAgent() authn.Principal {
	p := agentPrincipal()
	p.Plane = authn.PlaneLocal
	p.TokenName = "laptop"
	return p
}

// proposeRequest is a well-formed open, so that the only thing a test varies is
// the principal.
func proposeRequest(p authn.Principal) ProposeRequest {
	return ProposeRequest{
		Space:     fxSpace,
		Principal: p,
		Title:     "Add a note",
		IfMatch:   headRev,
		Message:   "add notes/a.md",
		Writes:    []DocumentWrite{{Path: "notes/a.md", Content: mdDoc("N-1", "A", "body")}},
	}
}

// An instance token minted without spec:propose is refused, and refused as a
// forbidden principal rather than as a bad request — the agent has to be told to
// ask for a wider grant, not to fix its document.
func TestProposeRefusesAnInstanceTokenWithoutTheProposeGrant(t *testing.T) {
	svc, _ := newService(t)
	for _, grantString := range []string{"spec:read", "bench:upload", "cover:read cover:upload"} {
		t.Run(grantString, func(t *testing.T) {
			_, err := svc.Propose(context.Background(), proposeRequest(instanceAgent(t, grantString)))
			require.Error(t, err)
			assert.ErrorIs(t, err, ErrForbidden)
			assert.ErrorIs(t, err, authn.ErrMissingGrant)
			assert.Contains(t, err.Error(), authn.ActionPropose)
		})
	}
}

// The grant check is a guard on the principal, like the agent-only one beside
// it: it holds with a database that cannot be reached, which is what proves it
// runs before anything is opened.
func TestProposeAcceptsTheGrantedPrincipals(t *testing.T) {
	svc, _ := newService(t)
	cases := map[string]authn.Principal{
		"local agent token":              localAgent(),
		"instance token, exact grant":    instanceAgent(t, "spec:propose"),
		"instance token, both grants":    instanceAgent(t, "spec:read spec:propose"),
		"instance token, universal":      instanceAgent(t, "*"),
		"instance token, registered row": instanceAgent(t, "spec:propose id:42"),
	}
	for name, p := range cases {
		t.Run(name, func(t *testing.T) {
			// The write cannot land — the pool is dead and the space does not
			// exist — but it must not be turned away at the ACL.
			_, err := svc.Propose(context.Background(), proposeRequest(p))
			require.Error(t, err, "the fixture has no space, so this cannot succeed")
			assert.NotErrorIs(t, err, ErrForbidden, "%s must clear the write ACL", name)
			assert.NotErrorIs(t, err, authn.ErrMissingGrant)
		})
	}
}

// The refs rule is untouched by grants and applies to both planes identically:
// an agent credential moves refs under the proposal prefix and nothing else, no
// matter how wide the grant set behind it is.
func TestRefsRuleIsUnchangedOnBothPlanes(t *testing.T) {
	planes := map[string]authn.Principal{
		"local":    localAgent(),
		"instance": instanceAgent(t, "*"),
	}
	newHash := plumbing.NewHash("1f0c1d1a1e2b3c4d5e6f708192a3b4c5d6e7f809")

	for name, p := range planes {
		t.Run(name, func(t *testing.T) {
			kind, err := principalKind(p)
			require.NoError(t, err)
			assert.Equal(t, gitx.PrincipalAgent, kind,
				"both planes are agents to the refs rule; a universal grant does not promote one")

			// Outside the proposal prefix: refused.
			err = gitx.CheckRefUpdate(kind, gitx.DefaultApprovedBranch, gitx.RefUpdate{
				Ref: "refs/heads/" + gitx.DefaultApprovedBranch, New: newHash, FastForward: true,
			})
			assert.ErrorIs(t, err, gitx.ErrRefRejected,
				"an agent may not move the approved branch on either plane")

			err = gitx.CheckRefUpdate(kind, gitx.DefaultApprovedBranch, gitx.RefUpdate{
				Ref: "refs/heads/scratch", New: newHash, FastForward: true,
			})
			assert.ErrorIs(t, err, gitx.ErrRefRejected,
				"an agent may not create a branch outside the proposal prefix on either plane")

			// Inside it: permitted, on both planes.
			err = gitx.CheckRefUpdate(kind, gitx.DefaultApprovedBranch, gitx.RefUpdate{
				Ref: "refs/heads/" + core.ProposalPrefix + "7", New: newHash, FastForward: true,
			})
			assert.NoError(t, err)
		})
	}
}

// Provenance is still mandatory on both planes. A grant says what a credential
// was minted for; it says nothing about who wrote a commit, and it does not
// excuse a write from saying so.
func TestProvenanceStillRequiredOnBothPlanes(t *testing.T) {
	svc, _ := newService(t)
	for name, p := range map[string]authn.Principal{
		"local":    localAgent(),
		"instance": instanceAgent(t, "*"),
	} {
		t.Run(name, func(t *testing.T) {
			noSession := p
			noSession.Session = ""
			_, err := svc.Propose(context.Background(), proposeRequest(noSession))
			require.Error(t, err)
			assert.NotErrorIs(t, err, ErrForbidden,
				"a missing session is a provenance failure, not an authorization one")

			// The provenance builder is where it is caught, whichever plane the
			// principal came in on.
			_, err = noSession.AgentWriteFor(headRev)
			assert.ErrorIs(t, err, authn.ErrMissingProvenance)
		})
	}
}

// --- the plane's own wiring ------------------------------------------------

// tokensConf is an instance config with a tokens.sr.ht section, in the shape
// GetOrigin reads.
func tokensConf(section ini.Section) ini.File {
	conf := ini.File{"sr.ht": ini.Section{"owner-name": "bigbes"}}
	if section != nil {
		conf[TokensSection] = section
	}
	return conf
}

// An instance whose config has no [tokens.sr.ht] section has no such daemon.
// The plane must then be absent rather than broken: spec starts, and its own
// agent token is the only door — which is exactly what the instance ran before
// tokens.sr.ht existed.
func TestInstancePlane_AbsentSectionIsNotAnError(t *testing.T) {
	for name, conf := range map[string]ini.File{
		"no section at all": tokensConf(nil),
		"section with no origin": tokensConf(ini.Section{
			"connection-string": "postgresql://tokens@postgres/tokens.sr.ht",
		}),
	} {
		t.Run(name, func(t *testing.T) {
			plane, err := instancePlane(conf, deadDB(t))
			require.NoError(t, err, "a missing origin is an answer, not a misconfiguration")
			assert.Nil(t, plane)
		})
	}
}

func TestInstancePlane_BuiltFromTheInternalOrigin(t *testing.T) {
	// The internal origin wins over the external one: the revocation check goes
	// over the docker network rather than out through the proxy and back.
	plane, err := instancePlane(tokensConf(ini.Section{
		"origin":          "https://tokens.srht.bigb.es",
		"internal-origin": "http://tokens.sr.ht:5094",
	}), deadDB(t))
	require.NoError(t, err)
	require.NotNil(t, plane)

	rs, err := authn.NewResolver("bigbes", NewTokenStore(nil), plane)
	require.NoError(t, err)
	assert.True(t, rs.HasInstancePlane())
}

// An origin that is present and unusable fails startup, where an operator is
// looking — not one request at a time as an unexplained 503.
func TestInstancePlane_UnusableOriginIsAStartupError(t *testing.T) {
	_, err := instancePlane(tokensConf(ini.Section{"origin": "tokens.srht.bigb.es"}), deadDB(t))
	require.Error(t, err)
	assert.Contains(t, err.Error(), "tokens.sr.ht")
}

// Service.New wires the plane when it is offered and a config supports it, and
// builds the same local-only resolver it always did when it is not.
func TestNew_InstancePlaneIsOptional(t *testing.T) {
	cfg := testConfig(t, t.TempDir())

	svc, err := New(cfg, deadDB(t))
	require.NoError(t, err)
	assert.False(t, svc.Resolver().HasInstancePlane(),
		"no option means no instance plane, exactly as before it existed")

	svc, err = New(cfg, deadDB(t), WithInstanceTokens(tokensConf(nil)))
	require.NoError(t, err)
	assert.False(t, svc.Resolver().HasInstancePlane(),
		"an instance without a [tokens.sr.ht] section must still start, on the local plane")

	svc, err = New(cfg, deadDB(t), WithInstanceTokens(tokensConf(ini.Section{
		"origin": "https://tokens.srht.bigb.es",
	})))
	require.NoError(t, err)
	assert.True(t, svc.Resolver().HasInstancePlane())
}

// --- Postgres-backed integration tests (skip when SPECSRHT_TEST_PG is unset) ---

// The whole open path, once per plane: the credential every agent is configured
// with today and a tokens.sr.ht token carrying spec:propose both open a
// proposal, with the same provenance recorded on it.
func TestProposeOpensProposalOnBothPlanes(t *testing.T) {
	for name, p := range map[string]authn.Principal{
		"local agent token": localAgent(),
		"instance token":    instanceAgent(t, "spec:read spec:propose"),
	} {
		t.Run(name, func(t *testing.T) {
			svc, _ := newTestService(t)
			ctx := context.Background()
			sp, err := svc.CreateSpace(ctx, fxSpace)
			require.NoError(t, err)
			base, err := sp.Repo.ApprovedHead(ctx)
			require.NoError(t, err)

			req := proposeRequest(p)
			req.IfMatch = base.String()
			res, err := svc.Propose(ctx, req)
			require.NoError(t, err)

			assert.Equal(t, core.StateOpen, res.Proposal.State)
			assert.Equal(t, "claude-code/spec-writer", res.Proposal.Agent)
			assert.True(t, strings.HasSuffix(res.URL, "/p/1"), "URL = %q", res.URL)
			assert.True(t, strings.HasPrefix(res.Proposal.Branch, core.ProposalPrefix),
				"an agent's branch stays under the proposal prefix: %q", res.Proposal.Branch)
		})
	}
}

// And the refusal, end to end: a token minted for reading only gets 403's
// sentinel out of the same call, with the space in place and nothing else to
// blame.
func TestProposeRefusesAReadOnlyInstanceTokenEndToEnd(t *testing.T) {
	svc, _ := newTestService(t)
	ctx := context.Background()
	sp, err := svc.CreateSpace(ctx, fxSpace)
	require.NoError(t, err)
	base, err := sp.Repo.ApprovedHead(ctx)
	require.NoError(t, err)

	req := proposeRequest(instanceAgent(t, "spec:read"))
	req.IfMatch = base.String()
	_, err = svc.Propose(ctx, req)
	require.Error(t, err)
	assert.ErrorIs(t, err, ErrForbidden)
	assert.ErrorIs(t, err, authn.ErrMissingGrant)

	open, err := svc.ListProposals(ctx, fxSpace, core.StateOpen)
	require.NoError(t, err)
	assert.Empty(t, open, "a refused write must leave no row behind")
}

M service/propose.go => service/propose.go +8 -0
@@ 101,6 101,14 @@ func (s *Service) Propose(ctx context.Context, req ProposeRequest) (ProposeResul
	if !req.Principal.IsAgent() {
		return ProposeResult{}, fmt.Errorf("%w: %s may not propose; proposing is agent-only", ErrForbidden, req.Principal)
	}
	// The grant check, at the layer that finally knows the action. It is a no-op
	// for the local agent token — which carries no grants and never will, its
	// boundary being the refs rule, unchanged by any of this — and refuses a
	// tokens.sr.ht token that was minted without spec:propose. Both write
	// surfaces come through here, so this is the one place it is spelled.
	if err := req.Principal.Authorize(authn.ActionPropose); err != nil {
		return ProposeResult{}, fmt.Errorf("%w: %s may not propose: %w", ErrForbidden, req.Principal, err)
	}
	if len(req.Writes) == 0 {
		return ProposeResult{}, fmt.Errorf("%w: a proposal must write at least one document", ErrInvalid)
	}

M service/service.go => service/service.go +23 -2
@@ 244,16 244,37 @@ type Service struct {
// reconciler's background queries share one pool. A nil handle is refused
// rather than tolerated: every agent token would then resolve as unknown, which
// looks exactly like a mass revocation and is a miserable thing to debug.
func New(cfg Config, q db.Querier) (*Service, error) {
//
// Pass WithInstanceTokens(conf) to offer the resolver the tokens.sr.ht bearer
// plane alongside the local agent-token one. Without it — and with it on an
// instance whose config has no [tokens.sr.ht] section — the resolver knows only
// the local plane, which is what every caller got before that plane existed.
func New(cfg Config, q db.Querier, opts ...Option) (*Service, error) {
	if err := cfg.Validate(); err != nil {
		return nil, err
	}
	if q == nil {
		return nil, errors.New("service: nil database handle")
	}

	var o options
	for _, opt := range opts {
		opt(&o)
	}
	var ropts []authn.ResolverOption
	if o.haveConf {
		plane, err := instancePlane(o.conf, q)
		if err != nil {
			return nil, err
		}
		if plane != nil {
			ropts = append(ropts, plane)
		}
	}

	store := db.NewStore(q)
	tokens := NewTokenStore(store)
	resolver, err := authn.NewResolver(cfg.Instance.OwnerName, tokens)
	resolver, err := authn.NewResolver(cfg.Instance.OwnerName, tokens, ropts...)
	if err != nil {
		return nil, fmt.Errorf("service: build resolver: %w", err)
	}

M web/comments.go => web/comments.go +6 -0
@@ 347,6 347,12 @@ func (s *Server) commentPost(w http.ResponseWriter, r *http.Request) (service.Pr
		s.renderError(w, r, http.StatusForbidden, "you may not read this proposal")
		return service.Proposal{}, false
	}
	// The grant half of the same ACL. The review conversation is content, so a
	// tokens.sr.ht token reaches it on spec:read like every other read here.
	if err := readGrant(r); err != nil {
		s.denyGrant(w, r, formatHTML)
		return service.Proposal{}, false
	}
	if err := r.ParseForm(); err != nil {
		s.renderError(w, r, http.StatusBadRequest, "malformed form submission")
		return service.Proposal{}, false

A web/grant_test.go => web/grant_test.go +131 -0
@@ 0,0 1,131 @@
package web

import (
	"net/http"
	"net/http/httptest"
	"testing"

	"github.com/go-chi/chi/v5"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"github.com/vaughan0/go-ini"

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

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

// mustGrants parses a grant string or fails the test.
func mustGrants(t *testing.T, s string) grants.Grants {
	t.Helper()
	g, err := grants.Parse(s)
	require.NoError(t, err, "parse grants %q", s)
	return g
}

// grantRouter mounts the read plane with a fixed principal injected into every
// request, bypassing token resolution: what put the principal there is the
// resolver's business, and this file is about what the handlers do with it.
func grantRouter(t *testing.T, p authn.Principal) http.Handler {
	t.Helper()
	resolver, err := authn.NewResolver("bigbes", stubTokenStore{})
	require.NoError(t, err)

	srv, err := New(Options{
		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"},
		},
		Reader:   newFakeReader(),
		Searcher: &fakeSearcher{},
		Resolver: resolver,
	})
	require.NoError(t, err)

	r := chi.NewRouter()
	r.Use(func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
			next.ServeHTTP(w, req.WithContext(authn.WithPrincipal(req.Context(), p)))
		})
	})
	srv.Register(r)
	return r
}

func instancePrincipal(t *testing.T, grantString string) authn.Principal {
	t.Helper()
	return authn.Principal{
		Kind: authn.KindAgent, Owner: "bigbes", Agent: "claude-code", Session: "s-1",
		Plane: authn.PlaneInstance, Grants: mustGrants(t, grantString),
	}
}

// Every read route asks for spec:read from a tokens.sr.ht working token, and
// asks nothing extra of the credentials that carry no grants. The refusal is a
// 403 and pointedly not a login redirect: the caller is already authenticated,
// so sending it to meta would loop it back with the same token.
func TestReadRoutesRequireTheReadGrant(t *testing.T) {
	targets := []string{
		"/~bigbes/rfcs",
		"/~bigbes/rfcs/specs/0007-storage",
		"/~bigbes/rfcs/specs/0007-storage.md",
		"/~bigbes/rfcs/specs/0007-storage.json",
		"/search?q=storage",
		"/inbox",
	}

	t.Run("refused without it", func(t *testing.T) {
		h := grantRouter(t, instancePrincipal(t, "spec:propose"))
		for _, target := range targets {
			t.Run(target, func(t *testing.T) {
				rec := httptest.NewRecorder()
				h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil))
				assert.Equal(t, http.StatusForbidden, rec.Code, "body: %s", rec.Body)
				assert.Contains(t, rec.Body.String(), authn.ActionRead)
				assert.Empty(t, rec.Header().Get("Location"),
					"an authenticated caller must not be redirected to a login")
			})
		}
	})

	// The credentials that work today, and the instance token that was minted
	// for reading: all served, no 403 anywhere.
	for name, p := range map[string]authn.Principal{
		"owner cookie":              {Kind: authn.KindOwner, Owner: "bigbes", CookieUser: "bigbes"},
		"local agent token":         {Kind: authn.KindAgent, Owner: "bigbes", Plane: authn.PlaneLocal, TokenName: "laptop"},
		"instance token, spec:read": instancePrincipal(t, "spec:read"),
		"instance token, universal": instancePrincipal(t, "*"),
	} {
		t.Run("served for "+name, func(t *testing.T) {
			h := grantRouter(t, p)
			for _, target := range targets {
				rec := httptest.NewRecorder()
				h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, target, nil))
				assert.Equal(t, http.StatusOK, rec.Code, "%s: body %s", target, rec.Body)
			}
		})
	}
}

// An anonymous browser is still sent to meta's login and an anonymous bot still
// gets a 401: the grant check sits behind the identity one and does not change
// what happens when there is no identity at all.
func TestAnonymousDenialIsUnchanged(t *testing.T) {
	h := grantRouter(t, authn.Anonymous())

	rec := httptest.NewRecorder()
	h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/~bigbes/rfcs/specs/0007-storage", nil))
	assert.Equal(t, http.StatusFound, rec.Code)
	assert.Contains(t, rec.Header().Get("Location"), "meta.example")

	rec = httptest.NewRecorder()
	h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/~bigbes/rfcs/specs/0007-storage.md", nil))
	assert.Equal(t, http.StatusUnauthorized, rec.Code)
}

M web/handlers.go => web/handlers.go +42 -6
@@ 58,6 58,31 @@ func mayRead(r *http.Request) bool {
	return authn.PrincipalFromContext(r.Context()).CanRead()
}

// readGrant reports whether the credential behind this request was minted for
// reading. It is a no-op for the owner's cookie and for spec's own agent token,
// neither of which carries grants; it refuses a tokens.sr.ht working token
// without spec:read.
//
// Kept apart from mayRead because the two questions have different answers when
// they fail: "who are you" ends in a login, "what may this token do" does not.
func readGrant(r *http.Request) error {
	return authn.PrincipalFromContext(r.Context()).Authorize(authn.ActionRead)
}

// allowRead is the whole read gate: identity, then grant. It answers the request
// itself when either refuses and reports whether the handler may go on.
func (s *Server) allowRead(w http.ResponseWriter, r *http.Request, f format) bool {
	if !mayRead(r) {
		s.denyRead(w, r, f)
		return false
	}
	if err := readGrant(r); err != nil {
		s.denyGrant(w, r, f)
		return false
	}
	return true
}

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


@@ 71,6 96,20 @@ func (s *Server) denyRead(w http.ResponseWriter, r *http.Request, f format) {
	http.Error(w, "authentication required", http.StatusUnauthorized)
}

// denyGrant answers a caller whose credential is good but was not minted for
// reading. 403 in both shapes, and pointedly no login redirect: the caller is
// already authenticated, so sending them to meta would loop them back here with
// the same token and the same answer.
func (s *Server) denyGrant(w http.ResponseWriter, r *http.Request, f format) {
	msg := "this token does not grant " + authn.ActionRead
	if f == formatHTML {
		s.renderError(w, r, http.StatusForbidden, msg)
		return
	}
	w.Header().Set("Content-Type", "text/plain; charset=utf-8")
	http.Error(w, msg, http.StatusForbidden)
}

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

// httpStatusFor maps a service error onto a status. service.ErrNotFound already


@@ 185,8 224,7 @@ type spaceData struct {
}

func (s *Server) handleSpace(w http.ResponseWriter, r *http.Request) {
	if !mayRead(r) {
		s.denyRead(w, r, formatHTML)
	if !s.allowRead(w, r, formatHTML) {
		return
	}
	ref, err := spaceRefFrom(r)


@@ 333,8 371,7 @@ func (s *Server) handleDocument(w http.ResponseWriter, r *http.Request) {
	}
	address, f := splitFormat(rest)

	if !mayRead(r) {
		s.denyRead(w, r, f)
	if !s.allowRead(w, r, f) {
		return
	}
	ref, err := spaceRefFrom(r)


@@ 522,8 559,7 @@ type searchData struct {
}

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

M web/inbox.go => web/inbox.go +1 -2
@@ 41,8 41,7 @@ type proposalRow struct {
// design describes — the link an agent hands you is the normal way in, and this
// catches the work no link reached.
func (s *Server) handleInbox(w http.ResponseWriter, r *http.Request) {
	if !mayRead(r) {
		s.loginRedirect(w, r)
	if !s.allowRead(w, r, formatHTML) {
		return
	}
	open, err := s.reader.Inbox(r.Context())

M web/proposal.go => web/proposal.go +1 -2
@@ 72,8 72,7 @@ func proposalIDFrom(r *http.Request) (int, bool) {
// and answering for the wrong space would let one space's URL surface another's
// proposal.
func (s *Server) handleProposal(w http.ResponseWriter, r *http.Request) {
	if !mayRead(r) {
		s.loginRedirect(w, r)
	if !s.allowRead(w, r, formatHTML) {
		return
	}
	ref, err := spaceRefFrom(r)