~bigbes/sr-ht-spec

64cae3af81d4b0039edc8ec3946bed36166a447b — Eugene Blikh a day ago 3cb1c03 master
graph: accept a meta.sr.ht token, so /query can be federated

api.sr.ht forwards ONE client Authorization header to every service a
federated query touches — AuthMiddleware copies it verbatim into the
request context, and the Internal credential it can mint is used only to
fetch schemas at startup. So a federated caller arrives here holding
whatever credential the client had, and the only credential that works
across the whole instance is a meta.sr.ht personal access token. /query
refused those, which made federating it one `api-origin=` line that
would produce 401s.

api-meta.json was the stronger half of the same refusal: with an empty
scope list meta.sr.ht renders no checkbox for this service, so no PAT
could be scoped for it even in principle — no amount of code in graph/
could have worked around that. It now publishes SPECS, derived from
authn.ScopeRead rather than spelled twice, with a test on each side
asserting the two agree. A scope published and not checked admits what
should be refused, one checked and not published cannot be minted at
all, and neither is visible from inside one file.

The plane is built in cmd/specsrht beside the graph.New call and NOT on
the *authn.Resolver every surface shares, which is the whole scope of
the exception: the REST write plane, /mcp and the push hook resolve
through that resolver, which knows nothing of PlaneMeta, so a PAT is not
a way around the tokens.sr.ht grant they require. Routing is
metapat.PlaneOf on the presented credential rather than a fall-through
from a failed plane, because an instance with no [tokens.sr.ht] section
has no working-token validator to fail first.

Two things this service needs that the cov and bench donors did not.
MetaAuth is told the instance owner and refuses a PAT belonging to
anybody else with the same ErrNotInstanceOwner a foreign working token
gets: a PAT is the credential every account on the instance can mint for
itself, so without that check the widest credential in existence would
have been the one that skipped the narrowest identity rule, and any user
could have read the corpus. And a PAT resolves to KindAgent, never
KindOwner, so webhook management and proposal approval stay with the
human at a browser.

The sentinels are spec's own — ErrMissingScope, ErrInvalidPersonalToken,
ErrMetaUnavailable — rather than reuses of bearer's, because bearer's
prose names tokens.sr.ht and every word of it is wrong for this plane;
an operator reading "tokens.sr.ht could not be reached" while that
daemon is up goes looking in the wrong place. What must agree between
the planes is the status, not the wording, and StatusFor is the one
table that decides it for both.

The two vocabularies do not overlap and are refused differently, which
the tests pin: a PAT naming spec.sr.ht/spec:read is a well-formed OAuth
grant for a scope this service does not publish (403), while a working
token naming spec.sr.ht/SPECS does not parse as a grant string at all
(401). A 403 names the permission of the plane the caller actually used
— telling a PAT holder to obtain "spec:read" would send them looking for
a checkbox meta.sr.ht cannot render.

cmd/specsrht's api-meta assertion is not Postgres-gated here, unlike the
bench donor's: it ran locally and went red on the stale empty-list
assertion, rather than hiding until CI.
M authn/bearer.go => authn/bearer.go +43 -29
@@ 67,8 67,11 @@ type UserLookup interface {
}

// resolveInstanceToken runs the tokens.sr.ht plane against a presented bearer
// credential. Its answer is final: there is one agent credential plane, so a
// refusal here is the service's refusal.
// credential. Its answer is final: this plane has nothing behind it, so a refusal
// here is the service's refusal on every surface that reaches it. /query is the
// one that may not — it routes a PAT to MetaAuth before asking this — and that
// choice is made on the credential, upstream, rather than by this function
// handing a refusal on to somebody else.
//
// It used to report a third thing — whether the caller should fall back to
// spec's own agent_token store — and exactly two refusals said yes:


@@ 81,8 84,9 @@ type UserLookup interface {
//     that would miss.
//
// With that store gone both are plain refusals. The one consequence worth
// naming is ErrNotOurs: IsAuthFailure now counts it permanent, so a meta PAT
// presented here earns a 401 rather than the 503 an unclassified error would.
// naming is ErrNotOurs: IsAuthFailure counts it permanent, so a meta PAT that
// does reach this plane earns a 401 rather than the 503 an unclassified error
// would.
//
// The rest of the mapping is unchanged and lives in StatusFor: ErrInvalid and
// ErrRevoked are 401, ErrForbidden and a foreign owner are 403, and


@@ 148,16 152,20 @@ func instanceTokenLabel(tok *bearer.Token) string {
	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.
// StatusFor maps an error out of Resolve — or out of a later Authorize, or out
// of MetaAuth.VerifyToken — onto the status the surface must answer with. It is
// one function so that the surfaces cannot each invent their own table, and one
// function across both credential planes so that the two cannot answer a client
// differently for the same kind of failure. That is the whole of what makes them
// consistent: the prose of a refusal is per-plane and its status is not.
//
// Everything the credential itself can be wrong about is bearer.StatusFor's
// answer, not a second copy of it: ErrForbidden is 403, ErrUnavailable is 503
// and never 401, and ErrInvalid, ErrRevoked and ErrNotOurs are 401. That last
// arm is only reached because ErrNotOurs is decided before we ask — a meta.sr.ht
// PAT used to fall through to spec's own token store, and with that store gone
// it is a refusal at the door.
// it is a refusal at the door of every surface but /query, which routes one to
// MetaAuth instead.
//
// The ErrUnavailable line is the one worth restating even though it is no longer
// spelled here. Reading "I could not reach tokens.sr.ht" as "your token is


@@ 167,31 175,37 @@ func instanceTokenLabel(tok *bearer.Token) string {
//
// What this function adds is what bearer cannot know:
//
//   - 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. Both are asked before the
//     bearer table, because ErrMissingGrant is raised beside a token that
//     verified and must not be read as one that did not.
//   - Whatever else IsAuthFailure calls permanent is 401 — today that is
//     ErrNoToken, nothing having been presented on a surface that requires a
//     credential. The predicate is asked rather than the sentinel listed a second
//     time, so that a sentinel added to one of them cannot be missing from the
//     other: this package's two answers to "is the credential the problem?" have
//     to agree, and the cheapest way to guarantee that is for one to be built
//     from the other.
//   - ErrNoAgentPlane, and anything else at all, is 503. An instance with no
//     [tokens.sr.ht] origin cannot check any credential, and telling the holder
//     of a good token that it is bad would send them to re-provision it; an
//     unclassified error is a backend that could not answer. This is where the
//     two tables' defaults deliberately differ — bearer's unrecognised failure
//     is the caller's credential, because everything reaching it is about a
//     credential, while an unrecognised failure here can be the database this
//     resolver had to consult, which must never read as a bad token.
//   - ErrMissingGrant, ErrMissingScope 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 permission, not another login. All
//     three are asked before the bearer table, because each is raised beside a
//     token that verified and must not be read as one that did not.
//   - Whatever else IsAuthFailure calls permanent is 401 — ErrNoToken, nothing
//     having been presented on a surface that requires a credential, and
//     ErrInvalidPersonalToken, the meta plane's counterpart of the bearer
//     sentinels above. The predicate is asked rather than its members listed a
//     second time, so that a sentinel added to one of them cannot be missing from
//     the other: this package's two answers to "is the credential the problem?"
//     have to agree, and the cheapest way to guarantee that is for one to be
//     built from the other.
//   - ErrNoAgentPlane, ErrMetaUnavailable, and anything else at all, is 503. An
//     instance with no [tokens.sr.ht] origin cannot check any credential, a
//     meta.sr.ht that will not answer means this one could not be checked, and
//     telling the holder of a good token that it is bad would send them to
//     re-provision it; an unclassified error is a backend that could not answer.
//     This is where the two tables' defaults deliberately differ — bearer's
//     unrecognised failure is the caller's credential, because everything reaching
//     it is about a credential, while an unrecognised failure here can be the
//     database this resolver had to consult, which must never read as a bad token.
//     The meta plane leans on that default rather than being listed: its
//     classification is written to land on this arm for a sentinel nobody has seen
//     before, which is the fail-closed direction.
func StatusFor(err error) int {
	switch {
	case err == nil:
		return http.StatusOK
	case errors.Is(err, ErrMissingGrant), errors.Is(err, ErrNotInstanceOwner):
	case errors.Is(err, ErrMissingGrant), errors.Is(err, ErrMissingScope),
		errors.Is(err, ErrNotInstanceOwner):
		return http.StatusForbidden
	case isBearerRefusal(err):
		return bearer.StatusFor(err)

M authn/doc.go => authn/doc.go +102 -24
@@ 14,29 14,52 @@
// (agents may only write proposals/*), and that lives in gitx; nothing here
// replaces it.
//
// # One agent credential plane
// # Two agent credential planes, and one surface that takes the second
//
// An agent is recognised by a tokens.sr.ht working token — PlaneInstance:
// 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 it is the only credential this service authenticates an
// agent with.
//
// spec used to mint its own as well — the agent_token row: one instance-wide
// shared secret, hashed at rest, with no owner, no expiry and no grants. That
// plane is gone. Issuance is centralised in tokens.sr.ht, so there is one door
// and nothing behind it: a credential this plane refuses is refused, rather than
// being offered to a second store that might say yes. A well-formed token from
// another issuer (a meta.sr.ht PAT — bearer.ErrNotOurs) used to fall through to
// that store and now fails at the door, which is the same answer one hash lookup
// later, said honestly.
//
// The instance plane names an owner where the local secret had none.
// bearer package, and it is the credential of every surface this service has:
// the REST write plane, /mcp, the machine formats of the read plane, and the
// `git push` hook path.
//
// /query takes a second one — PlaneMeta, an ordinary meta.sr.ht personal access
// token scoped by ScopeRead — and it takes it because of api.sr.ht rather than
// because a second credential is desirable. The gateway forwards ONE client
// Authorization header to every service a federated query touches: its
// AuthMiddleware copies the client's header verbatim into the request context,
// and the internal credential it can mint is used only to fetch schemas at
// startup. So a federated caller arrives here holding whatever credential the
// client had, and the only credential a client can hold that works across the
// whole instance is a meta PAT. An endpoint that refuses one can never be
// federated: adding it to the gateway would be one `api-origin=` line that
// produces 401s.
//
// The scope of that exception is narrow and is kept narrow structurally rather
// than by discipline. MetaAuth is not part of Resolver, so nothing that resolves
// identity through Resolver can produce a PlaneMeta principal — graph builds the
// plane itself, wired from cmd/specsrht at the single call site that may use it,
// and no other surface holds one. A personal access token is therefore not a way
// around the tokens.sr.ht grant an upload, an MCP tool or a push requires.
//
// spec used to mint its own credential as well — the agent_token row: one
// instance-wide shared secret, hashed at rest, with no owner, no expiry and no
// grants. That plane is gone. Issuance is centralised — tokens.sr.ht mints a
// working token, meta.sr.ht mints a PAT — so each plane has one door and nothing
// behind it: a credential a plane refuses is refused, rather than being offered
// to a second store that might say yes. A meta PAT presented to any surface but
// /query is bearer.ErrNotOurs and fails at that door, which is the same answer
// one hash lookup later, said honestly.
//
// Both credential planes name an owner where the local secret had none.
// Principal.Owner means "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.
// principal kind, the coreauth AuthContext) is written for one human. That rule
// is why MetaAuth is told the owner too: a PAT is the credential every account on
// the instance can mint, so without it the widest possible credential would be
// the one that skipped the narrowest check.
//
// 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


@@ 86,10 109,13 @@ import (
// permanent (the credential is bad — 401/403) versus transient (the backend
// could not answer — 503); IsAuthFailure draws it.
//
// Everything the credential itself can be wrong about is now spelled by
// sr-ht-ecore's bearer package — ErrInvalid, ErrNotOurs, ErrRevoked — because
// there is one issuer and one validator. The sentinels below are what this
// service adds on top of that answer.
// Everything a working token can be wrong about is spelled by sr-ht-ecore's
// bearer package — ErrInvalid, ErrNotOurs, ErrRevoked — because that plane has
// one issuer and one validator. The sentinels below are what this service adds
// on top of that answer, and they now include the meta plane's three: its
// validator's refusals are metapat's, and its prose has to name meta.sr.ht where
// bearer's names tokens.sr.ht, while the status each one maps to is deliberately
// identical. See ScopeRead and MetaAuth.
var (
	// ErrNoToken is returned when a bearer credential was expected but the
	// request carried no Authorization header, or one in another scheme.


@@ 138,6 164,50 @@ var (
	// 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")

	// ErrMissingScope marks a meta.sr.ht personal access token that authenticated
	// fine and was not minted to read through this service: it does not carry
	// ScopeRead. 403, exactly as ErrMissingGrant is and for the same reason.
	//
	// It is deliberately not ErrMissingGrant. The two name permissions in
	// vocabularies that do not overlap — no PAT can carry "spec:read", because
	// meta's personal-token page cannot spell it, and no working token can carry
	// "spec.sr.ht/SPECS", because ecore's grants grammar does not read that shape
	// — so a refusal has to name the one the caller can actually go and obtain.
	// A PAT holder sent looking for "spec:read" would be hunting a checkbox that
	// does not exist.
	//
	// Unlike ErrMissingGrant it is raised at resolution rather than at the router,
	// which is not an inconsistency: the plane that raises it guards /query alone,
	// every field of which is a read, so the action is known before the router.
	ErrMissingScope = errors.New("token does not carry the required OAuth scope")

	// ErrInvalidPersonalToken marks a personal access token this service will not
	// accept: the signature or the expiry did not hold, meta.sr.ht reports it
	// revoked, or it names an account meta will not resolve. 401 through
	// IsAuthFailure, beside the bearer sentinels that say the same thing about the
	// other plane.
	//
	// It is spec's own rather than a reuse of bearer.ErrInvalid because bearer's
	// sentinels are worded about tokens.sr.ht ("bearer: token was not issued by
	// tokens.sr.ht"), and for this plane every one of those words is wrong. The
	// status is what has to agree between the two planes, not the prose.
	ErrInvalidPersonalToken = errors.New("personal access token was refused")

	// ErrMetaUnavailable marks a personal access token that could not be
	// *checked*: the profile mirror or the revocation lookup at meta.sr.ht did not
	// answer.
	//
	// It is pointedly absent from IsAuthFailure, so StatusFor's fail-closed
	// default answers 503. "I could not decide" is not "your credential is bad",
	// and answering 401 to a meta.sr.ht restart would tell every federated client
	// on the instance to go and re-mint credentials that were never broken.
	//
	// It is not bearer.ErrUnavailable for the same reason as above, sharpened: that
	// sentinel says tokens.sr.ht could not be reached, and tokens.sr.ht can be
	// perfectly healthy while this is raised. An operator reading the wrong daemon
	// out of a log line goes and looks in the wrong place.
	ErrMetaUnavailable = errors.New("meta.sr.ht could not be reached")
)

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


@@ 148,13 218,21 @@ var (
//
// bearer.ErrNotOurs joined the set when the local plane left it. A meta.sr.ht
// PAT used to fall through to spec's own store, where it missed; with one door
// there is nothing to fall through to, and "that credential was issued by
// somebody whose tokens this service does not take" is as permanent a refusal as
// a signature that does not verify. bearer.ErrUnavailable is pointedly absent —
// see StatusFor, which is what surfaces should map with.
// per plane there is nothing to fall through to, and "that credential was issued
// by somebody whose tokens this surface does not take" is as permanent a refusal
// as a signature that does not verify. It is still the answer everywhere but
// /query, which routes a PAT to MetaAuth before this plane is asked and so never
// reaches it — see graph's resolveCaller.
//
// ErrInvalidPersonalToken is that plane's counterpart and sits here for the same
// reason its siblings do: what makes the two planes consistent is that a client
// gets the same status for the same kind of failure, whichever credential it
// presented. bearer.ErrUnavailable and ErrMetaUnavailable are both pointedly
// absent — see StatusFor, which is what surfaces should map with.
func IsAuthFailure(err error) bool {
	return errors.Is(err, ErrNoToken) ||
		errors.Is(err, bearer.ErrInvalid) ||
		errors.Is(err, bearer.ErrNotOurs) ||
		errors.Is(err, bearer.ErrRevoked)
		errors.Is(err, bearer.ErrRevoked) ||
		errors.Is(err, ErrInvalidPersonalToken)
}

A authn/meta.go => authn/meta.go +263 -0
@@ 0,0 1,263 @@
package authn

import (
	"context"
	"errors"
	"fmt"
	"strings"

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

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

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

// ScopeRead is the OAuth grant a meta.sr.ht personal access token must carry to
// read through this service: "spec.sr.ht/SPECS", at :RO or better.
//
// It is meta.sr.ht's vocabulary and it is not ActionRead spelled differently.
// The two grammars share a token format and nothing else: ActionRead
// ("spec:read") is what tokens.sr.ht seals into a working token, and no PAT can
// carry it because meta's personal-token page cannot spell it; ScopeRead is a
// checkbox on that page, and no working token can carry it because ecore's
// grants parser does not read that shape at all. A credential has one or the
// other and never both, which is why the two planes are asked different
// questions about the same request (Principal.Authorize, metapat.Allows).
//
// The service half is ConfigSection, so a rename breaks the build here rather
// than leaving behind a scope nobody can be granted. The bare half — "SPECS" —
// is what api-meta.json publishes, because meta prefixes the service name
// itself; cmd/specsrht derives that list from this constant rather than spelling
// it a second time, and a test on each side asserts the two agree. A scope
// published and not checked admits what should have been refused; one checked
// and not published cannot be minted at all; neither failure is visible from
// inside a single file.
const ScopeRead = ConfigSection + "/SPECS"

// MetaValidator is the sliver of sr-ht-ecore's metapat.Validator this plane
// needs.
//
// The interface is declared in the consumer, as BearerValidator and UserLookup
// are and for the same reasons: it states exactly how much of the shared
// validator this package depends on — one method — and it is what keeps authn
// testable with no meta.sr.ht, no network and no Postgres. The concrete
// validator reaches core-go's auth.LookupUser, which reads a database handle out
// of the context and panics without one; holding it behind an interface is what
// keeps that dependency in cmd/ where the wiring lives, exactly as this package's
// doc comment promises.
type MetaValidator interface {
	// Resolve verifies the signature and expiry locally, refuses a token sealed
	// by tokens.sr.ht, mirrors the owner's profile and asks meta.sr.ht whether
	// the token has been revoked. It answers with core-go's own OAuth2 caller, or
	// with one of the metapat package's sentinels.
	Resolve(ctx context.Context, presented string) (*auth.AuthContext, error)
}

// Compile-time proof that the shared validator satisfies the port. It is what
// lets this package depend on the interface rather than on *metapat.Validator,
// and it fails the build the moment either side drifts.
var _ MetaValidator = (*metapat.Validator)(nil)

// MetaAuth is the meta.sr.ht plane: an ordinary personal access token, the
// credential every upstream service on this instance already accepts.
//
// # Why spec.sr.ht accepts one at all
//
// It did not, and the reason it does now is the gateway. api.sr.ht forwards ONE
// client "Authorization" header to every service a federated query touches — its
// AuthMiddleware copies the client's header verbatim into the request context,
// and the internal credential it can mint is used only to fetch schemas at
// startup — so a federated caller arrives holding whatever credential the client
// had. The only credential that works across the whole instance is a meta PAT,
// so an endpoint that refuses them answers 401 to the first authenticated
// federated query and can never be part of the gateway's schema, however correct
// each individual refusal looks.
//
// # Where it is and is not accepted
//
// This plane is deliberately NOT part of Resolver. It is reachable from /query
// alone, which is the only surface with the federation problem; the REST write
// plane, /mcp and the push hook keep asking for a tokens.sr.ht working token,
// where a narrow, short-lived, revocable grant is worth what it costs an agent to
// obtain. Nothing on those paths holds a MetaAuth, so nothing on them can produce
// a PlaneMeta principal, and a PAT is not a way around a grant.
//
// # What it is left deciding
//
// It owns no crypto and no lookups: verification is ecore's metapat, one copy of
// that check for every service on the instance. Three questions are this
// service's own and are why the type exists at all — whether the token's owner is
// the one human this instance answers to, whether it carries the scope, and which
// failures of the shared validator are a bad credential, which are a missing
// permission, and which are an outage.
type MetaAuth struct {
	validator MetaValidator
	owner     string
	scope     string
}

// NewMetaAuth builds the plane over a validator, the instance owner username
// from [sr.ht] owner-name, and the OAuth scope a token must carry to read
// through this service.
//
// Each argument is refused rather than tolerated when it is empty, and the three
// reasons are different:
//
//   - A nil validator is not the "this instance has no such plane" configuration.
//     That one is a nil *MetaAuth, which graph.New refuses outright because the
//     plane needs no per-instance origin and so can never be legitimately absent.
//     A validator-less plane would fail every PAT while looking configured.
//   - An empty owner would compare every token's account against "", so either no
//     PAT would ever be admitted, or — worse, if the comparison were ever loosened
//     — the check that makes this a single-owner instance would be the one that
//     silently did nothing. NewResolver validates its owner for the same reason
//     and with the same rule, so the two planes cannot disagree about who bigbes
//     is.
//   - An empty scope would have metapat.Allows asked about the grant name "",
//     which no token carries and no meta checkbox can mint, so every PAT on the
//     instance would be refused with a message naming a permission that does not
//     exist.
func NewMetaAuth(validator MetaValidator, owner, scope string) (*MetaAuth, error) {
	if validator == nil {
		return nil, fmt.Errorf("authn: nil MetaValidator")
	}
	owner = strings.TrimPrefix(owner, "~")
	if err := core.ValidateOwner(owner); err != nil {
		return nil, fmt.Errorf("authn: meta plane instance owner: %w", err)
	}
	if scope == "" {
		return nil, fmt.Errorf("authn: empty OAuth scope, e.g. %s", ScopeRead)
	}
	return &MetaAuth{validator: validator, owner: owner, scope: scope}, nil
}

// Scope is the OAuth grant this plane requires, for a caller that wants to name
// it in a refusal without spelling it a second time.
func (a *MetaAuth) Scope() string { return a.scope }

// Owner is the instance owner username this plane admits tokens for.
func (a *MetaAuth) Owner() string { return a.owner }

// VerifyToken validates a presented personal access token and returns the
// principal of its owner.
//
// presented is the bare credential, with the "Bearer " scheme already stripped.
//
// The principal is KindAgent, never KindOwner, even though the token belongs to
// the instance owner's own meta.sr.ht account. That is the security decision of
// this whole change: KindOwner may approve proposals and manage webhooks, and it
// is reached by a unified-login cookie — a browser session a human is sitting in
// front of — while a PAT is a bearer string any process holding it can present,
// forwarded through a gateway by whatever client asked. Reading one as the owner
// would hand the approved branch to the widest credential on the instance.
//
// It carries no Grants either: those are tokens.sr.ht's vocabulary and a PAT is
// in meta's, which is checked here instead — once, against the scope this plane
// was built with, because the whole surface it guards is a read. That is also why
// Principal.Authorize passes a PlaneMeta caller through: asking it for a grant a
// PAT can never carry would refuse every one of them.
//
// Agent identity and session are left empty, and this plane is given the
// credential rather than the request precisely so that they cannot be otherwise.
// Provenance is demanded at a write (AgentWrite.Validate) and /query performs
// none — its only mutations are the webhook ones, which webhookAuthorized
// restricts to KindOwner — so there is nothing here to attribute. A plane handed
// the request could also read a cookie off it, which would quietly reintroduce
// the ambient authority this endpoint exists without.
//
// The failures are this package's sentinels, so that graph turns them into
// statuses through the same StatusFor table it already uses for the other plane.
func (a *MetaAuth) VerifyToken(ctx context.Context, presented string) (Principal, error) {
	if presented == "" {
		return Anonymous(), ErrNoToken
	}

	ac, err := a.validator.Resolve(ctx, presented)
	if err != nil {
		return Anonymous(), classifyResolve(err)
	}
	if ac.UserID == 0 {
		// metapat refuses this itself; the check is kept because the seam is an
		// interface, and a validator that answered with an empty context must not
		// produce a principal that owns whichever row has an unset owner id.
		return Anonymous(), fmt.Errorf(
			"%w: it resolved to no meta.sr.ht user id", ErrInvalidPersonalToken)
	}

	// The token names a meta.sr.ht account, and spec.sr.ht has exactly one that
	// means anything. This is the rule resolveInstanceToken applies to a working
	// token and the cookie plane applies to a session, and it matters most here:
	// a PAT is the credential every account on the instance can mint for itself,
	// so without this check the widest credential in existence would be the one
	// that skipped the narrowest identity rule, and any user of the instance could
	// read the whole corpus through /query.
	//
	// 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(ac.Username, "~")
	if username != a.owner {
		return Anonymous(), fmt.Errorf(
			"%w: the personal access token belongs to ~%s, and this instance answers only to ~%s",
			ErrNotInstanceOwner, username, a.owner)
	}

	if !metapat.Allows(ac, a.scope, auth.RO) {
		return Anonymous(), fmt.Errorf(
			"%w: this personal access token does not carry %s", ErrMissingScope, a.scope)
	}

	return Principal{
		Kind:  KindAgent,
		Owner: a.owner,
		// Diagnostics only, as on the other plane. There is no row id to print:
		// meta.sr.ht does not number a PAT the way tokens.sr.ht numbers a working
		// token, and naming the plane is the useful half anyway — it says which of
		// two credentials a log line is about.
		TokenName: "meta.sr.ht personal access token",
		Plane:     PlaneMeta,
		UserID:    ac.UserID,
	}, nil
}

// classifyResolve maps metapat's sentinels onto this package's, and is the one
// place spec.sr.ht decides what each refusal of the shared PAT validator means
// here.
//
// It is deliberately the same shape as the tokens.sr.ht plane's mapping, arm for
// arm — resolveInstanceToken wraps bearer's sentinels and StatusFor reads them —
// because the two planes owe the surfaces above the same three answers, 401, 403
// and 503. A difference between the tables would be a difference in what a client
// is told about the same kind of failure, decided by which credential it happened
// to be holding.
//
// metapat.ErrNotOurs is classified for totality and is not reachable in
// production: graph routes on metapat.PlaneOf before this plane is asked, so a
// working token has already gone to the other one. Were it ever to arrive here it
// is a bad credential *for this plane*, and 401 is the honest answer.
//
// An unrecognised error is ErrMetaUnavailable, and that is the fail-closed
// direction rather than a shrug: a sentinel this table has never seen must read
// as "I could not decide" — a 503 the caller retries — and never as a verdict
// about the credential. Answering 401 to something this function does not
// understand would tell a client to re-mint a token that may be perfectly good.
func classifyResolve(err error) error {
	switch {
	case errors.Is(err, metapat.ErrInvalid),
		errors.Is(err, metapat.ErrNotOurs),
		errors.Is(err, metapat.ErrRevoked):
		return fmt.Errorf("%w: %w", ErrInvalidPersonalToken, err)
	case errors.Is(err, metapat.ErrForbidden):
		// Not reachable through Resolve, which is never told a scope; classified
		// so that the table is total.
		return fmt.Errorf("%w: %w", ErrMissingScope, err)
	case errors.Is(err, metapat.ErrUnavailable):
		// meta.sr.ht and not tokens.sr.ht, which is the entire reason this
		// sentinel exists beside bearer.ErrUnavailable rather than reusing it.
		return fmt.Errorf("%w: %w", ErrMetaUnavailable, err)
	default:
		return fmt.Errorf("%w: validating a meta.sr.ht personal access token: %w",
			ErrMetaUnavailable, err)
	}
}

A authn/meta_test.go => authn/meta_test.go +331 -0
@@ 0,0 1,331 @@
package authn

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

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

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

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

// The meta.sr.ht plane as a unit: what it makes of an answer from the shared
// validator, and what each of that validator's refusals means here.
//
// graph/credential_test.go drives the same plane through a real handler with a
// real metapat.Validator behind it. These go in at the seam instead, because the
// arms that matter most — a meta.sr.ht that will not answer, a sentinel nobody
// has written yet — cannot be provoked from the outside without breaking
// something on purpose.

// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------

// stubPATs is an in-memory MetaValidator: it answers with whatever the test put
// in it, which is how every arm of the classification below is exercised with no
// meta.sr.ht, no network and no database.
type stubPATs struct {
	ac    *auth.AuthContext
	err   error
	calls int
}

func (s *stubPATs) Resolve(context.Context, string) (*auth.AuthContext, error) {
	s.calls++
	if s.err != nil {
		return nil, s.err
	}
	return s.ac, nil
}

// resolvedPAT is what metapat.Resolve leaves behind for a live personal access
// token of username carrying grantString: core-go's own OAuth2 caller, with the
// grants decoded in meta's vocabulary.
//
// They are decoded through a context naming this service because
// auth.DecodeGrants reads that name to expand a grant written without one — and
// panics when nothing put it there. That is the trap metapat.Options.Service
// exists to close, met here from the other side.
func resolvedPAT(t *testing.T, username, grantString string) *auth.AuthContext {
	t.Helper()
	g, err := auth.DecodeGrants(
		config.Context(context.Background(), ini.File{}, ConfigSection), grantString)
	require.NoError(t, err)

	return &auth.AuthContext{
		AuthMethod:  auth.AUTH_OAUTH2,
		UserID:      1,
		Username:    username,
		Email:       username + "@example.org",
		BearerToken: &auth.BearerToken{Username: username, Grants: grantString},
		Grants:      g,
	}
}

// newMetaAuth builds the plane over a validator that answers every credential
// the same way, for the instance owner these tests use throughout.
func newMetaAuth(t *testing.T, ac *auth.AuthContext, resolveErr error) (*MetaAuth, *stubPATs) {
	t.Helper()
	stub := &stubPATs{ac: ac, err: resolveErr}
	plane, err := NewMetaAuth(stub, "bigbes", ScopeRead)
	require.NoError(t, err)
	return plane, stub
}

// errUnknownToTheTable is a refusal this package's classification has never seen.
// It stands for the sentinel a future version of metapat adds without telling
// anybody, which is the case the fail-closed default exists for.
var errUnknownToTheTable = errors.New("metapat: something nobody has classified")

// ---------------------------------------------------------------------------
// Construction
// ---------------------------------------------------------------------------

func TestNewMetaAuthRefusesAPlaneThatCannotWork(t *testing.T) {
	t.Run("a nil validator", func(t *testing.T) {
		// Not the "this instance has no such plane" configuration — that one is a
		// nil *MetaAuth, and graph.New refuses even that — but a plane that would
		// fail every PAT while looking configured.
		_, err := NewMetaAuth(nil, "bigbes", ScopeRead)
		require.Error(t, err)
		assert.Contains(t, err.Error(), "MetaValidator")
	})

	t.Run("an empty owner", func(t *testing.T) {
		// Every token's account would be compared against "", so the check that
		// makes this a single-owner instance would be the one silently doing
		// nothing.
		_, err := NewMetaAuth(&stubPATs{}, "", ScopeRead)
		require.Error(t, err)
		assert.Contains(t, err.Error(), "owner")
	})

	t.Run("an owner that is not a usable name", func(t *testing.T) {
		// The same rule NewResolver applies, so the two planes cannot disagree
		// about who the instance owner is.
		_, err := NewMetaAuth(&stubPATs{}, "not a username", ScopeRead)
		require.Error(t, err)
	})

	t.Run("an empty scope", func(t *testing.T) {
		// metapat.Allows would be asked about the grant name "", which no token
		// carries and no meta checkbox can mint: every PAT on the instance
		// refused, with a message naming a permission that does not exist.
		_, err := NewMetaAuth(&stubPATs{}, "bigbes", "")
		require.Error(t, err)
		assert.Contains(t, err.Error(), "scope")
	})
}

func TestTheOwnerAndScopeAreSpelledOnceAndReadBack(t *testing.T) {
	plane, _ := newMetaAuth(t, nil, nil)
	assert.Equal(t, ScopeRead, plane.Scope())
	assert.Equal(t, "bigbes", plane.Owner())

	// The leading '~' is a display convention, not part of the name, and both
	// planes strip it — a resolver built with "~bigbes" and one built with
	// "bigbes" must admit the same tokens.
	tilde, err := NewMetaAuth(&stubPATs{}, "~bigbes", ScopeRead)
	require.NoError(t, err)
	assert.Equal(t, "bigbes", tilde.Owner())

	// The two halves the instance has to agree on: what meta prefixes into a
	// checkbox, and what this service checks.
	assert.Equal(t, ScopeRead, metapat.Scope(ConfigSection, metapat.ScopeName(ScopeRead)))
}

// ---------------------------------------------------------------------------
// VerifyToken
// ---------------------------------------------------------------------------

func TestAPersonalAccessTokenCarryingTheScopeIsAnAgent(t *testing.T) {
	plane, stub := newMetaAuth(t, resolvedPAT(t, "bigbes", ScopeRead+":RO"), nil)

	p, err := plane.VerifyToken(t.Context(), "presented")
	require.NoError(t, err)

	assert.Equal(t, 1, stub.calls)
	assert.Equal(t, KindAgent, p.Kind)
	assert.Equal(t, PlaneMeta, p.Plane)
	assert.Equal(t, "bigbes", p.Owner)
	assert.Equal(t, 1, p.UserID, "the row id every user-scoped lookup keys off")
	assert.True(t, p.CanRead())

	// It may read and it may not approve: KindOwner is the human at a browser,
	// and a PAT is a string any process holding it can present.
	assert.False(t, p.IsOwner())

	// It carries no tokens.sr.ht grants, and must not be asked for any — no PAT
	// can ever be minted with spec:read, so a grant check here would refuse every
	// one of them rather than scope it.
	assert.NoError(t, p.Authorize(ActionRead))
	assert.NoError(t, p.Authorize(ActionPropose))
}

func TestAnUngrantedPersonalAccessTokenIsUniversal(t *testing.T) {
	// meta.sr.ht mints a token with no grants selected and core-go reads that as
	// every permission (auth.Grants.HasAll). Refusing it here would refuse the
	// commonest credential on the instance — and the one a federated query is
	// most likely to arrive holding.
	plane, _ := newMetaAuth(t, resolvedPAT(t, "bigbes", ""), nil)

	p, err := plane.VerifyToken(t.Context(), "presented")
	require.NoError(t, err)
	assert.Equal(t, PlaneMeta, p.Plane)
}

func TestAPersonalAccessTokenWithoutTheScopeIsForbidden(t *testing.T) {
	plane, _ := newMetaAuth(t, resolvedPAT(t, "bigbes", "meta.sr.ht/PROFILE:RO"), nil)

	p, err := plane.VerifyToken(t.Context(), "presented")
	require.ErrorIs(t, err, ErrMissingScope)
	assert.Equal(t, http.StatusForbidden, StatusFor(err),
		"403: the credential is fine, the permission is not")
	assert.False(t, IsAuthFailure(err), "401 would send the holder re-minting a good token")
	assert.True(t, p.IsAnonymous())
	assert.Contains(t, err.Error(), ScopeRead, "the refusal must name what to go and obtain")
}

func TestAPersonalAccessTokenOfAnotherOwnerIsForbidden(t *testing.T) {
	// The single-owner rule, applied to the credential every account on the
	// instance can mint for itself. Without it the widest credential in existence
	// would be the one that skipped the narrowest identity check.
	//
	// 403 and not 401, and the same sentinel a foreign working token gets: the
	// token verifies and the holder is who they say they are, there is simply
	// nothing on this instance to grant them, so retrying will not help.
	plane, _ := newMetaAuth(t, resolvedPAT(t, "somebody-else", ScopeRead), nil)

	p, err := plane.VerifyToken(t.Context(), "presented")
	require.ErrorIs(t, err, ErrNotInstanceOwner)
	assert.Equal(t, http.StatusForbidden, StatusFor(err))
	assert.True(t, p.IsAnonymous())

	// The refusal is about whose token it is and not about what it carries: this
	// one holds the scope and is refused anyway, because a scope says what a
	// token may do and never whose it is.
	assert.NotErrorIs(t, err, ErrMissingScope)
}

func TestTheOwnerIsRecognisedThroughATildeInTheToken(t *testing.T) {
	// meta.sr.ht writes usernames both ways depending on the call, and the other
	// plane already strips one leading '~' before comparing. A plane that did not
	// would refuse the instance owner's own token half the time.
	plane, _ := newMetaAuth(t, resolvedPAT(t, "~bigbes", ScopeRead), nil)

	p, err := plane.VerifyToken(t.Context(), "presented")
	require.NoError(t, err)
	assert.Equal(t, "bigbes", p.Owner)
}

func TestAnAbsentCredentialIsNotARefusal(t *testing.T) {
	// ErrNoToken is what the caller reads as "nothing was presented" rather than
	// as a rejection, and nothing is asked of meta.sr.ht for it.
	plane, stub := newMetaAuth(t, nil, nil)

	_, err := plane.VerifyToken(t.Context(), "")
	require.ErrorIs(t, err, ErrNoToken)
	assert.Zero(t, stub.calls, "nothing to validate, so nothing is asked")
}

func TestAResolvedCallerWithNoIDIsRefused(t *testing.T) {
	// metapat refuses this itself; the check is kept because the seam is an
	// interface, and a zero id downstream would own whichever row has an unset
	// owner.
	ac := resolvedPAT(t, "bigbes", ScopeRead)
	ac.UserID = 0
	plane, _ := newMetaAuth(t, ac, nil)

	_, err := plane.VerifyToken(t.Context(), "presented")
	require.ErrorIs(t, err, ErrInvalidPersonalToken)
	assert.True(t, IsAuthFailure(err))
	assert.Equal(t, http.StatusUnauthorized, StatusFor(err))
}

// ---------------------------------------------------------------------------
// classifyResolve — the table the surfaces turn into statuses
// ---------------------------------------------------------------------------

func TestEveryRefusalOfTheSharedValidatorIsClassified(t *testing.T) {
	cases := []struct {
		name   string
		from   error
		want   error
		status int
	}{
		{
			name: "a forged or expired token", from: metapat.ErrInvalid,
			want: ErrInvalidPersonalToken, status: http.StatusUnauthorized,
		},
		{
			name: "a revoked token", from: metapat.ErrRevoked,
			want: ErrInvalidPersonalToken, status: http.StatusUnauthorized,
		},
		{
			// Unreachable in production — graph routes on metapat.PlaneOf before
			// this plane is asked — and classified so the table is total. Were it
			// ever to arrive it is a bad credential *for this plane*.
			name: "a working token that reached the wrong plane", from: metapat.ErrNotOurs,
			want: ErrInvalidPersonalToken, status: http.StatusUnauthorized,
		},
		{
			// Resolve is never told a scope, so it cannot raise this; classified
			// so the table is total.
			name: "a scope refusal from the validator itself", from: metapat.ErrForbidden,
			want: ErrMissingScope, status: http.StatusForbidden,
		},
		{
			// The arm that has to be defended: "I could not check" is not "your
			// credential is bad", and 401 here would tell every federated client
			// on the instance to re-mint over a meta.sr.ht restart.
			name: "meta.sr.ht unreachable", from: metapat.ErrUnavailable,
			want: ErrMetaUnavailable, status: http.StatusServiceUnavailable,
		},
		{
			// Fail-closed: a sentinel this table has never seen must read as "I
			// could not decide", never as a verdict about the credential.
			name: "a sentinel nobody has classified yet", from: errUnknownToTheTable,
			want: ErrMetaUnavailable, status: http.StatusServiceUnavailable,
		},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			plane, _ := newMetaAuth(t, nil, tc.from)

			p, err := plane.VerifyToken(t.Context(), "presented")
			require.ErrorIs(t, err, tc.want)
			assert.ErrorIs(t, err, tc.from, "the cause must survive for the log")
			assert.Equal(t, tc.status, StatusFor(err))
			assert.True(t, p.IsAnonymous(), "a refused caller carries no authority")
		})
	}
}

// The two planes owe the surfaces above the same three answers, so their tables
// must not drift apart. This asserts the property rather than the code: every
// refusal this plane can produce lands on exactly one of 401, 403 and 503, and
// never on 200.
func TestTheClassificationIsTotalAndUnambiguous(t *testing.T) {
	for _, from := range []error{
		metapat.ErrInvalid, metapat.ErrNotOurs, metapat.ErrRevoked,
		metapat.ErrForbidden, metapat.ErrUnavailable, errUnknownToTheTable,
	} {
		plane, _ := newMetaAuth(t, nil, from)
		_, err := plane.VerifyToken(t.Context(), "presented")
		require.Error(t, err)

		status := StatusFor(err)
		assert.Contains(t,
			[]int{http.StatusUnauthorized, http.StatusForbidden, http.StatusServiceUnavailable},
			status, "%v mapped to %d", from, status)
	}
}

M authn/principal.go => authn/principal.go +55 -14
@@ 33,10 33,12 @@ const (

// Plane names the credential plane an agent authenticated on.
//
// One plane is left, and the field outlives its sibling because the distinction
// it draws is no longer "which of two stores said yes" but "was there a
// credential at all". Only a credential carries a grant set, and a check that
// reads Grants has to know whether there were any to read.
// What the field draws is not "which of two stores said yes" but "in which
// vocabulary, if any, was this credential scoped". Only PlaneInstance carries a
// tokens.sr.ht grant set, so a check that reads Grants has to know whether there
// were any to read — and reading the zero set of a plane that was never scoped
// in that grammar would refuse a caller for lacking a permission its credential
// could not have been minted with.
//
// Empty for every principal that is not an agent, and for the one agent that is
// not credential-backed: `specsrht doc propose`, which runs as the operator on


@@ 49,6 51,26 @@ const (
	// 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"

	// PlaneMeta is a meta.sr.ht personal access token, scoped in meta's own OAuth
	// vocabulary (ScopeRead) rather than in tokens.sr.ht's, and reachable on
	// /query alone.
	//
	// It exists because api.sr.ht forwards one client credential to every service
	// a federated query touches, so a /query that refused the credential the rest
	// of the instance uses could never be federated. MetaAuth's own comment
	// carries the argument; what matters here is the scope of the exception —
	// nothing on the REST, MCP or push paths can produce this plane, because none
	// of them holds a MetaAuth, so a PAT is not a way around the grants those
	// surfaces require.
	//
	// A principal on this plane is KindAgent and never KindOwner, even though the
	// token belongs to the instance owner's own meta account. KindOwner is the
	// human at a browser, and it is the only principal that may approve a proposal
	// or manage a webhook; promoting a credential that any process holding a
	// string can present into that role would hand the approved branch to whatever
	// is holding it.
	PlaneMeta Plane = "meta"
)

// Principal is the resolved identity of a request. It is a value type with no


@@ 145,12 167,23 @@ func (p Principal) CanRead() bool { return p.IsOwner() || p.IsAgent() }
// 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.
//
// A principal off the instance plane passes. That is not a hole left over from
// the agent_token days: grants describe machine credentials, and the principals
// with no plane are the owner's cookie — a person, whose authority is their
// identity — and the CLI's locally asserted agent, which runs as the operator on
// the daemon's host and presented nothing to have a grant clipped out of. Every
// agent the resolver produces is on the instance plane and is checked here.
// A principal off the instance plane passes, and each of the three ways that
// happens is deliberate rather than a hole left over from the agent_token days:
//
//   - The owner's cookie is a person, whose authority is their identity. There is
//     no grant to read, and checking a zero set would refuse every logged-in
//     human on the site.
//   - The CLI's locally asserted agent runs as the operator on the daemon's own
//     host and presented nothing to have a grant clipped out of.
//   - A meta.sr.ht personal access token (PlaneMeta) is scoped in a vocabulary
//     this method does not speak. No PAT can ever carry "spec:read" — meta's
//     personal-token page cannot spell it — so checking one here would refuse
//     every PAT on the instance rather than scope it, which is the opposite of
//     what a grant check is for. A PAT is scoped once, in meta's own grammar, at
//     the point it is resolved (MetaAuth.VerifyToken), and /query's read gate is
//     the only surface it can reach at all.
//
// Every agent the resolver produces is on the instance plane and is checked here.
func (p Principal) Authorize(action string) error {
	if p.Plane != PlaneInstance {
		return nil


@@ 179,11 212,19 @@ func (p Principal) String() string {
			session = "(no session)"
		}
		line := fmt.Sprintf("agent %s session %s for ~%s", agent, session, p.Owner)
		// Only a credential-backed agent is annotated: the grant set is what the
		// annotation says, and an agent a local process asserted has none to
		// print.
		if p.Plane == PlaneInstance {
		// Each credential-backed agent is annotated with the plane that admitted
		// it, and only the instance plane's annotation carries a grant set: that
		// set is what its annotation says, and neither a PAT nor an agent a local
		// process asserted has one to print. Naming the plane is what lets a log
		// line distinguish the two credentials afterwards, which is the whole
		// reason a reader would look — a PAT reaches /query and nothing else, so
		// "which plane" is also "which surface" when one turns up somewhere
		// surprising.
		switch p.Plane {
		case PlaneInstance:
			line += " (tokens.sr.ht: " + p.Grants.String() + ")"
		case PlaneMeta:
			line += " (meta.sr.ht personal access token)"
		}
		return line
	default:

M cmd/specsrht/graphql.go => cmd/specsrht/graphql.go +62 -10
@@ 1,9 1,15 @@
package main

import (
	"fmt"
	"strconv"

	"github.com/vaughan0/go-ini"

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

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

// queryRoute is where the GraphQL schema answers. It is core-go's own path,


@@ 13,18 19,64 @@ import (
// /query is the one thing core-go does not serve that file for.
const queryRoute = "/query"

// apiScopes is what this service publishes at apimeta.Path: nothing.
// apiScopes is what this service publishes at apimeta.Path: the scopes a
// meta.sr.ht personal access token can be minted for here.
//
// A scope is the part after the service name in a meta.sr.ht personal-token
// grant, and spec.sr.ht defines none — no AccessScope enum, no @access directive
// on any field, and no code path that reads one. Its grant vocabulary is
// tokens.sr.ht's (authn.ActionRead, authn.ActionPropose), which meta neither
// mints nor advertises, so the honest list is empty and not a placeholder.
//
// It is a variable so that what the daemon serves and what its test asserts are
// one value rather than two spellings of an intention. apimeta marshals it as []
// and never as null; see the test for why that distinction is instance-wide.
var apiScopes []string
// grant, and meta builds the checkboxes of /oauth2/personal-token by fetching
// this file from every service it discovers and prefixing each entry with that
// service's own name. So "SPECS" here is what makes "spec.sr.ht/SPECS:RO" a token
// a human can actually obtain.
//
// It published nothing until /query started accepting a PAT, and that empty list
// was not merely an omission. It meant no PAT could be scoped for this service
// even in principle, so the credential api.sr.ht forwards to every service a
// federated query touches could never have been presented here — whatever the
// endpoint's own code said about it.
//
// It is graph.GrantScopes and not a literal, so that what the daemon serves is
// derived from the same authn.ScopeRead the endpoint checks rather than being a
// second spelling of it. apimeta marshals it as [] and never as null; see the
// test for why that distinction is instance-wide.
var apiScopes = graph.GrantScopes

// newMetaPlane builds the meta.sr.ht personal access token plane /query accepts
// beside the tokens.sr.ht working token every other surface takes.
//
// It is built here, at the single call site that may use it, and deliberately not
// on the *authn.Resolver the daemon hands to every surface. Putting it there
// would give it to the REST write plane, /mcp and the push hook as well, which is
// precisely what must not happen: those require a tokens.sr.ht grant, and a
// personal access token — the credential every account on the instance can mint
// for itself, in a vocabulary that cannot spell spec:propose — must not be a way
// around one. Building it at one call site makes the scope of the exception
// something a reader can see rather than something they have to trust.
//
// owner is the same [sr.ht] owner-name the resolver was built with, because the
// single-owner rule belongs to the service and not to one plane: a PAT belonging
// to anybody else is refused exactly as a working token of theirs is.
//
// The validator is metapat's default backend, which is core-go — it mirrors the
// owner's profile and asks meta.sr.ht about revocation through the database and
// config in the request context. /query has both: mountGraphQL puts it in a Group
// carrying core-go's config and database middleware, which the webhook resolvers
// already needed.
//
// Unlike the working-token plane this one needs no configuration and can never be
// legitimately absent: it depends on no per-instance origin. So a failure here is
// fatal to startup rather than a degraded mode — an instance that came up without
// it would answer 401 to every federated query while looking configured.
func newMetaPlane(owner string) (*authn.MetaAuth, error) {
	pats, err := metapat.New(metapat.Options{Service: authn.ConfigSection})
	if err != nil {
		return nil, fmt.Errorf("assemble the meta.sr.ht token plane: %w", err)
	}
	plane, err := authn.NewMetaAuth(pats, owner, authn.ScopeRead)
	if err != nil {
		return nil, fmt.Errorf("assemble the meta.sr.ht token plane: %w", err)
	}
	return plane, nil
}

// defaultMaxComplexity is the bound core-go's server.WithSchema would have
// applied. It is repeated here because this daemon does not call WithSchema —

M cmd/specsrht/graphql_test.go => cmd/specsrht/graphql_test.go +58 -17
@@ 18,6 18,9 @@ import (
	"sourcecraft.dev/bigbes/sr-ht-core/crypto"
	"sourcecraft.dev/bigbes/sr-ht-core/database"
	"sourcecraft.dev/bigbes/sr-ht-ecore/apimeta"

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

// TestMain initialises the crypto globals apimeta.Handler reads the webhook


@@ 38,33 41,45 @@ func TestQueryRouteIsCoreGosPath(t *testing.T) {
	assert.Equal(t, queryRoute+"/api-meta.json", apimeta.Path)
}

// api-meta.json must be served, and its scope list must be an empty array and
// never a JSON null.
// api-meta.json must be served, it must publish exactly the scope the endpoint
// checks, and that list must be a JSON array and never a null.
//
// meta.sr.ht fetches this file from every service it discovers when it renders
// /oauth2/personal-token, and iterates the "scopes" field to build the grant
// checkboxes. A null there is a nil iteration in meta — a 500 on that page for
// the WHOLE instance, every service's grants and not just this one's. It is a
// failure nobody would find by testing the service that caused it, which is why
// the assertion lives here even though the marshalling is sr-ht-ecore's.
// checkboxes. Two different failures live in that one field:
//
//   - A null is a nil iteration in meta — a 500 on that page for the WHOLE
//     instance, every service's grants and not just this one's. It is a failure
//     nobody would find by testing the service that caused it, which is why the
//     assertion lives here even though the marshalling is sr-ht-ecore's.
//   - An empty list means no personal access token can be scoped for this service
//     at all, so /query could never be federated whatever its own code said: the
//     credential api.sr.ht forwards to every service a query touches would be one
//     no checkbox could mint. That was spec.sr.ht's state until the endpoint grew
//     the meta plane, and it is the half of the refusal no amount of code in
//     graph/ could have worked around.
//
// Empty is also the honest answer for spec.sr.ht rather than a placeholder: this
// service defines no meta OAuth scope and no @access directive to check one
// against. Its grant vocabulary is tokens.sr.ht's — authn.ActionRead and
// authn.ActionPropose — which meta neither mints nor advertises.
func TestAPIMetaAdvertisesNoScopeAndNeverNull(t *testing.T) {
// The published scope is compared against graph.GrantScopes rather than a
// literal, because the two spellings that must agree are what meta turns into a
// checkbox and what the endpoint checks. A scope published and not checked admits
// what should have been refused; one checked and not published cannot be minted
// at all; neither is visible from inside a single file. The wire spelling is
// asserted as well, since meta reads the bytes and not the Go value.
func TestAPIMetaPublishesTheScopeTheEndpointChecks(t *testing.T) {
	rec := httptest.NewRecorder()
	// apiScopes and not a literal: this asserts what mountWeb actually serves.
	apimeta.Handler(apiScopes...).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, apimeta.Path, nil))

	require.Equal(t, http.StatusOK, rec.Code)
	assert.Contains(t, rec.Body.String(), `"scopes":[]`,
		"a JSON null here is a 500 on meta's personal-token page for the whole instance")
	assert.Contains(t, rec.Body.String(), `"scopes":["SPECS"]`,
		"the wire spelling meta.sr.ht reads, and prefixes into spec.sr.ht/SPECS")

	var got apimeta.Meta
	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
	assert.NotNil(t, got.Scopes)
	assert.Empty(t, got.Scopes)
	require.NotNil(t, got.Scopes,
		"a JSON null here is a 500 on meta's personal-token page for the whole instance")
	assert.Equal(t, graph.GrantScopes, got.Scopes,
		"the file must publish exactly what the endpoint checks")
	assert.NotEmpty(t, got.WebhookPubkey, "a webhook consumer verifies payloads with this")
}



@@ 128,11 143,37 @@ func TestMountGraphQL(t *testing.T) {
		require.Equal(t, http.StatusOK, resp.StatusCode)
		var got apimeta.Meta
		require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
		assert.NotNil(t, got.Scopes)
		assert.Empty(t, got.Scopes)
		require.NotNil(t, got.Scopes)
		assert.Equal(t, graph.GrantScopes, got.Scopes,
			"what meta.sr.ht mints a token for is what /query checks")
	})
}

// The meta.sr.ht plane the daemon hands to /query and to nothing else.
//
// What is worth asserting here is not that it builds — graph.New already refuses
// a nil one — but what it is built *with*: the instance owner, so a personal
// access token belonging to anybody else is refused exactly as a foreign working
// token is, and the scope api-meta.json publishes, so the checkbox meta renders
// is the permission this endpoint checks.
func TestNewMetaPlane(t *testing.T) {
	plane, err := newMetaPlane("bigbes")
	require.NoError(t, err)
	require.NotNil(t, plane)

	assert.Equal(t, "bigbes", plane.Owner(),
		"a PAT of any other account must be refused, and a PAT is what every "+
			"account on the instance can mint for itself")
	assert.Equal(t, authn.ScopeRead, plane.Scope())
	assert.Equal(t, authn.ScopeRead,
		authn.ConfigSection+"/"+apiScopes[0], "the published half and the checked half")

	// A bad owner is a startup failure and not a plane that admits nobody: the
	// daemon must say so while somebody is watching it start.
	_, err = newMetaPlane("")
	require.Error(t, err)
}

// The complexity bound core-go's WithSchema would have set. The daemon does not
// call WithSchema any more, and the value's second reader is not the HTTP
// surface at all: the webhook delivery worker runs a subscriber's stored query

M cmd/specsrht/main.go => cmd/specsrht/main.go +37 -14
@@ 292,11 292,17 @@ func run(conf ini.File, log *slog.Logger) error {
	}
	defer pool.Close()

	// WithInstanceTokens builds the daemon's one agent credential plane. It is
	// required, not offered: spec.sr.ht mints no credential of its own any more,
	// so an instance with no [tokens.sr.ht] section could authenticate no agent
	// at all, over HTTP or over `git push`. service.New fails here rather than
	// letting the daemon come up and refuse every agent one request at a time.
	// WithInstanceTokens builds the agent credential plane every surface shares.
	// It is required, not offered: spec.sr.ht mints no credential of its own any
	// more, so an instance with no [tokens.sr.ht] section could authenticate no
	// agent at all, over HTTP or over `git push`. service.New fails here rather
	// than letting the daemon come up and refuse every agent one request at a
	// time.
	//
	// /query holds a second plane the resolver knows nothing about — the
	// meta.sr.ht personal access token newMetaPlane builds — and that one is
	// deliberately not here, because a plane on the resolver is a plane on every
	// surface. It is built beside the graph.New call in newSurfaces instead.
	svc, err := service.New(cfg, pool, service.WithInstanceTokens(conf))
	if err != nil {
		return err


@@ 356,11 362,13 @@ func run(conf ini.File, log *slog.Logger) error {
	// for the authenticated router it decorates, which now carries no routes at
	// all. There is deliberately no WithSchema: it would mount /query on that
	// authenticated router, behind core-go's auth.Middleware, which speaks
	// meta.sr.ht's OAuth vocabulary and not the tokens.sr.ht one every other
	// surface of this service accepts. /query is mounted on the anonymous
	// router by mountRoutes instead, with graph's own credential middleware in
	// front of it, and api-meta.json is served there too because core-go serves
	// that file only for the schemas it hosts itself.
	// meta.sr.ht's OAuth vocabulary and 401s the tokens.sr.ht one every other
	// surface of this service accepts. /query takes both — a working token and a
	// meta PAT — and serving two planes from one endpoint is exactly what that
	// middleware cannot do. It is mounted on the anonymous router by mountRoutes
	// instead, with graph's own credential middleware in front of it, and
	// api-meta.json is served there too because core-go serves that file only for
	// the schemas it hosts itself.
	//
	// MaxComplexity is the one thing WithSchema set that still has to be set,
	// and its reader has nothing to do with serving /query: the webhook delivery


@@ 600,15 608,30 @@ func newSurfaces(conf ini.File, cfg service.Config, svc *service.Service, versio
	// the gate just makes the read tools match.
	mcp = svc.Resolver().Middleware()(mcpsrv.Gate(mcp))

	// /query, with the same credential plane /mcp and /api use — the resolver is
	// the one svc holds, so a token that reads through one surface reads through
	// all three. graph.Server installs that middleware itself, which is why it is
	// mounted on the anonymous router below and not through core-go's WithSchema.
	// The one credential plane that is not shared. /query is federated into
	// api.sr.ht, which forwards a single client Authorization header to every
	// service a query touches, so it has to take the meta.sr.ht personal access
	// token that header carries; the three surfaces above must not, because a PAT
	// would then be a way around the tokens.sr.ht grant they require. Building it
	// beside the one endpoint that gets it is what keeps that true — see
	// newMetaPlane.
	metaPlane, err := newMetaPlane(svc.Resolver().Owner())
	if err != nil {
		index.Close()
		return nil, err
	}

	// /query, with the credential plane /mcp and /api use — the resolver is the
	// one svc holds, so a token that reads through one surface reads through all
	// three — plus the meta plane above, which this surface alone holds.
	// graph.Server installs that middleware itself, which is why it is mounted on
	// the anonymous router below and not through core-go's WithSchema.
	gql, err := graph.New(graph.Options{
		Reader:    svc,
		Searcher:  index,
		Proposals: graph.NewProposals(svc),
		Resolver:  svc.Resolver(),
		Meta:      metaPlane,
	})
	if err != nil {
		index.Close()

M go.mod => go.mod +1 -1
@@ 22,7 22,7 @@ require (
	go.bigb.es/auxilia v0.7.0
	gopkg.in/yaml.v3 v3.0.1
	sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260816094344-effb7ced05b7
	sourcecraft.dev/bigbes/sr-ht-ecore v0.0.0-20260816081411-3bd158fbb232
	sourcecraft.dev/bigbes/sr-ht-ecore v0.0.0-20260816184219-89fa694cbf54
)

require (

M go.sum => go.sum +2 -0
@@ 423,3 423,5 @@ sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260816094344-effb7ced05b7 h1:YpwRaM3M
sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260816094344-effb7ced05b7/go.mod h1:Mu1Vx39ws/OTKWGoVERXvkdRSPLBdhuFTYv0ftVV31c=
sourcecraft.dev/bigbes/sr-ht-ecore v0.0.0-20260816081411-3bd158fbb232 h1:vfuiF+4BwTFyLOaxuuIGdyL22is6RN/7yF/CV35frTQ=
sourcecraft.dev/bigbes/sr-ht-ecore v0.0.0-20260816081411-3bd158fbb232/go.mod h1:Hu5gSbJ9ZQup0KGfupXRxeako5ajV86zB6fC2DbleWY=
sourcecraft.dev/bigbes/sr-ht-ecore v0.0.0-20260816184219-89fa694cbf54 h1:odN8rYDbV7ieJmYbULr1+9v6yvAmwpXY54Sk2ydEhkM=
sourcecraft.dev/bigbes/sr-ht-ecore v0.0.0-20260816184219-89fa694cbf54/go.mod h1:Hu5gSbJ9ZQup0KGfupXRxeako5ajV86zB6fC2DbleWY=

M graph/credential_test.go => graph/credential_test.go +191 -26
@@ 2,6 2,7 @@ package graph

import (
	"bytes"
	"context"
	"encoding/json"
	"net/http"
	"net/http/httptest"


@@ 13,6 14,7 @@ import (

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

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


@@ 43,22 45,76 @@ func request(t *testing.T, q string, credential func(*http.Request)) (*http.Requ
	return req, httptest.NewRecorder()
}

// metaPAT mints a bearer token sealed with the instance's key but stamped with
// an OAuth client id rather than tokens.sr.ht's. That is exactly what a
// meta.sr.ht personal access token is on this instance — same format, same key,
// different issuer — and the client id is the only thing that tells the two
// apart (see sr-ht-ecore/bearer, step 2).
func metaPAT() string {
// personalToken mints a bearer token sealed with the instance's key but stamped
// with an OAuth client id rather than tokens.sr.ht's, carrying grantString in
// meta.sr.ht's own vocabulary — "spec.sr.ht/SPECS:RO", or "" for the ungranted
// token meta treats as universal.
//
// That is exactly what a meta.sr.ht personal access token is on this instance:
// same format, same key, different issuer. The client id is the ONLY thing that
// tells the two planes apart (see sr-ht-ecore/bearer, step 2), which is why
// routing can be one local decode and why this fixture needs nothing else to be
// a convincing PAT.
func personalToken(username, grantString string) string {
	bt := &auth.BearerToken{
		Version:  auth.TokenVersion,
		Expires:  auth.ToTimestamp(time.Now().Add(time.Hour)),
		Grants:   "",
		Grants:   grantString,
		ClientID: "00000000-0000-0000-0000-00000000beef",
		Username: "bigbes",
		Username: username,
	}
	return bt.Encode()
}

// expiredPersonalToken is a PAT whose expiry is already in the past. It is worth
// a fixture of its own because of where it lands: auth.DecodeBearerToken checks
// expiry before it reports an issuer, so metapat.PlaneOf answers PlaneUnknown and
// this never reaches the meta plane at all — see resolveCaller's routing table.
func expiredPersonalToken(username string) string {
	bt := &auth.BearerToken{
		Version:  auth.TokenVersion,
		Expires:  auth.ToTimestamp(time.Now().Add(-time.Hour)),
		ClientID: "00000000-0000-0000-0000-00000000beef",
		Username: username,
	}
	return bt.Encode()
}

// patBackend is metapat's port over the same fixed user stubUsers answers the
// working-token plane with, so both planes resolve one owner to one row and a
// test that compares them is comparing planes rather than two fixtures.
//
// It answers "not revoked" always. Revocation is metapat's own contract and is
// tested there against a backend that says otherwise; what this suite is about is
// which plane a credential reaches and what it may then read.
type patBackend struct{}

func (patBackend) LookupUser(_ context.Context, username string, out *auth.AuthContext) error {
	out.UserID = 1
	out.Username = username
	return nil
}

func (patBackend) IsRevoked(context.Context, string, [64]byte, string) (bool, error) {
	return false, nil
}

// testMetaAuth is the production meta.sr.ht plane over ecore's real
// metapat.Validator. Nothing in the signature check, the grant decoding or the
// scope comparison is faked — only the profile mirror behind them, which is the
// one step that would need a meta.sr.ht.
func testMetaAuth(t *testing.T) *authn.MetaAuth {
	t.Helper()
	pats, err := metapat.New(metapat.Options{
		Service: authn.ConfigSection,
		Backend: patBackend{},
	})
	require.NoError(t, err)
	plane, err := authn.NewMetaAuth(pats, "bigbes", authn.ScopeRead)
	require.NoError(t, err)
	return plane
}

// foreignToken mints a working token belonging to somebody who is not the
// instance owner.
func foreignToken(grantString string) string {


@@ 137,7 193,10 @@ func TestCookieIsNotACredentialHere(t *testing.T) {

	t.Run("under a router that does resolve the cookie", func(t *testing.T) {
		resolver := testResolver(t)
		srv, err := New(Options{Reader: newFakeReader(), Searcher: &fakeSearcher{}, Resolver: resolver})
		srv, err := New(Options{
			Reader: newFakeReader(), Searcher: &fakeSearcher{},
			Resolver: resolver, Meta: testMetaAuth(t),
		})
		require.NoError(t, err)

		// The cookie plane, installed above the endpoint. It resolves the


@@ 150,30 209,131 @@ func TestCookieIsNotACredentialHere(t *testing.T) {
	})
}

// A meta.sr.ht personal access token is refused, and this is a deliberate
// difference from dolt.sr.ht rather than an oversight.
//
// spec.sr.ht authenticates through one issuer (authn's package comment) and
// publishes no OAuth scope for meta to grant against — cmd/specsrht serves
// api-meta.json with an empty scope list — so there is nothing a PAT could be
// scoped *for* here. bearer classifies it ErrNotOurs and authn calls that
// permanent, hence 401 with the challenge rather than a 503.
// The other plane, and the reason this endpoint has two. A federated query
// arrives from api.sr.ht carrying the client's own meta.sr.ht token, because the
// gateway forwards one Authorization header to every service it touches — so a
// personal access token has to work here, scoped in meta's vocabulary rather than
// in tokens.sr.ht's.
func TestTheReadScopeIsRequiredOfAPersonalAccessToken(t *testing.T) {
	cases := []struct {
		name   string
		grants string
		status int
	}{
		{"the scope itself", authn.ScopeRead, http.StatusOK},
		{"an explicit :RO", authn.ScopeRead + ":RO", http.StatusOK},
		{"a write scope covers a read", authn.ScopeRead + ":RW", http.StatusOK},
		{
			// meta mints a token with no grants selected and core-go reads that
			// as every permission (auth.Grants.HasAll). Refusing it here would
			// refuse the commonest credential on the instance.
			"no grants at all is universal, as meta defines it", "", http.StatusOK,
		},
		{"another service's scope", "bench.sr.ht/RESULTS:RO", http.StatusForbidden},
		{"meta's own profile scope is not this one", "meta.sr.ht/PROFILE:RO", http.StatusForbidden},
	}

	for _, tc := range cases {
		t.Run(tc.name, func(t *testing.T) {
			h := newHarness(t, false)

			r := post(t, h, probeQuery, func(req *http.Request) {
				req.Header.Set("Authorization", "Bearer "+personalToken("bigbes", tc.grants))
			})

			require.Equal(t, tc.status, r.status, "body %s", r.body)
			if tc.status != http.StatusForbidden {
				return
			}
			assert.Contains(t, r.body, authn.ScopeRead,
				"a 403 must name meta's scope, not tokens.sr.ht's grant: a PAT holder "+
					"sent looking for spec:read will not find a checkbox for it")
			assert.NotContains(t, r.body, authn.ActionRead)
			assert.Empty(t, r.header.Get("WWW-Authenticate"),
				"a challenge would send the holder re-minting a token that is fine")
			assert.NotContains(t, r.body, "~bigbes/rfcs", "the refusal leaked content")
		})
	}
}

// A personal access token reads as the owner's agent, and the emphasis is on
// agent: it is a bearer string any process holding it can present, forwarded
// through a gateway by whatever client asked, so reading it as authn.KindOwner
// would hand the approved branch and the webhook subscriptions to the widest
// credential on the instance. The scope says the token may read through spec at
// all; who it is stays the ACL's input.
func TestAPersonalAccessTokenReadsAsAnAgentAndNotTheOwner(t *testing.T) {
	h := newHarness(t, false)

	r := post(t, h, probeQuery, func(req *http.Request) {
		req.Header.Set("Authorization", "Bearer "+personalToken("bigbes", authn.ScopeRead))
	})
	require.Equal(t, http.StatusOK, r.status, "body %s", r.body)
	assert.Contains(t, string(r.Data), "~bigbes/rfcs")

	// The principal itself, since a 200 on a read cannot tell an agent from the
	// owner: both may read, and only one may approve.
	plane := testMetaAuth(t)
	p, err := plane.VerifyToken(t.Context(), personalToken("bigbes", authn.ScopeRead))
	require.NoError(t, err)
	assert.True(t, p.IsAgent())
	assert.False(t, p.IsOwner(), "a PAT must not reach the webhook mutations")
	assert.Equal(t, authn.PlaneMeta, p.Plane)
	assert.NoError(t, p.Authorize(authn.ActionRead),
		"a PAT can never carry spec:read; asking for it would refuse every one of them")
}

// A personal access token belonging to another meta.sr.ht account is 403 and is
// not admitted as a second identity — the same rule, and the same status, a
// foreign working token gets.
//
// It is the credential this endpoint used to take, when core-go's
// auth.Middleware stood in front of it, and the test exists to pin the change
// rather than to celebrate it: reopening that plane means giving spec.sr.ht a
// meta scope first.
func TestMetaPersonalAccessTokenIsRefused(t *testing.T) {
// This is the check that matters most on this plane. A PAT is the credential
// every account on the instance can mint for itself, so without it the widest
// credential in existence would be the one that skipped the narrowest identity
// rule, and any user of this instance could read the whole corpus.
func TestPersonalAccessTokenOfAnotherOwnerIsRefused(t *testing.T) {
	h := newHarness(t, false)

	r := post(t, h, probeQuery, func(req *http.Request) {
		req.Header.Set("Authorization", "Bearer "+metaPAT())
		req.Header.Set("Authorization", "Bearer "+personalToken("somebody-else", authn.ScopeRead))
	})

	assert.Equal(t, http.StatusUnauthorized, r.status, "body %s", r.body)
	assert.Equal(t, http.StatusForbidden, r.status, "body %s", r.body)
	assert.NotContains(t, r.body, "~bigbes/rfcs", "the refusal leaked content")
}

// The two vocabularies do not overlap, and this is the test that says so. Each
// plane is reached by the client id its token was sealed with, and neither can be
// talked into honouring the other's permission — but they refuse it differently,
// and the difference is worth pinning because it is not arbitrary.
func TestOnePlanesPermissionIsNotTheOthers(t *testing.T) {
	t.Run("a PAT spelling tokens.sr.ht's grant", func(t *testing.T) {
		// Well-formed in meta's grammar — a scope this service does not publish —
		// so it authenticates and is refused on permission: 403.
		h := newHarness(t, false)
		r := post(t, h, probeQuery, func(req *http.Request) {
			req.Header.Set("Authorization",
				"Bearer "+personalToken("bigbes", authn.ConfigSection+"/"+authn.ActionRead))
		})

		assert.Equal(t, http.StatusForbidden, r.status, "body %s", r.body)
	})

	t.Run("a working token spelling meta's scope", func(t *testing.T) {
		// Not even parseable: ecore's grants grammar is "<service>:<action>", and
		// "spec.sr.ht/SPECS" is not a grant string in it. The credential is
		// malformed rather than insufficient, so this is 401 with the challenge —
		// a stricter answer than the one above, arrived at one step earlier.
		h := newHarness(t, false)
		r := post(t, h, probeQuery, func(req *http.Request) {
			req.Header.Set("Authorization", "Bearer "+agentToken(authn.ScopeRead))
		})

		assert.Equal(t, http.StatusUnauthorized, r.status, "body %s", r.body)
		assert.NotEmpty(t, r.header.Get("WWW-Authenticate"))
	})
}

// A working token belonging to another meta.sr.ht account is 403, not 401 and
// not admitted as a second identity: the token verifies and the holder is who
// they say they are, there is simply nothing on this single-owner instance to


@@ 203,8 363,13 @@ func TestEveryUnauthorizedCarriesTheBearerChallenge(t *testing.T) {
		"a forged token": func(req *http.Request) {
			req.Header.Set("Authorization", "Bearer not-a-real-token")
		},
		"a meta PAT": func(req *http.Request) {
			req.Header.Set("Authorization", "Bearer "+metaPAT())
		// Expired, and therefore unreadable rather than refused: DecodeBearerToken
		// checks expiry before it reports an issuer, so metapat.PlaneOf cannot say
		// which plane sealed this and resolveCaller sends it to the working one.
		// Both planes owe it the same 401, which is exactly why that routing
		// choice is safe — and this asserts the challenge survives it.
		"an expired personal access token": func(req *http.Request) {
			req.Header.Set("Authorization", "Bearer "+expiredPersonalToken("bigbes"))
		},
	}
	for name, credential := range cases {

M graph/graph_test.go => graph/graph_test.go +57 -13
@@ 15,6 15,9 @@ import (
	"time"

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

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


@@ 330,7 333,12 @@ func newHarness(t *testing.T, withProposals bool) harness {
	t.Helper()
	resolver := testResolver(t)
	searcher := &fakeSearcher{}
	opts := Options{Reader: newFakeReader(), Searcher: searcher, Resolver: resolver}
	opts := Options{
		Reader:   newFakeReader(),
		Searcher: searcher,
		Resolver: resolver,
		Meta:     testMetaAuth(t),
	}
	var proposals *fakeProposals
	if withProposals {
		proposals = &fakeProposals{}


@@ 344,9 352,14 @@ func newHarness(t *testing.T, withProposals bool) harness {
}

// response is one GraphQL response, decoded far enough to assert on.
//
// The headers are kept because a refusal's status is only half of it: a 401 owes
// the caller the bearer challenge, and a 403 must not carry one — a challenge
// tells a client to go and re-mint a credential that is in fact perfectly good.
type response struct {
	status int
	body   string
	header http.Header
	Data   json.RawMessage `json:"data"`
	Errors []graphqlError  `json:"errors"`
}


@@ 392,7 405,7 @@ func post(t *testing.T, h harness, q string, auth func(*http.Request)) response 
	rec := httptest.NewRecorder()
	h.handler.ServeHTTP(rec, req)

	out := response{status: rec.Code, body: rec.Body.String()}
	out := response{status: rec.Code, body: rec.Body.String(), header: rec.Header()}
	if strings.HasPrefix(rec.Header().Get("Content-Type"), "application/json") {
		if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
			t.Fatalf("decode response %q: %v", rec.Body.String(), err)


@@ 1074,7 1087,10 @@ func TestSchemaMutationsAreWebhooksOnly(t *testing.T) {
// The daemon's one line is the line under test here.
func TestMountedOnAChiRouter(t *testing.T) {
	resolver := testResolver(t)
	srv, err := New(Options{Reader: newFakeReader(), Searcher: &fakeSearcher{}, Resolver: resolver})
	srv, err := New(Options{
		Reader: newFakeReader(), Searcher: &fakeSearcher{},
		Resolver: resolver, Meta: testMetaAuth(t),
	})
	if err != nil {
		t.Fatalf("New: %v", err)
	}


@@ 1095,17 1111,45 @@ func TestMountedOnAChiRouter(t *testing.T) {
}

// New refuses a half-wired server at startup rather than failing inside the
// first query.
// first query, and says which seam is missing.
//
// The meta plane is the case that would not announce itself, and it is why the
// error text is asserted rather than only the failure. An endpoint built without
// it starts, serves every working token exactly as before and passes every other
// test in this file — while answering 401 to every federated query, a failure
// visible only from api.sr.ht and only once somebody has added the `api-origin=`
// line. "New succeeded" would not have been enough to tell that seam from any
// other.
func TestNewRequiresItsSeams(t *testing.T) {
	resolver := testResolver(t)
	cases := map[string]Options{
		"no reader":   {Searcher: &fakeSearcher{}, Resolver: resolver},
		"no searcher": {Reader: newFakeReader(), Resolver: resolver},
		"no resolver": {Reader: newFakeReader(), Searcher: &fakeSearcher{}},
	}
	for name, opts := range cases {
		if _, err := New(opts); err == nil {
			t.Errorf("%s: New succeeded", name)
	full := func() Options {
		return Options{
			Reader:   newFakeReader(),
			Searcher: &fakeSearcher{},
			Resolver: testResolver(t),
			Meta:     testMetaAuth(t),
		}
	}
	cases := map[string]func(*Options){
		"Reader":            func(o *Options) { o.Reader = nil },
		"Searcher":          func(o *Options) { o.Searcher = nil },
		"Resolver":          func(o *Options) { o.Resolver = nil },
		"MetaAuthenticator": func(o *Options) { o.Meta = nil },
	}
	for name, drop := range cases {
		t.Run("without a "+name, func(t *testing.T) {
			opts := full()
			drop(&opts)

			_, err := New(opts)
			require.Error(t, err)
			assert.Contains(t, err.Error(), name)
		})
	}

	// The control: everything above fails for the seam it dropped and not because
	// the fixture was never buildable. Proposals stays nil throughout, being the
	// one genuinely optional seam — while it is, the `proposals` field fails
	// loudly rather than answering "none".
	_, err := New(full())
	require.NoError(t, err)
}

M graph/resolver.go => graph/resolver.go +20 -4
@@ 6,6 6,7 @@ import (

	"github.com/99designs/gqlgen/graphql"

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


@@ 90,13 91,28 @@ type Proposals interface {
	ListProposals(ctx context.Context, space core.SpaceRef, state core.ProposalState) ([]Proposal, error)
}

// MetaAuthenticator turns a presented meta.sr.ht personal access token into a
// principal. The production implementation is *authn.MetaAuth.
//
// It takes the credential and not the request, which is the whole difference
// from the *authn.Resolver this endpoint holds beside it, and it is deliberate
// twice over. The credential has already been read by the time this is asked —
// resolveCaller routed on it to get here — so handing the request back would
// invite a second parse that could disagree with the first. And a plane that
// cannot see the request cannot read a cookie off it, which makes this
// endpoint's bearer-only rule structural rather than a thing to remember.
type MetaAuthenticator interface {
	VerifyToken(ctx context.Context, presented string) (authn.Principal, error)
}

// Compile-time assertions that the production types satisfy the interfaces this
// package is written against. They are here rather than in a test so that a
// signature change in service/ or search/ breaks the build of the package that
// depends on them, not of a test somebody may not run.
// signature change in service/, search/ or authn/ breaks the build of the
// package that depends on them, not of a test somebody may not run.
var (
	_ Reader   = (*service.Service)(nil)
	_ Searcher = (*search.Index)(nil)
	_ Reader            = (*service.Service)(nil)
	_ Searcher          = (*search.Index)(nil)
	_ MetaAuthenticator = (*authn.MetaAuth)(nil)
)

// Resolver is the root resolver. It holds the seams above — every read field of

M graph/server.go => graph/server.go +171 -30
@@ 28,22 28,46 @@
// for introspection, which is why a gateway federating this schema has to
// present a token like any other client.
//
// The credential is the bearer plane /mcp and the REST write plane already
// define, and nothing else:
// The credential is a bearer token, and this is the one surface of this service
// that takes either of the instance's two:
//
//   - A tokens.sr.ht working token, verified through sr-ht-ecore's bearer
//     package by authn.Resolver, owned by [sr.ht] owner-name, and carrying
//     authn.ActionRead. A token that verifies but does not carry that grant is
//     403; one that does not verify is 401 with the bearer challenge.
//   - A meta.sr.ht personal access token, verified through sr-ht-ecore's metapat
//     package by authn.MetaAuth, owned by that same [sr.ht] owner-name, and
//     carrying authn.ScopeRead — the same permission in meta's OAuth vocabulary
//     rather than in tokens.sr.ht's. The statuses are the other plane's arm for
//     arm; what differs is the permission a 403 names, because the two
//     vocabularies do not overlap.
//   - No cookie. An API client is not a browser. The unified-login cookie is
//     web/'s plane, and this endpoint is deliberately outside it — so the
//     principal is overwritten with the anonymous one when no bearer credential
//     is presented, rather than inherited from whatever middleware happens to
//     sit above the mount point.
//   - No meta.sr.ht personal access token. spec.sr.ht authenticates through one
//     issuer (see authn's package comment) and publishes no OAuth scope for meta
//     to grant against, so there is nothing a PAT could be scoped for here. A
//     PAT is bearer.ErrNotOurs and is refused at the door with 401.
//
// # Why this surface takes a meta PAT when no other one does
//
// Because of the gateway, and for no other reason. api.sr.ht forwards ONE client
// "Authorization" header to every service a federated query touches — its
// AuthMiddleware copies the client's header verbatim into the request context,
// and the internal credential it can mint is used only to fetch schemas at
// startup — so a federated caller arrives here holding whatever credential the
// client had. The only credential that works across the whole instance is a meta
// PAT, so an endpoint that refuses them can never be part of the gateway's
// schema: federating it would be one `api-origin=` line that produced 401s.
//
// The REST write plane, /mcp and the push hook keep exactly one plane,
// tokens.sr.ht's, because they have no such problem and a narrow, revocable,
// short-lived grant is worth its cost there. They resolve identity through
// authn.Resolver, which holds no MetaAuth and therefore cannot produce an
// authn.PlaneMeta principal at all — the scope of the exception is structural
// rather than a convention to remember.
//
// A PAT admitted here is an agent and never the owner, so webhook management
// stays out of its reach: those mutations ask authn.Principal.IsOwner, which only
// the unified-login cookie satisfies and which this endpoint reads none of.
//
// # What the cmd layer wires
//


@@ 51,6 75,7 @@
//		Reader:   svc,             // *service.Service
//		Searcher: index,           // *search.Index
//		Resolver: svc.Resolver(),
//		Meta:     metaAuth,        // *authn.MetaAuth, this surface alone
//	})
//	if err != nil {
//		return err


@@ 59,12 84,14 @@
//
// Server installs its own credential middleware, so it goes on the *anonymous*
// router: core-go's server.WithSchema would mount it on the authenticated one,
// whose auth.Middleware answers meta's OAuth vocabulary and 401s anything else
// — the vocabulary this service deliberately does not speak. A service that
// mounts its own /query owes the instance api-meta.json as well, because
// core-go serves that file only for the schemas it hosts itself; sr-ht-ecore's
// apimeta package is what serves it, and cmd/specsrht wires it beside the
// route.
// whose auth.Middleware speaks meta's OAuth vocabulary and 401s anything else —
// including the working token every other surface of this service takes. Serving
// both planes from one endpoint is exactly what that middleware cannot do, which
// is why this package resolves the credential itself. A service that mounts its
// own /query owes the instance api-meta.json as well, because core-go serves that
// file only for the schemas it hosts itself; sr-ht-ecore's apimeta package is
// what serves it, and cmd/specsrht wires it beside the route with the scope list
// GrantScopes derives from authn.ScopeRead.
//
// The router it is mounted on must carry core-go's config and database
// middleware. The read path does not need either, but the webhook management


@@ 73,6 100,7 @@
package graph

import (
	"errors"
	"fmt"
	"log/slog"
	"net/http"


@@ 83,11 111,37 @@ import (
	"github.com/99designs/gqlgen/graphql/handler/transport"
	"go.bigb.es/auxilia/scribe"

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

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

// GrantScopes is what this service publishes in api-meta.json: the scopes a
// meta.sr.ht personal access token can be minted for here.
//
// meta.sr.ht reads that file to build the checkboxes of its personal-token page,
// prefixing each entry with the service's own name — so "SPECS" here is what
// makes "spec.sr.ht/SPECS:RO" a token a human can actually obtain. An empty list,
// which this service published until /query started accepting a PAT, does not
// mean "a service with no scopes": it means no PAT can be scoped for this service
// at all, so the credential the rest of the instance uses could never be
// presented here even in principle. That was the stronger half of the refusal,
// and the half no amount of code in this package could have worked around.
//
// It is derived from authn.ScopeRead rather than spelled a second time, because
// the two spellings must agree: what meta's checkbox mints, and what
// MetaAuth.VerifyToken checks. A scope published and not checked admits what
// should have been refused, one checked and not published cannot be minted at
// all, and neither failure is visible from inside a single file — so a test on
// each side asserts the pair.
//
// This is meta.sr.ht's vocabulary. The other plane's permission is
// authn.ActionRead ("spec:read"), in tokens.sr.ht's; the two grammars share a
// token format and nothing else (sr-ht-ecore's grants and metapat packages).
var GrantScopes = []string{metapat.ScopeName(authn.ScopeRead)}

// Options is everything a Server needs. New says which one is missing rather
// than failing later inside a resolver.
type Options struct {


@@ 103,9 157,20 @@ type Options struct {
	// error saying so rather than answering "none".
	Proposals Proposals

	// Resolver verifies the bearer credential a caller presents. Its cookie
	// plane is not used here: see the package comment.
	// Resolver verifies a presented tokens.sr.ht working token. Its cookie plane
	// is not used here: see the package comment.
	Resolver *authn.Resolver

	// Meta verifies a presented meta.sr.ht personal access token — the plane that
	// exists so this endpoint can be federated into api.sr.ht at all (see
	// authn.MetaAuth).
	//
	// Required, and unlike the working-token plane it needs no configuration to be
	// available: it depends on no per-instance origin, only on the meta.sr.ht
	// every SourceHut service already talks to. So there is no legitimate "this
	// instance has no such plane" case to tolerate a nil for, and an endpoint
	// built without it would answer 401 to the gateway while looking configured.
	Meta MetaAuthenticator
}

// Server is the /query endpoint: the executable schema behind the credential


@@ 150,6 215,12 @@ func New(opts Options) (*Server, error) {
	if opts.Resolver == nil {
		return nil, fmt.Errorf("graph: authn Resolver is required")
	}
	// The seam whose absence would not announce itself: an endpoint with no PAT
	// plane starts, serves every working token exactly as before, and answers 401
	// to every federated query — a failure visible only from the gateway.
	if opts.Meta == nil {
		return nil, fmt.Errorf("graph: MetaAuthenticator is required")
	}
	schema, err := newSchema(opts)
	if err != nil {
		return nil, err


@@ 168,7 239,7 @@ func New(opts Options) (*Server, error) {

	return &Server{
		schema: schema,
		http:   resolveCaller(opts.Resolver, gate(coreContext(exec))),
		http:   resolveCaller(opts.Resolver, opts.Meta, gate(coreContext(exec))),
	}, nil
}



@@ 192,17 263,54 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.http.Serv
// resolved a cookie identity, this endpoint still sees anonymous and still
// answers 401.
//
// The statuses are authn.StatusFor's, which is the one table this service maps
// a credential failure with: 401 for a credential that does not verify — forged,
// expired, revoked, or issued by somebody else — 403 for one that verifies and
// belongs to a human this single-owner instance has nothing to grant, and 503
// for a credential that could not be *checked*. The last is not "your token is
// bad": answering 401 to a restart of tokens.sr.ht tells every agent to re-mint
// credentials that were never broken.
// # Two planes, and which one gets the request
//
// This is where spec.sr.ht's /query differs from every other surface it has, and
// the difference is api.sr.ht's. The gateway forwards ONE client "Authorization"
// header to every service a federated query touches, so a federated caller
// arrives holding whatever credential the client had — in practice a meta.sr.ht
// personal access token, the only credential that works instance-wide. An
// endpoint that refused those could not be federated at all, however correct its
// refusal looked.
//
// So the plane is chosen by what was presented, with metapat.PlaneOf, which
// decodes locally and asks no daemon anything:
//
//	PlaneMeta       the meta.sr.ht plane, scoped by authn.ScopeRead in meta's own
//	                OAuth vocabulary
//	PlaneWorking    the tokens.sr.ht plane, scoped by authn.ActionRead
//	PlaneUnknown    the tokens.sr.ht plane as well — the credential is unreadable
//	                (forged, corrupt or expired: DecodeBearerToken checks expiry
//	                before it reports an issuer), both planes owe it the same 401,
//	                and routing it to the one that already words that refusal
//	                keeps one message rather than two
//
// Routing on the credential rather than on the failure of one plane is what lets
// the meta plane work on an instance whose config.ini has no [tokens.sr.ht]
// section at all: there is no working-token validator there to fail first, only
// an authn.ErrNoAgentPlane that would have to be told apart from a real refusal.
//
// # What each plane is then asked
//
// Both answer the same three questions and only the vocabulary differs. Is the
// credential live; does it belong to the one human this instance answers to; was
// it minted for reading. The last is asked at different moments — the working
// plane's grant in gate below, where the action is known, the PAT's scope inside
// VerifyToken, because that plane guards this one read surface and nothing else —
// and that is the only asymmetry between them.
//
// The statuses are authn.StatusFor's, which is the one table this service maps a
// credential failure with, and it is one table across both planes on purpose:
// 401 for a credential that does not verify — forged, expired, revoked, or issued
// by somebody else — 403 for one that verifies and belongs to a human this
// single-owner instance has nothing to grant, and 503 for a credential that could
// not be *checked*. The last is not "your token is bad": answering 401 to a
// restart of tokens.sr.ht, or to a meta.sr.ht that will not answer, tells every
// agent to re-mint credentials that were never broken.
//
// The messages are written here from what the caller already knows, never from
// the error's own text: authn's errors name usernames and token ids.
func resolveCaller(rs *authn.Resolver, next http.Handler) http.Handler {
func resolveCaller(rs *authn.Resolver, meta MetaAuthenticator, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		presented := authn.BearerFromRequest(r)
		if presented == "" {


@@ 211,8 319,20 @@ func resolveCaller(rs *authn.Resolver, next http.Handler) http.Handler {
			return
		}

		p, err := rs.ResolveAgent(r.Context(), presented,
			r.Header.Get(authn.HeaderAgent), r.Header.Get(authn.HeaderAgentSession))
		var (
			p   authn.Principal
			err error
		)
		if metapat.PlaneOf(presented) == metapat.PlaneMeta {
			// The credential and not the request: a plane that cannot see the
			// request cannot read a cookie off it, and the provenance headers are
			// a write's business, which this surface has none of. See
			// MetaAuthenticator.
			p, err = meta.VerifyToken(r.Context(), presented)
		} else {
			p, err = rs.ResolveAgent(r.Context(), presented,
				r.Header.Get(authn.HeaderAgent), r.Header.Get(authn.HeaderAgentSession))
		}
		if err != nil {
			status := authn.StatusFor(err)
			if status >= http.StatusInternalServerError {


@@ 228,21 348,35 @@ func resolveCaller(rs *authn.Resolver, next http.Handler) http.Handler {
				// and the realm is what tells it which credential was refused.
				w.Header().Set("WWW-Authenticate", authn.Challenge())
			}
			http.Error(w, refusalMessage(status), status)
			http.Error(w, refusalMessage(status, err), status)
			return
		}
		next.ServeHTTP(w, r.WithContext(authn.WithPrincipal(r.Context(), p)))
	})
}

// refusalMessage is what a refused caller is told. It is keyed on the status and
// not on the error, so that nothing about whose token it was, or whether a row
// exists, leaks to a caller holding a credential this service did not accept.
func refusalMessage(status int) string {
// refusalMessage is what a refused caller is told.
//
// It is keyed on the status, so that nothing about whose token it was, or whether
// a row exists, leaks to a caller holding a credential this service did not
// accept. The one thing it reads off the error is authn.ErrMissingScope, and that
// is not a hole in the rule: what it adds is a compile-time constant naming a
// permission the caller can go and tick a box for, which is precisely what gate
// below already does for the other plane's grant. It says nothing about the
// credential, the account or the database.
//
// Without that arm a PAT missing the scope would be told "this token does not
// authorize requests to spec.sr.ht" and have nowhere to go: meta's personal-token
// page is a list of checkboxes, and a client that is not told which one it lacks
// cannot ask for it.
func refusalMessage(status int, err error) string {
	switch status {
	case http.StatusUnauthorized:
		return "the bearer token presented was refused"
	case http.StatusForbidden:
		if errors.Is(err, authn.ErrMissingScope) {
			return "this personal access token does not carry " + authn.ScopeRead
		}
		return "this token does not authorize requests to " + authn.ConfigSection
	default:
		return "the credential could not be verified, try again"


@@ 262,6 396,13 @@ func refusalMessage(status int) string {
// must also hold spec:read — the grant half of the same question, asked here
// because here is where the action ("read") is known.
//
// A caller on the meta.sr.ht plane passes that half untouched, and has already
// answered it. Principal.Authorize is a no-op for authn.PlaneMeta because no PAT
// can carry "spec:read" at all — asking would refuse every one of them — so the
// equivalent question was put to it in meta's own vocabulary at resolution, where
// authn.ScopeRead is what it had to carry. The permission is checked exactly once
// on either plane; only the moment and the grammar differ.
//
// It is one check at the boundary rather than one per field, because every read
// field of this schema is a read and the surface has one action. The webhook
// mutations must NOT rely on it: they would be admitted by a read grant, which