// 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:
// <base-rev>`, 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 the bearer plane /mcp and the REST write plane already
// define, and nothing else:
//
// - 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.
// - 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.
//
// # What the cmd layer wires
//
// gql, err := graph.New(graph.Options{
// Reader: svc, // *service.Service
// Searcher: index, // *search.Index
// Resolver: svc.Resolver(),
// })
// 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 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.
//
// 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 (
"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-spec/authn"
"sourcecraft.dev/bigbes/sr-ht-spec/coreauth"
"sourcecraft.dev/bigbes/sr-ht-spec/graph/api"
)
// 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 the bearer credential a caller presents. Its cookie
// plane is not used here: see the package comment.
Resolver *authn.Resolver
}
// 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")
}
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, 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.
//
// 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.
//
// 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 {
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
}
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), 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 {
switch status {
case http.StatusUnauthorized:
return "the bearer token presented was refused"
case http.StatusForbidden:
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.
//
// 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)))
})
}