~bigbes/sr-ht-ecore

2e37ec7343344a5eea26c77cda89dfcc11b01e75 — Eugene Blikh 3 days ago 0b1bba8
apimeta: serve the api-meta.json a self-mounted /query owes the instance

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.
2 files changed, 162 insertions(+), 0 deletions(-)

A apimeta/apimeta.go
A apimeta/apimeta_test.go
A apimeta/apimeta.go => apimeta/apimeta.go +81 -0
@@ 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)
	}
}

A apimeta/apimeta_test.go => apimeta/apimeta_test.go +81 -0
@@ 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")
}