// 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 }