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