coverage~bigbes/sr-ht-spec64cae3afcmd/specsrht/graphql.go

Coverage
92.9% 13/14 statements
Δ
-7.1
Blob
8e6135c
Uncovered L72-L73
1 package main
2
3 import (
4 "fmt"
5 "strconv"
6
7 "github.com/vaughan0/go-ini"
8
9 "sourcecraft.dev/bigbes/sr-ht-ecore/metapat"
10
11 "sourcecraft.dev/bigbes/sr-ht-spec/authn"
12 "sourcecraft.dev/bigbes/sr-ht-spec/graph"
13 )
14
15 // queryRoute is where the GraphQL schema answers. It is core-go's own path,
16 // because that is where every SourceHut client — hut, api.sr.ht, meta's
17 // personal-token page — already looks. The file beside it is served by
18 // sr-ht-ecore's apimeta, at apimeta.Path, because a service that mounts its own
19 // /query is the one thing core-go does not serve that file for.
20 const queryRoute = "/query"
21
22 // apiScopes is what this service publishes at apimeta.Path: the scopes a
23 // meta.sr.ht personal access token can be minted for here.
24 //
25 // A scope is the part after the service name in a meta.sr.ht personal-token
26 // grant, and meta builds the checkboxes of /oauth2/personal-token by fetching
27 // this file from every service it discovers and prefixing each entry with that
28 // service's own name. So "SPECS" here is what makes "spec.sr.ht/SPECS:RO" a token
29 // a human can actually obtain.
30 //
31 // It published nothing until /query started accepting a PAT, and that empty list
32 // was not merely an omission. It meant no PAT could be scoped for this service
33 // even in principle, so the credential api.sr.ht forwards to every service a
34 // federated query touches could never have been presented here — whatever the
35 // endpoint's own code said about it.
36 //
37 // It is graph.GrantScopes and not a literal, so that what the daemon serves is
38 // derived from the same authn.ScopeRead the endpoint checks rather than being a
39 // second spelling of it. apimeta marshals it as [] and never as null; see the
40 // test for why that distinction is instance-wide.
41 var apiScopes = graph.GrantScopes
42
43 // newMetaPlane builds the meta.sr.ht personal access token plane /query accepts
44 // beside the tokens.sr.ht working token every other surface takes.
45 //
46 // It is built here, at the single call site that may use it, and deliberately not
47 // on the *authn.Resolver the daemon hands to every surface. Putting it there
48 // would give it to the REST write plane, /mcp and the push hook as well, which is
49 // precisely what must not happen: those require a tokens.sr.ht grant, and a
50 // personal access token — the credential every account on the instance can mint
51 // for itself, in a vocabulary that cannot spell spec:propose — must not be a way
52 // around one. Building it at one call site makes the scope of the exception
53 // something a reader can see rather than something they have to trust.
54 //
55 // owner is the same [sr.ht] owner-name the resolver was built with, because the
56 // single-owner rule belongs to the service and not to one plane: a PAT belonging
57 // to anybody else is refused exactly as a working token of theirs is.
58 //
59 // The validator is metapat's default backend, which is core-go — it mirrors the
60 // owner's profile and asks meta.sr.ht about revocation through the database and
61 // config in the request context. /query has both: mountGraphQL puts it in a Group
62 // carrying core-go's config and database middleware, which the webhook resolvers
63 // already needed.
64 //
65 // Unlike the working-token plane this one needs no configuration and can never be
66 // legitimately absent: it depends on no per-instance origin. So a failure here is
67 // fatal to startup rather than a degraded mode — an instance that came up without
68 // it would answer 401 to every federated query while looking configured.
69 2 func newMetaPlane(owner string) (*authn.MetaAuth, error) {
70 2 pats, err := metapat.New(metapat.Options{Service: authn.ConfigSection})
71 2 if err != nil {
72 0 return nil, fmt.Errorf("assemble the meta.sr.ht token plane: %w", err)
73 0 }
74 2 plane, err := authn.NewMetaAuth(pats, owner, authn.ScopeRead)
75 2 if err != nil {
76 1 return nil, fmt.Errorf("assemble the meta.sr.ht token plane: %w", err)
77 1 }
78 1 return plane, nil
79 }
80
81 // defaultMaxComplexity is the bound core-go's server.WithSchema would have
82 // applied. It is repeated here because this daemon does not call WithSchema —
83 // /query is mounted on the anonymous router with spec's own credential plane in
84 // front of it — and the value has a second reader that has nothing to do with
85 // HTTP: the webhook delivery worker runs a subscriber's stored query through
86 // corewebhooks.Exec, which compares its complexity against Server.MaxComplexity
87 // and refuses everything above it. Leaving the field at its zero value would
88 // therefore not mean "no limit"; it would mean every webhook delivery fails.
89 const defaultMaxComplexity = 250
90
91 // maxComplexity is [spec.sr.ht::api] max-complexity, or defaultMaxComplexity
92 // when the instance does not set it.
93 //
94 // An unparseable value is a configuration error and is reported as one, rather
95 // than being read as "the operator meant the default": a limit somebody wrote
96 // down and got wrong must not be silently replaced by a different limit.
97 4 func maxComplexity(conf ini.File) (int, error) {
98 4 raw, ok := conf.Get(serviceName+"::api", "max-complexity")
99 4 if !ok || raw == "" {
100 2 return defaultMaxComplexity, nil
101 2 }
102 2 limit, err := strconv.Atoi(raw)
103 2 if err != nil {
104 1 return 0, err
105 1 }
106 1 return limit, nil
107 }