// Package apimeta serves the api-meta.json a SourceHut service publishes beside // its GraphQL endpoint, for the services on this instance that mount /query // themselves. // // core-go serves this file for a service assembled through // server.Server.WithSchema, which mounts /query on the *authenticated* router. // A service whose API must answer anonymous callers — a public repository, a // public database — cannot use that: core-go's auth middleware 401s an // un-cookied request. Such a service mounts its own /query, and then owes the // instance this file too, because nothing else will serve it. // // # Why the scope list must never be null // // meta.sr.ht fetches api-meta.json from every service it discovers when it // renders /oauth2/personal-token, and iterates each service's "scopes" to build // the grant checkboxes. A JSON `null` there is not a service with no scopes: it // is a nil iteration in meta, which is a 500 on the personal-token page for the // WHOLE instance — every service's grants, not just the one that answered // badly. That is why Handler takes its scopes variadically and marshals an // empty slice for none: the failure mode is one nobody would find by testing // the service that caused it. // // # What a scope is // // The part after the service name in a personal-token grant. A service that // checks "dolt.sr.ht/repos:RO" publishes "repos" here; meta prefixes the // service name itself. The two spellings are the same fact written twice, so a // service should assert them equal in a test rather than hope. package apimeta import ( "encoding/base64" "encoding/json" "net/http" "sourcecraft.dev/bigbes/sr-ht-core/crypto" ) // Path is where meta.sr.ht and every other client look for this file. It is // core-go's own path, so a service that mounts /query itself stays // indistinguishable from one that did not. const Path = "/query/api-meta.json" // Meta is the document itself. It is exported so a test can unmarshal into it // rather than into a map with the field names spelled a second time. type Meta struct { // Scopes are the grants this service defines, without the service prefix. // It marshals as [] and never as null — see the package comment. Scopes []string `json:"scopes"` // WebhookPubkey is the instance's Ed25519 webhook public key, base64. It is // the same key for every service (it comes from the shared [webhooks] // private-key), and it is what a webhook consumer verifies payloads with. WebhookPubkey string `json:"webhook-pubkey"` } // Handler serves api-meta.json for a service declaring these scopes. // // crypto.InitCrypto must have run — core-go's server.New does it — or the // published key is the empty string. That is a boot-order bug rather than a // runtime condition, so it is not reported per request. func Handler(scopes ...string) http.HandlerFunc { // Marshalled once: the document cannot change between requests, and // building it per request would be one more thing that can fail on a path // meta.sr.ht calls for the whole instance. if scopes == nil { scopes = []string{} } body, err := json.Marshal(Meta{ Scopes: scopes, WebhookPubkey: base64.StdEncoding.EncodeToString(crypto.WebhookPubkey), }) if err != nil { // Two strings and a string slice; there is no input that reaches this. panic("apimeta: marshalling api-meta.json: " + err.Error()) } return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") _, _ = w.Write(body) } }