// Package graph is spec.sr.ht's GraphQL read schema, served at /query. // // # Read only, deliberately // // There are no 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. // // 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 // // The read plane is fail-closed and this is the same one-line ACL web/ applies: // the instance owner and its 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. // // # What the cmd layer must wire // // 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.Handler()) // // [Server.Handler] installs authn's principal middleware itself, so it can be // mounted on a router that has none. A caller whose router already resolves a // principal uses [Server.Endpoint] instead. package graph import ( "fmt" "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" "sourcecraft.dev/bigbes/sr-ht-spec/authn" "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 turns the unified-login cookie or an agent bearer token into a // principal. Handler installs its middleware; Endpoint does not. Resolver *authn.Resolver } // Server is the /query endpoint: the executable schema plus the read gate. It // is built once at startup and is safe for concurrent use. type Server struct { exec http.Handler resolver *authn.Resolver } // NewSchema builds the executable schema over the seams in opts. The daemon // hands it to core-go's server.WithSchema (to serve /query) and to // webhooks.NewQueue (which executes a subscription's stored query against it at // delivery time), so both share exactly one schema. 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, } return api.NewExecutableSchema(api.Config{Resolvers: root}), nil } // New assembles the executable schema 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{exec: exec, resolver: opts.Resolver}, nil } // Handler is the /query handler with authn's principal middleware installed, so // it can be mounted on a router that has none: // // router.Handle("/query", gql.Handler()) // // Installing that middleware twice is harmless — it is idempotent — so a router // that already applies it may use this too. func (s *Server) Handler() http.Handler { return s.resolver.Middleware()(s.Endpoint()) } // Endpoint is the /query handler without any middleware of its own. The router // it is mounted on must already resolve a principal into the request context // (authn.Resolver.Middleware), or every caller looks anonymous and is refused. func (s *Server) Endpoint() http.Handler { return gate(s.exec) } // 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. // // 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 a no-op for the // owner's cookie and for the local agent token, neither of which carries grants. // // 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) }) }