From 2e37ec7343344a5eea26c77cda89dfcc11b01e75 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Sat, 15 Aug 2026 13:05:01 +0300 Subject: [PATCH] apimeta: serve the api-meta.json a self-mounted /query owes the instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core-go serves this file for a service assembled through WithSchema, which mounts /query on the authenticated router. A service whose API must answer anonymous callers cannot use that — core-go's auth middleware 401s an un-cookied request — so it mounts /query itself, and then nothing serves the file meta.sr.ht reads to build its personal-token page. The scope list is variadic and marshals empty rather than null, which is the whole reason this is a package and not four lines per service: meta iterates every discovered service's scopes on ONE page, so a single `"scopes": null` is a 500 on /oauth2/personal-token for the entire instance rather than one degraded entry — a failure nobody would find by testing the service that caused it. --- apimeta/apimeta.go | 81 +++++++++++++++++++++++++++++++++++++++++ apimeta/apimeta_test.go | 81 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 apimeta/apimeta.go create mode 100644 apimeta/apimeta_test.go diff --git a/apimeta/apimeta.go b/apimeta/apimeta.go new file mode 100644 index 0000000000000000000000000000000000000000..27a9a9bf7cad79521239273ba0fc7056e7353665 --- /dev/null +++ b/apimeta/apimeta.go @@ -0,0 +1,81 @@ +// 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) + } +} diff --git a/apimeta/apimeta_test.go b/apimeta/apimeta_test.go new file mode 100644 index 0000000000000000000000000000000000000000..8c22929f74868a214a9869c461c1b7e39d7b64d7 --- /dev/null +++ b/apimeta/apimeta_test.go @@ -0,0 +1,81 @@ +package apimeta + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "sourcecraft.dev/bigbes/sr-ht-core/crypto" + + "sourcecraft.dev/bigbes/sr-ht-ecore/ecoretest" +) + +func TestMain(m *testing.M) { + ecoretest.InitCrypto() + m.Run() +} + +func serve(t *testing.T, scopes ...string) (*httptest.ResponseRecorder, Meta) { + t.Helper() + rec := httptest.NewRecorder() + Handler(scopes...).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, Path, nil)) + + var got Meta + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got)) + return rec, got +} + +func TestHandlerPublishesTheScopesAndTheWebhookKey(t *testing.T) { + rec, got := serve(t, "repos") + + require.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, "application/json", rec.Header().Get("Content-Type")) + assert.Equal(t, []string{"repos"}, got.Scopes) + + want := base64.StdEncoding.EncodeToString(crypto.WebhookPubkey) + assert.Equal(t, want, got.WebhookPubkey) + assert.NotEmpty(t, got.WebhookPubkey, "the instance's webhook key is what a consumer verifies payloads with") +} + +// The failure this guards is not this service's: meta.sr.ht iterates every +// discovered service's scopes on one page, so a null here 500s the +// personal-token page for the whole instance. It is checked on the wire and not +// on the struct, because it is the JSON that travels. +func TestNoScopesIsAnEmptyListAndNeverNull(t *testing.T) { + for _, tc := range []struct { + name string + scopes []string + }{ + {"no arguments at all", nil}, + {"an explicitly empty slice", []string{}}, + } { + t.Run(tc.name, func(t *testing.T) { + rec, got := serve(t, tc.scopes...) + + assert.NotContains(t, rec.Body.String(), "null") + assert.Contains(t, rec.Body.String(), `"scopes":[]`) + assert.NotNil(t, got.Scopes) + assert.Empty(t, got.Scopes) + }) + } +} + +func TestHandlerAnswersEveryRequestIdentically(t *testing.T) { + h := Handler("repos", "objects") + + var bodies []string + for range 3 { + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, Path, nil)) + require.Equal(t, http.StatusOK, rec.Code) + bodies = append(bodies, rec.Body.String()) + } + assert.Equal(t, bodies[0], bodies[1]) + assert.Equal(t, bodies[1], bodies[2]) + assert.Contains(t, bodies[0], `"scopes":["repos","objects"]`, "the scopes keep the order they were declared in") +}