// 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: <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.
//
// 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")
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)
})
}