// Package graph is spec.sr.ht's GraphQL read schema, served at /query. // // # Read only, deliberately // // There are no proposal mutations here. The design defers them until the // proposal state machine has settled, and the reason is technical rather than // scope discipline: the write plane's concurrency story is `If-Match: // `, an HTTP idiom with well-defined 409 semantics that agents get // right by default, and a type that has been federated into api.sr.ht is a // consumed contract — expensive to churn. Read types (space, document, project, // search) are stable from the start; the review types are not, and that is // where the line is drawn. The webhook management mutations are the exception // and are not a proposal write: core-go's webhook engine is GraphQL-native and // has no other surface. // // Serving GraphQL at all is justified without federation: Phase 5's webhooks // are GraphQL-native so gqlgen arrives regardless, and this is the read surface // anything on the instance that already speaks SourceHut GraphQL can consume. // Federating into api.sr.ht is then one `api-origin=` line on the gateway that // nothing here depends on — `hut` builds its endpoint from the per-service // origin and talks to this /query directly either way. // // # Who may read, and with what // // The read plane is fail-closed and this is the same one-line ACL web/ and // mcpsrv/ apply: the instance owner's agents may read, and nobody else may. A // viewer with no read authority gets 401 before the query is parsed — including // for introspection, which is why a gateway federating this schema has to // present a token like any other client. // // 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. // // # 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 // // gql, err := graph.New(graph.Options{ // Reader: svc, // *service.Service // Searcher: index, // *search.Index // Resolver: svc.Resolver(), // Meta: metaAuth, // *authn.MetaAuth, this surface alone // }) // if err != nil { // return err // } // router.Handle("/query", gql) // // 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 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 // resolvers open transactions through core-go's database context, and // WithDefaultMiddleware installs that on the authenticated router only. package graph import ( "errors" "fmt" "log/slog" "net/http" "github.com/99designs/gqlgen/graphql" "github.com/99designs/gqlgen/graphql/handler" "github.com/99designs/gqlgen/graphql/handler/extension" "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 { // Reader is the orchestration layer. *service.Service satisfies it. Reader Reader // Searcher is the one global keyword index. *search.Index satisfies it. Searcher Searcher // Proposals is the proposal read side. It has no production implementation // yet — see the Proposals interface — so it is the one optional field here, // and while it is nil the `proposals` field of the schema fails with an // error saying so rather than answering "none". Proposals Proposals // 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 // gate. It is built once at startup and is safe for concurrent use. type Server struct { http http.Handler schema graphql.ExecutableSchema } // newSchema builds the executable schema over the seams in opts. // // It is unexported now that nothing outside this package wants a schema without // an endpoint. The daemon needs both — the endpoint to serve /query, and the // schema to hand to webhooks.NewQueue, which executes a subscription's stored // query at delivery time — and takes them from one Server, so that the two // cannot become two schemas. func newSchema(opts Options) (graphql.ExecutableSchema, error) { if opts.Reader == nil { return nil, fmt.Errorf("graph: Reader is required") } if opts.Searcher == nil { return nil, fmt.Errorf("graph: Searcher is required") } root := &Resolver{ reader: opts.Reader, searcher: opts.Searcher, proposals: opts.Proposals, } schema := api.NewExecutableSchema(api.Config{Resolvers: root}) // The root resolver holds the schema it is part of. The knot is deliberate: // the webhook resolvers validate and execute a subscriber's stored query // against this service's schema, and used to reach it through core-go's // server context — which exists on the authenticated router and nowhere // else. Holding it here is what lets /query move to the anonymous router // without those resolvers reaching for a context value that is not there. root.schema = schema return schema, nil } // New assembles the /query endpoint over the seams in opts. 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 } // The transport set is core-go's, not gqlgen's NewDefaultServer: POST and // introspection, and nothing else. NewDefaultServer would also install GET, // multipart upload and a websocket transport — a second way in, a file // upload path for a schema with no Upload scalar, and a subscription // transport for a schema with no subscriptions. Every SourceHut service on // this instance answers /query over POST, so a client that works against // one works against this. exec := handler.New(schema) exec.AddTransport(transport.POST{}) exec.Use(extension.Introspection{}) return &Server{ schema: schema, http: resolveCaller(opts.Resolver, opts.Meta, gate(coreContext(exec))), }, nil } // Schema is the executable schema this endpoint serves. The daemon hands it to // webhooks.NewQueue so that the query a subscriber stored is executed against // exactly the schema they wrote it for. func (s *Server) Schema() graphql.ExecutableSchema { return s.schema } // ServeHTTP serves /query behind the chain New built. func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.http.ServeHTTP(w, r) } // resolveCaller turns the presented bearer credential into this request's // principal, or refuses the request. It is the whole credential plane of this // endpoint, and it is authn.Resolver's bearer arm and not its Middleware: // Middleware also reads the unified-login cookie, and a cookie is not a // credential here. // // A request with no Authorization header is given the anonymous principal // explicitly rather than being passed through untouched. That overwrite is the // "no cookie" rule made structural: mounted under a router that already // resolved a cookie identity, this endpoint still sees anonymous and still // answers 401. // // # 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, meta MetaAuthenticator, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { presented := authn.BearerFromRequest(r) if presented == "" { next.ServeHTTP(w, r.WithContext( authn.WithPrincipal(r.Context(), authn.Anonymous()))) return } 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 { // Fail closed and loudly. The alternative — degrading to // anonymous — would turn an unreachable tokens.sr.ht into every // agent silently losing its read access. slog.ErrorContext(r.Context(), "a bearer credential could not be checked", "component", "graph", "path", r.URL.Path, "status", status, scribe.Err(err)) } if status == http.StatusUnauthorized { // RFC 9110 requires the challenge on a 401, and every caller // here is a machine holding a bearer token: naming the scheme // and the realm is what tells it which credential was refused. w.Header().Set("WWW-Authenticate", authn.Challenge()) } 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, 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" } } // gate refuses a caller with no read authority before the query is parsed. // // The ACL is authn.Principal.CanRead — the owner and its agents may read and // nobody else may — the same predicate web/ and mcpsrv/ apply, so the three read // surfaces cannot drift into three policies, which is how a corpus leaks. On // this endpoint the owner half of it is unreachable in practice: the owner is // recognised by a cookie, and resolveCaller above accepts none. It is asked // anyway because it is the shared predicate and not this endpoint's own. // // A caller that clears it and authenticated with a tokens.sr.ht working token // must also hold spec:read — the grant half of the same question, asked here // because here is where the action ("read") is known. // // 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 // is not what a read grant says, so they carry their own owner gate in the // resolver. // // The refusal is a 401 — or a 403 for the missing grant — with a line of text // and never a redirect to meta's login: every caller here is a machine, and // handing a bot 200 and a page of login markup tells it nothing it can act on. // The two statuses stay apart because retrying is worth it for one and never for // the other. func gate(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { p := authn.PrincipalFromContext(r.Context()) if !p.CanRead() { w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("WWW-Authenticate", authn.Challenge()) http.Error(w, "authentication required", http.StatusUnauthorized) return } if err := p.Authorize(authn.ActionRead); err != nil { w.Header().Set("Content-Type", "text/plain; charset=utf-8") http.Error(w, "this token does not grant "+authn.ActionRead, http.StatusForbidden) return } next.ServeHTTP(w, r) }) } // coreContext derives core-go's AuthContext from the principal the gate has // already admitted, because core-go's webhook engine reads one out of the // context and this endpoint no longer runs behind the middleware that puts it // there. // // The user id is the credential's own: authn resolved the token's owner to a // local row and refused the token outright if that row had no id, so there is // nothing to substitute and no default to invent here. func coreContext(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { p := authn.PrincipalFromContext(r.Context()) next.ServeHTTP(w, r.WithContext(coreauth.Context(r.Context(), p, p.UserID))) }) }