// Package graph is dolt.sr.ht's GraphQL read schema, served at /query.
//
// # Why a GraphQL surface at all
//
// Everything on this instance that already speaks SourceHut GraphQL — hut, a
// script written against git.sr.ht's API, api.sr.ht itself — can read this
// service the moment it has a /query, and could not before it. Federating into
// api.sr.ht is then one `api-origin=` line on the gateway that nothing here
// depends on.
//
// # Read only, deliberately
//
// There are no mutations, for spec.sr.ht's reason: a type federated into the
// gateway is a consumed contract, expensive to churn, so only what has settled
// is published. Creating, renaming and deleting a database each move a metadata
// row and an on-disk store together, and that pairing is young. Rows and diffs
// are absent for a different reason — they live on /mcp, where a read that had
// to be clipped says so in its own answer.
//
// # Who may read what
//
// Not spec.sr.ht's single-owner gate: dolt.sr.ht is multi-user, and its
// visibility rules already exist. The endpoint therefore answers anyone, and
// every field applies the same access matrix the web pages and the MCP tools do:
//
// - The credential is the bearer plane /mcp already defines — a meta personal
// access token scoped `dolt.sr.ht/repos:RO`, or a tokens.sr.ht working token
// carrying `dolt:read`. No cookie: an API client is not a browser, and this
// endpoint is deliberately outside web's same-origin group.
// - Anonymous is a normal caller. It reads what an anonymous visitor reads,
// which is why /query is mounted on the anonymous router: core-go's own auth
// middleware 401s an un-cookied request, and a public database is public.
// - A database the caller may not see resolves to null, never to an
// authorization error, so its existence cannot be read out of the shape of
// the refusal.
//
// # What the cmd layer wires
//
// gql, err := graph.New(graph.Options{
// Repos: dbAdapter, // request-scoped, over db.Store
// Browse: browseAdapter, // over browse.Open
// Validator: validator, // may be nil: no tokens.sr.ht on the instance
// })
// if err != nil { return err }
// router.Handle("/query", gql)
//
// Server installs its own credential middleware, so it can be mounted on a
// router that resolves none.
package graph
import (
"context"
"errors"
"log/slog"
"net/http"
"github.com/99designs/gqlgen/graphql/handler"
"github.com/99designs/gqlgen/graphql/handler/extension"
"github.com/99designs/gqlgen/graphql/handler/transport"
"go.bigb.es/auxilia/culpa"
"go.bigb.es/auxilia/scribe"
"sourcecraft.dev/bigbes/sr-ht-ecore/bearer"
"sourcecraft.dev/bigbes/sr-ht-dolt/authn"
"sourcecraft.dev/bigbes/sr-ht-dolt/core"
"sourcecraft.dev/bigbes/sr-ht-dolt/graph/api"
)
// ServiceName is what this service calls itself in a bearer challenge.
const ServiceName = "dolt.sr.ht"
var bearerChallenge = bearer.Challenge(ServiceName)
// Options is everything a Server needs. New says which one is missing rather
// than failing later inside a resolver.
type Options struct {
// Repos is the metadata store. In production it is the same request-scoped
// adapter web and mcpsrv use.
Repos Repos
// Browse opens read-only sessions over the bare stores.
Browse BrowseOpener
// Validator verifies a tokens.sr.ht working token. It may be nil — an
// instance that runs no tokens.sr.ht is a supported configuration — and then
// a working token is refused while meta PATs and anonymous callers keep
// working, exactly as on /mcp.
Validator authn.InstanceValidator
}
// 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
}
// New builds the endpoint. Repos and Browse are required: a surface that
// answered every query "could not be read" because a seam was never wired would
// be a daemon that starts and does not work.
func New(opts Options) (*Server, error) {
if opts.Repos == nil {
return nil, culpa.New("graph: nil Repos")
}
if opts.Browse == nil {
return nil, culpa.New("graph: nil BrowseOpener")
}
srv := handler.New(api.NewExecutableSchema(api.Config{
Resolvers: &Resolver{repos: opts.Repos, opener: opts.Browse},
}))
// POST alone: this schema is read-only but its transport is not a GET API.
// A GET query would be a cross-origin-readable URL for data that is often
// private, and there is no cookie plane here to make that safe.
srv.AddTransport(transport.POST{})
// Introspection is on: a client that cannot introspect cannot generate a
// typed client, and everything this schema describes is already gated per
// field by the access matrix.
srv.Use(extension.Introspection{})
return &Server{
http: resolveCaller(opts.Validator, requireReadGrant(srv)),
}, nil
}
// 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 the request's
// principal, or refuses the request. It is /mcp's middleware, arm for arm,
// because it is the same credential plane and a second reading of it would be a
// second thing to keep in agreement:
//
// no credential anonymous — a normal caller here
// ErrMissingGrant 403, the credential is good and the caller is known
// ErrInvalidToken 401 + the challenge — forged, expired, revoked, or a
// working token on an instance with no tokens.sr.ht to
// verify it against
// anything else 503 — the credential could not be *checked*, which is not
// "your token is bad": answering 401 to a restart of
// meta.sr.ht tells every client 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, hosts and token ids.
func resolveCaller(v authn.InstanceValidator, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
presented := authn.ParseBearer(r)
if presented == "" {
next.ServeHTTP(w, r)
return
}
bc, err := authn.ResolveBearer(r.Context(), v, presented)
if err != nil {
switch {
case errors.Is(err, authn.ErrMissingGrant):
// Asked before ErrInvalidToken: ResolveBearer joins the two, so
// a caller that can say 403 asks for this sentinel first.
http.Error(w, "this credential does not grant read access to "+ServiceName+" databases",
http.StatusForbidden)
case errors.Is(err, authn.ErrInvalidToken):
w.Header().Set("WWW-Authenticate", bearerChallenge)
http.Error(w, "the bearer token presented was refused", http.StatusUnauthorized)
default:
slog.Error("a bearer credential could not be checked",
"component", "graph", scribe.Err(err))
http.Error(w, "the credential could not be verified, try again",
http.StatusServiceUnavailable)
}
return
}
ctx := authn.WithCaller(r.Context(), bc.AuthContext)
ctx = withBearerCaller(ctx, bc)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// requireReadGrant is one check at the boundary rather than one per field,
// because every field of this schema is a read, so the surface has exactly one
// action. A mutation added here must NOT rely on it: it would be admitted by a
// read grant, which is not what a read grant says.
//
// A meta PAT and an anonymous caller pass, and neither is a hole: a PAT carries
// no tokens.sr.ht grants at all — the vocabularies do not overlap — and its own
// scoping was applied when it resolved, while an anonymous caller is held to
// visibility by every resolver.
func requireReadGrant(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if bc := bearerCallerFrom(r.Context()); bc != nil {
if err := bc.Authorize(core.GrantRead); err != nil {
http.Error(w, "this token does not carry the "+core.GrantRead+" grant",
http.StatusForbidden)
return
}
}
next.ServeHTTP(w, r)
})
}
type contextKey struct{ name string }
// bearerCallerKey holds the resolved *authn.BearerCaller for the grant gate.
// The identity itself goes where the rest of the service looks for it
// (authn.WithCaller); what has no house-wide home is the tokens.sr.ht grant set.
var bearerCallerKey = contextKey{"graph.bearerCaller"}
func withBearerCaller(ctx context.Context, bc *authn.BearerCaller) context.Context {
return context.WithValue(ctx, bearerCallerKey, bc)
}
func bearerCallerFrom(ctx context.Context) *authn.BearerCaller {
bc, _ := ctx.Value(bearerCallerKey).(*authn.BearerCaller)
return bc
}