// Package authn answers one question for spec.sr.ht — "who is making this
// request?" — and builds the git provenance that answers "who made this write?"
// forever after.
//
// The design has exactly two principals that carry authority:
//
// - the human owner, recognised by the shared `sr.ht.unified-login.v1`
// cookie carrying the instance's [sr.ht] owner-name;
// - an agent, recognised by a bearer token.
//
// Everything else is anonymous. Single-user does not mean "no authorization";
// it relocates it onto agents, which is why Principal distinguishes those two
// and nothing finer. The boundary that bounds an agent's damage is the refs rule
// (agents may only write proposals/*), and that lives in gitx; nothing here
// replaces it.
//
// # 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 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. 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
// where an agent may point a ref, and provenance is still mandatory on every
// agent write.
//
// The cookie and the bearer planes are deliberately asymmetric:
//
// - A cookie that is missing, forged, expired or unreadable yields an
// anonymous principal and never an error. Browsing must keep working.
// - A bearer token that is present but unknown, revoked or corrupt is a hard
// failure. An agent that presented an explicit credential must not be
// silently downgraded to a reader; it would then fail confusingly at the
// write instead of clearly at the door.
//
// Provenance is the other half. Agent identity and session ID are mandatory on
// every agent write, and are recorded in the commit itself so that a plain
// `git log` on any clone carries the audit trail:
//
// Author: claude-code/spec-writer (for bigbes) <agent@spec.srht.bigb.es>
// Committer: bigbes <bigbes@gmail.com>
//
// Add storage model section
//
// X-Agent-Session: 8fb9c9a4-b078-4af1-89eb-d97c522f9921
// X-Agent-Base: 1f0c1d1a1e2b3c4d5e6f708192a3b4c5d6e7f809
//
// A write missing either field is rejected rather than defaulted: a commit
// stamped with a guessed session is worse than no commit, because it launders
// unattributable output as attributed.
//
// This package owns no storage and opens no connections. The "user" row an
// instance token's owner resolves to is reached through UserLookup, and the
// tokens.sr.ht validator through BearerValidator. authn never imports db and
// never calls core-go's auth.LookupUser itself, so the dependency arrow keeps
// pointing downward and the whole package stays testable with no Postgres and no
// daemon to talk to.
package authn
import (
"errors"
"sourcecraft.dev/bigbes/sr-ht-ecore/bearer"
)
// Sentinel errors. Callers compare with errors.Is. The split that matters is
// permanent (the credential is bad — 401/403) versus transient (the backend
// could not answer — 503); IsAuthFailure draws it.
//
// 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.
ErrNoToken = errors.New("no agent token presented")
// ErrNoAgentPlane is returned when a bearer credential is presented to a
// resolver that was built without the tokens.sr.ht plane — an instance whose
// config.ini has no [tokens.sr.ht] origin. It is a wiring failure and not a
// credential failure, so it is deliberately not an IsAuthFailure: telling an
// agent its token is bad when the truth is that this service cannot check
// any token would send it off to re-provision a perfectly good credential.
ErrNoAgentPlane = errors.New("no agent credential plane is configured")
// ErrNotAgent is returned when agent provenance is demanded of a principal
// that is not an agent — the human push path builds no trailers.
ErrNotAgent = errors.New("principal is not an agent")
// ErrMissingProvenance marks an agent write that omits the agent identity,
// the session ID, or the base revision. Mandatory on every agent write; the
// design is explicit that these are not defaultable.
ErrMissingProvenance = errors.New("missing agent provenance")
// ErrInvalidProvenance marks provenance whose values are present but
// unusable: control characters or angle brackets that would forge a git
// signature line or inject an extra trailer, an over-long field, or a base
// revision that is not a hex object name.
ErrInvalidProvenance = errors.New("invalid agent provenance")
// ErrMissingConfig is returned by InstanceFromConfig when the instance
// config lacks a key the provenance identities are built from. It is a
// startup failure, not a request failure.
ErrMissingConfig = errors.New("missing instance config key")
// ErrNotInstanceOwner marks a valid tokens.sr.ht working token whose owner is
// somebody other than the instance owner. 403: the credential verifies and
// the holder is who they say they are, there is simply nothing on this
// single-owner instance to grant them. Deliberately not an IsAuthFailure —
// presenting it again will not help and neither will logging in.
ErrNotInstanceOwner = errors.New("token owner is not the instance owner")
// ErrMissingGrant marks an instance token that authenticated fine but does
// not carry the action being attempted. 403, for the reason
// bearer.ErrForbidden is: what the holder needs is a wider grant, not another
// login.
//
// It is raised by Principal.Authorize, at the layer that knows the action —
// never by the resolver, which runs before the router and so knows none.
ErrMissingGrant = errors.New("token does not grant this action")
// 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
// caller should answer 401/403 — as opposed to a transient backend failure,
// which should answer 503 and be retried. Everything not in this set is
// transient by definition, which is the fail-closed direction: a backend outage
// never reads as a valid credential.
//
// 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
// 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, ErrInvalidPersonalToken)
}