~bigbes/sr-ht-spec

ref: 65eac3cf812b912b1cdc5b160de08b569ac54510 sr-ht-spec/graph/server.go -rw-r--r-- 6.0 KiB
65eac3cf — Eugene Blikh feat(service): write plane — Propose, Merge, ListProposals (Phase 3) 26 days ago
                                                                                
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
// 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/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
}

// New assembles the executable schema over the seams in opts.
func New(opts Options) (*Server, 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")
	}
	if opts.Resolver == nil {
		return nil, fmt.Errorf("graph: authn Resolver is required")
	}
	root := &Resolver{
		reader:    opts.Reader,
		searcher:  opts.Searcher,
		proposals: opts.Proposals,
	}

	// 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(api.NewExecutableSchema(api.Config{Resolvers: root}))
	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.
//
// One human, no visibility levels: the owner and its agents may read and nobody
// else may. A logged-in human who is not the instance owner has already been
// resolved to anonymous by authn, so this is the whole ACL — the same one web/
// applies, spelled the same way, because two read surfaces with two policies is
// how a corpus leaks.
//
// The refusal is a 401 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.
func gate(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		p := authn.PrincipalFromContext(r.Context())
		if !p.IsOwner() && !p.IsAgent() {
			w.Header().Set("Content-Type", "text/plain; charset=utf-8")
			http.Error(w, "authentication required", http.StatusUnauthorized)
			return
		}
		next.ServeHTTP(w, r)
	})
}