@@ 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)
+ }
+}
@@ 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")
+}