~bigbes/sr-ht-spec

5bb0bb134d608263da3197df9fb4f1d8a3fe42db — Eugene Blikh 2 days ago 824788a
graph: serve /query on the anonymous router with a bearer credential

The schema was mounted by core-go's server.WithSchema, on the authenticated
router, behind an ownerOnly middleware. That put it on meta.sr.ht's OAuth
vocabulary while every other surface of this service — the web UI, /mcp, the
REST write plane — authenticates with a tokens.sr.ht working token, so a
credential that reads through /mcp was refused by the endpoint meant to be the
instance-native read plane. dolt.sr.ht's graph package is the pattern; this
follows it.

/query is now mounted on the anonymous router and graph.Server installs its own
credential middleware: a working token owned by [sr.ht] owner-name and carrying
spec:read reads, one without that grant is 403, one belonging to anybody else is
403, and anything that does not verify is 401 with the bearer challenge. A
cookie is not a credential here — the principal is overwritten with the
anonymous one when no bearer token is presented, so no middleware above the
mount point can promote a browser session into read authority.

ownerOnly's rule survives the move: it compared auth.AuthContext.Username to the
owner, and authn's resolver refuses a foreign token's owner at the door with the
same 403. What it also did — remapping the owner to AUTH_INTERNAL so core-go's
webhook engine would accept them — is now coreauth's, which is what that package
was written for and had no caller for until today.

A service that mounts its own /query owes the instance api-meta.json, because
core-go serves that file only for the schemas it hosts itself. sr-ht-ecore's
apimeta serves it, with an empty scope list: spec.sr.ht defines no meta OAuth
scope and no @access directive to check one against, and a JSON null there is a
500 on meta's personal-token page for the whole instance.

Two consequences worth naming.

A meta.sr.ht personal access token no longer reaches /query. It did while
core-go's auth.Middleware stood in front of it. Accepting one again means giving
spec.sr.ht a meta scope first, and there is none to invent.

WebhookSubscription.sample cannot be rendered on this endpoint and says so.
corewebhooks.Exec reads the complexity bound off core-go's server context, which
only WithDefaultMiddleware installs and which cannot be built from outside that
package. Delivery is unaffected: the queue's context comes from WithQueues and
does carry it — but MaxComplexity must now be set by hand, because zero there
fails every delivery rather than imposing no limit.
A cmd/specsrht/graphql.go => cmd/specsrht/graphql.go +55 -0
@@ 0,0 1,55 @@
package main

import (
	"strconv"

	"github.com/vaughan0/go-ini"
)

// queryRoute is where the GraphQL schema answers. It is core-go's own path,
// because that is where every SourceHut client — hut, api.sr.ht, meta's
// personal-token page — already looks. The file beside it is served by
// sr-ht-ecore's apimeta, at apimeta.Path, because a service that mounts its own
// /query is the one thing core-go does not serve that file for.
const queryRoute = "/query"

// apiScopes is what this service publishes at apimeta.Path: nothing.
//
// A scope is the part after the service name in a meta.sr.ht personal-token
// grant, and spec.sr.ht defines none — no AccessScope enum, no @access directive
// on any field, and no code path that reads one. Its grant vocabulary is
// tokens.sr.ht's (authn.ActionRead, authn.ActionPropose), which meta neither
// mints nor advertises, so the honest list is empty and not a placeholder.
//
// It is a variable so that what the daemon serves and what its test asserts are
// one value rather than two spellings of an intention. apimeta marshals it as []
// and never as null; see the test for why that distinction is instance-wide.
var apiScopes []string

// defaultMaxComplexity is the bound core-go's server.WithSchema would have
// applied. It is repeated here because this daemon does not call WithSchema —
// /query is mounted on the anonymous router with spec's own credential plane in
// front of it — and the value has a second reader that has nothing to do with
// HTTP: the webhook delivery worker runs a subscriber's stored query through
// corewebhooks.Exec, which compares its complexity against Server.MaxComplexity
// and refuses everything above it. Leaving the field at its zero value would
// therefore not mean "no limit"; it would mean every webhook delivery fails.
const defaultMaxComplexity = 250

// maxComplexity is [spec.sr.ht::api] max-complexity, or defaultMaxComplexity
// when the instance does not set it.
//
// An unparseable value is a configuration error and is reported as one, rather
// than being read as "the operator meant the default": a limit somebody wrote
// down and got wrong must not be silently replaced by a different limit.
func maxComplexity(conf ini.File) (int, error) {
	raw, ok := conf.Get(serviceName+"::api", "max-complexity")
	if !ok || raw == "" {
		return defaultMaxComplexity, nil
	}
	limit, err := strconv.Atoi(raw)
	if err != nil {
		return 0, err
	}
	return limit, nil
}

A cmd/specsrht/graphql_test.go => cmd/specsrht/graphql_test.go +172 -0
@@ 0,0 1,172 @@
package main

import (
	"database/sql"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"os"
	"strings"
	"testing"

	"github.com/go-chi/chi/v5"
	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"
	"github.com/vaughan0/go-ini"

	"sourcecraft.dev/bigbes/sr-ht-core/config"
	"sourcecraft.dev/bigbes/sr-ht-core/crypto"
	"sourcecraft.dev/bigbes/sr-ht-core/database"
	"sourcecraft.dev/bigbes/sr-ht-ecore/apimeta"
)

// TestMain initialises the crypto globals apimeta.Handler reads the webhook
// public key out of. core-go's server.New does it in the daemon; here the
// config from main_test.go stands in.
func TestMain(m *testing.M) {
	crypto.InitCrypto(completeConfig())
	os.Exit(m.Run())
}

// The route the schema answers on is core-go's own, so that a client which found
// this service through meta.sr.ht — hut, api.sr.ht, a script written against
// git.sr.ht's API — finds /query where it already looks. A service that mounts
// its own endpoint gets no help from core-go here, which is exactly why the
// constant is asserted rather than assumed.
func TestQueryRouteIsCoreGosPath(t *testing.T) {
	assert.Equal(t, "/query", queryRoute)
	assert.Equal(t, queryRoute+"/api-meta.json", apimeta.Path)
}

// api-meta.json must be served, and its scope list must be an empty array and
// never a JSON null.
//
// meta.sr.ht fetches this file from every service it discovers when it renders
// /oauth2/personal-token, and iterates the "scopes" field to build the grant
// checkboxes. A null there is a nil iteration in meta — a 500 on that page for
// the WHOLE instance, every service's grants and not just this one's. It is a
// failure nobody would find by testing the service that caused it, which is why
// the assertion lives here even though the marshalling is sr-ht-ecore's.
//
// Empty is also the honest answer for spec.sr.ht rather than a placeholder: this
// service defines no meta OAuth scope and no @access directive to check one
// against. Its grant vocabulary is tokens.sr.ht's — authn.ActionRead and
// authn.ActionPropose — which meta neither mints nor advertises.
func TestAPIMetaAdvertisesNoScopeAndNeverNull(t *testing.T) {
	rec := httptest.NewRecorder()
	// apiScopes and not a literal: this asserts what mountWeb actually serves.
	apimeta.Handler(apiScopes...).ServeHTTP(rec, httptest.NewRequest(http.MethodGet, apimeta.Path, nil))

	require.Equal(t, http.StatusOK, rec.Code)
	assert.Contains(t, rec.Body.String(), `"scopes":[]`,
		"a JSON null here is a 500 on meta's personal-token page for the whole instance")

	var got apimeta.Meta
	require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
	assert.NotNil(t, got.Scopes)
	assert.Empty(t, got.Scopes)
	assert.NotEmpty(t, got.WebhookPubkey, "a webhook consumer verifies payloads with this")
}

// The wiring itself, over a real chi router: the route the endpoint answers on,
// the two core-go context values the webhook resolvers reach for, and the
// api-meta.json beside it.
//
// The database context is the one worth a test rather than a comment. /query
// moved off the authenticated router, and WithDefaultMiddleware installs
// database.Middleware there and nowhere else — so without this Group every
// webhook mutation would panic on the first transaction, and no read would,
// which is exactly the shape of a bug that reaches production.
func TestMountGraphQL(t *testing.T) {
	conf := completeConfig()
	pool := &sql.DB{} // never queried: the handler only proves the context carries it

	var (
		reached  bool
		gotConf  ini.File
		gotPool  *sql.DB
		endpoint = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			reached = true
			gotConf = config.ForContext(r.Context())
			// DBForContext and not ForContext: the latter dials a connection,
			// and the pool here is a zero value that would panic on one. Both
			// read the same context value, which is what is under test.
			gotPool = database.DBForContext(r.Context())
			w.WriteHeader(http.StatusTeapot)
		})
	)

	router := chi.NewRouter()
	mountGraphQL(router, conf, pool, endpoint)
	// The web UI claims "/", and "query" is a legal space name. chi resolves by
	// trie specificity rather than by registration order — measured, not assumed
	// — so /query wins over the catch-all; this is here to prove that rather
	// than to rely on the order the daemon happens to register them in.
	router.Mount("/", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
		w.WriteHeader(http.StatusGone)
	}))

	srv := httptest.NewServer(router)
	defer srv.Close()

	t.Run("the endpoint answers at /query with the core-go context", func(t *testing.T) {
		resp, err := srv.Client().Post(srv.URL+queryRoute, "application/json", strings.NewReader(`{}`))
		require.NoError(t, err)
		defer resp.Body.Close()

		require.Equal(t, http.StatusTeapot, resp.StatusCode, "the web UI's catch-all swallowed /query")
		assert.True(t, reached)
		assert.Equal(t, conf, gotConf, "config.ForContext panics without config.Middleware")
		assert.Same(t, pool, gotPool, "the webhook resolvers open transactions through this")
	})

	t.Run("api-meta.json is served beside it", func(t *testing.T) {
		resp, err := srv.Client().Get(srv.URL + apimeta.Path)
		require.NoError(t, err)
		defer resp.Body.Close()

		require.Equal(t, http.StatusOK, resp.StatusCode)
		var got apimeta.Meta
		require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
		assert.NotNil(t, got.Scopes)
		assert.Empty(t, got.Scopes)
	})
}

// The complexity bound core-go's WithSchema would have set. The daemon does not
// call WithSchema any more, and the value's second reader is not the HTTP
// surface at all: the webhook delivery worker runs a subscriber's stored query
// through corewebhooks.Exec, which refuses anything above Server.MaxComplexity.
// Zero there would fail every delivery rather than impose no limit, so what this
// function returns when the instance says nothing is load-bearing.
func TestMaxComplexity(t *testing.T) {
	t.Run("defaults to core-go's bound", func(t *testing.T) {
		limit, err := maxComplexity(completeConfig())
		require.NoError(t, err)
		assert.Equal(t, 250, limit)
		assert.Equal(t, defaultMaxComplexity, limit)
	})

	t.Run("an empty value is no value", func(t *testing.T) {
		conf := completeConfig()
		conf[serviceName+"::api"] = ini.Section{"max-complexity": ""}
		limit, err := maxComplexity(conf)
		require.NoError(t, err)
		assert.Equal(t, defaultMaxComplexity, limit)
	})

	t.Run("the instance's value wins", func(t *testing.T) {
		conf := completeConfig()
		conf[serviceName+"::api"] = ini.Section{"max-complexity": "400"}
		limit, err := maxComplexity(conf)
		require.NoError(t, err)
		assert.Equal(t, 400, limit)
	})

	t.Run("a value that does not parse is an error, not the default", func(t *testing.T) {
		conf := completeConfig()
		conf[serviceName+"::api"] = ini.Section{"max-complexity": "lots"}
		_, err := maxComplexity(conf)
		require.Error(t, err, "a limit somebody wrote down and got wrong must not be silently replaced")
	})
}

M cmd/specsrht/main.go => cmd/specsrht/main.go +108 -78
@@ 81,16 81,16 @@ import (
	"syscall"
	"time"

	"github.com/99designs/gqlgen/graphql"
	"github.com/go-chi/chi/v5"
	chimw "github.com/go-chi/chi/v5/middleware"
	_ "github.com/lib/pq" // registers the "postgres" database/sql driver
	"github.com/vaughan0/go-ini"
	"go.bigb.es/auxilia/scribe"
	"sourcecraft.dev/bigbes/sr-ht-ecore/apimeta"
	"sourcecraft.dev/bigbes/sr-ht-ecore/logging"

	"sourcecraft.dev/bigbes/sr-ht-core/auth"
	"sourcecraft.dev/bigbes/sr-ht-core/config"
	"sourcecraft.dev/bigbes/sr-ht-core/database"
	coreserver "sourcecraft.dev/bigbes/sr-ht-core/server"
	"sourcecraft.dev/bigbes/sr-ht-core/webhooks"



@@ 347,30 347,45 @@ func run(conf ini.File, log *slog.Logger) error {

	// server.New parses -b/-d/-m/-p and runs crypto.InitCrypto(conf), whose
	// two required keys validateConfig already checked, so it cannot fatal
	// here for a reason we have not already reported.
	// /query is served on the authenticated router so core-go installs the
	// auth/database/server context the webhook engine needs — WithDefaultMiddleware
	// brings that whole stack (and, deliberately, core-go auth: agents therefore
	// read via MCP/REST, not /query). ownerOnly restricts /query to the instance
	// owner and maps them to AUTH_INTERNAL so the webhook engine's
	// NewAuthConfig/FilterWebhooks (which refuse cookie auth) accept them.
	// WithQueues starts the webhook delivery worker with a context carrying that
	// same stack; the queue executes a subscription's stored query against the
	// shared schema at delivery time.
	// The scope list must be an empty slice and not nil. core-go serves it
	// verbatim at /query/api-meta.json, where a nil slice marshals to
	// `"scopes": null` — and meta.sr.ht's OAuth page iterates that field for
	// every service it discovers, so one null there is a 500 on
	// /oauth2/personal-token for the whole instance, not a degraded entry.
	// Empty is also the honest answer: this service is owner-only (see
	// ownerOnly above) and defines no AccessScope enum to grant against.
	webhookQueue := webhooks.NewQueue(surf.schema, conf)
	// here for a reason we have not already reported. It must run before
	// mountRoutes: apimeta.Handler reads the webhook public key InitCrypto
	// establishes, once, when the handler is built.
	//
	// WithDefaultMiddleware is here for the database pool, the redis client and
	// the email queue that WithQueues hands the webhook delivery worker — not
	// for the authenticated router it decorates, which now carries no routes at
	// all. There is deliberately no WithSchema: it would mount /query on that
	// authenticated router, behind core-go's auth.Middleware, which speaks
	// meta.sr.ht's OAuth vocabulary and not the tokens.sr.ht one every other
	// surface of this service accepts. /query is mounted on the anonymous
	// router by mountRoutes instead, with graph's own credential middleware in
	// front of it, and api-meta.json is served there too because core-go serves
	// that file only for the schemas it hosts itself.
	//
	// MaxComplexity is the one thing WithSchema set that still has to be set,
	// and its reader has nothing to do with serving /query: the webhook delivery
	// worker runs a subscriber's stored query through corewebhooks.Exec, which
	// reads the bound off this field — through the context WithQueues gives it,
	// which is the one context in this daemon that still carries core-go's
	// server — and refuses everything above it. Zero does not mean "no limit"
	// there; it means every delivery fails, logged and not raised. Measured, by
	// removing this line: "operation has complexity 2, which exceeds the maximum
	// of 0" and no delivery.
	//
	// Server.Schema is deliberately not set. WithSchema assigns it, but nothing
	// in core-go reads it back — the delivery worker executes against the schema
	// it was handed in NewQueue, and the resolvers that used to reach for it
	// through the server context now hold their own.
	limit, err := maxComplexity(conf)
	if err != nil {
		return fmt.Errorf("[%s::api] max-complexity: %w", serviceName, err)
	}
	webhookQueue := webhooks.NewQueue(surf.gql.Schema(), conf)
	srv := coreserver.New(serviceName, defaultBind, conf, os.Args).
		WithDefaultMiddleware().
		WithMiddleware(ownerOnly(cfg.Instance.OwnerName)).
		WithSchema(surf.schema, []string{}).
		WithQueues(webhookQueue.Queue)
	mountRoutes(srv.AnonRouter(), conf, surf)
		WithDefaultMiddleware()
	srv.MaxComplexity = limit
	srv.WithQueues(webhookQueue.Queue)
	mountRoutes(srv.AnonRouter(), conf, pool, surf)

	// Now that the webhook queue is started (WithQueues gave its worker the
	// server+database+config context), install the sink so proposal lifecycle


@@ 501,7 516,7 @@ func refreshHooks(ctx context.Context, log *slog.Logger, svc *service.Service, b
}

// mountRoutes installs what the daemon serves over HTTP.
func mountRoutes(router chi.Router, conf ini.File, surfaces *surfaces) {
func mountRoutes(router chi.Router, conf ini.File, pool *sql.DB, surfaces *surfaces) {
	// server.New already froze the anonymous router for direct middleware
	// registration, so middleware and routes go in together inside a Group —
	// which chi permits on a fresh inline mux sharing the same routing tree.


@@ 514,7 529,7 @@ func mountRoutes(router chi.Router, conf ini.File, surfaces *surfaces) {
		})
	})

	mountWeb(router, conf, surfaces)
	mountWeb(router, conf, pool, surfaces)
}

// surfaces are the three Phase 2 read surfaces, assembled once at startup.


@@ 527,13 542,12 @@ type surfaces struct {
	mcp   http.Handler
	api   http.Handler

	// schema is the GraphQL executable schema. Unlike the other surfaces it is
	// not a mounted handler: /query is served by core-go's server (WithSchema)
	// on the authenticated router, because the webhook engine needs core-go's
	// auth/database/server context there. The same schema is also handed to the
	// webhook queue, which executes a subscription's stored query against it at
	// delivery time — one schema, both callers.
	schema graphql.ExecutableSchema
	// gql is the /query endpoint. Like /mcp and /api it carries its own
	// credential middleware and is mounted on the anonymous router; unlike them
	// it also owns the executable schema, which is handed to the webhook queue
	// so a subscription's stored query is executed at delivery time against
	// exactly the schema its author wrote it for.
	gql *graph.Server
}

// newSurfaces opens the index and builds the three read surfaces over it.


@@ 586,7 600,11 @@ func newSurfaces(conf ini.File, cfg service.Config, svc *service.Service, versio
	// the gate just makes the read tools match.
	mcp = svc.Resolver().Middleware()(mcpsrv.Gate(mcp))

	schema, err := graph.NewSchema(graph.Options{
	// /query, with the same credential plane /mcp and /api use — the resolver is
	// the one svc holds, so a token that reads through one surface reads through
	// all three. graph.Server installs that middleware itself, which is why it is
	// mounted on the anonymous router below and not through core-go's WithSchema.
	gql, err := graph.New(graph.Options{
		Reader:    svc,
		Searcher:  index,
		Proposals: graph.NewProposals(svc),


@@ 594,7 612,7 @@ func newSurfaces(conf ini.File, cfg service.Config, svc *service.Service, versio
	})
	if err != nil {
		index.Close()
		return nil, fmt.Errorf("assemble the GraphQL schema: %w", err)
		return nil, fmt.Errorf("assemble the GraphQL surface: %w", err)
	}

	rest, err := api.New(api.Options{Writer: svc, Resolver: svc.Resolver()})


@@ 603,7 621,7 @@ func newSurfaces(conf ini.File, cfg service.Config, svc *service.Service, versio
		return nil, fmt.Errorf("assemble the REST write surface: %w", err)
	}

	return &surfaces{index: index, web: site, mcp: mcp, api: rest.Handler(), schema: schema}, nil
	return &surfaces{index: index, web: site, mcp: mcp, api: rest.Handler(), gql: gql}, nil
}

func (s *surfaces) Close() error {


@@ 614,55 632,67 @@ func (s *surfaces) Close() error {
}

// mountWeb attaches the anonymous-router HTTP surfaces: the web UI, the MCP
// endpoint, and the REST write plane. /query is NOT here — it is served by
// core-go's server on the authenticated router (see run), because the webhook
// engine needs core-go's auth/database/server context, which only
// WithDefaultMiddleware installs.
//
// Order is load-bearing: /mcp and /api are registered before the web UI, which
// mounts at "/" and would otherwise swallow them as document paths — "mcp" and
// "api" are both legal space names as far as the router is concerned.
//
// These three keep spec's own authentication (agent bearer tokens,
// anonymous-capable reads, login redirects), which is why they stay on the anon
// router rather than behind core-go's 401-by-default auth.
// ownerOnly is the /query access gate and the webhook engine's auth adapter, in
// one middleware. It runs after core-go's auth.Middleware (which has already
// 401'd anyone without a valid credential and resolved a username), so:
//
//   - A non-owner authenticated user is refused with 403. core-go's auth admits
//     any valid meta user — it JIT-creates a row on a table miss — but spec.sr.ht
//     is single-owner: only the configured owner-name may reach /query at all.
//     This restores what spec's own authn did (everyone but the owner is nobody)
//     now that /query is behind core-go auth.
//   - The owner's context is remapped to AUTH_INTERNAL. The webhook engine's
//     NewAuthConfig and FilterWebhooks refuse AUTH_COOKIE outright, and INTERNAL
//     bypasses the (unused) @access scope checks — so this remap is what lets the
//     single owner, authenticated by a web cookie, manage webhooks.
func ownerOnly(owner string) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			ac := auth.ForContext(r.Context())
			if ac.Username != owner {
				http.Error(w, "spec.sr.ht: only the instance owner may use /query", http.StatusForbidden)
				return
			}
			internal := *ac
			internal.AuthMethod = auth.AUTH_INTERNAL
			next.ServeHTTP(w, r.WithContext(auth.Context(r.Context(), &internal)))
		})
	}
}

func mountWeb(router chi.Router, _ ini.File, s *surfaces) {
// endpoint, the REST write plane and /query.
//
// All four keep spec's own authentication (tokens.sr.ht working tokens,
// anonymous-capable reads, login redirects for the browser), which is why they
// are here rather than behind core-go's 401-by-default auth. /query used to be
// the exception, served by core-go's server.WithSchema on the authenticated
// router; it authenticated with meta's OAuth vocabulary there, which is not the
// vocabulary the other three speak, and a caller holding a working token that
// works everywhere else on this service was refused by the one surface meant to
// be the instance-native read plane.
//
// "mcp", "api" and "query" are all legal space names as far as the router is
// concerned, so each of them is a path the web UI's "/" mount could plausibly
// serve as a document. It does not: chi resolves by trie specificity and not by
// registration order, which was measured rather than assumed — a preceding
// comment here asserted the opposite, and moving mountGraphQL after the "/"
// mount changes no route. The registration order below is for reading, not for
// routing.
func mountWeb(router chi.Router, conf ini.File, pool *sql.DB, s *surfaces) {
	if s == nil {
		return
	}
	router.Handle("/mcp", s.mcp)
	router.Mount("/api", s.api)
	mountGraphQL(router, conf, pool, s.gql)
	router.Mount("/", s.web.Handler())
}

// mountGraphQL installs /query and the api-meta.json beside it.
//
// The endpoint carries its own credential middleware, so it goes here on the
// anonymous router rather than through core-go's server.WithSchema. It does need
// two things from the router that /mcp and /api do not:
//
//   - core-go's config and database middleware. The webhook management resolvers
//     open transactions through core-go's database context, and
//     WithDefaultMiddleware installs that on the authenticated router only —
//     which this is not.
//   - api-meta.json, at core-go's own path. meta.sr.ht fetches that file from
//     every service it discovers to build /oauth2/personal-token, core-go serves
//     it only for the schemas it hosts itself, and this service now hosts its
//     own. A 404 there is a broken personal-token page for the whole instance.
//
// The middleware goes on a Group and not on router, because chi refuses a Use
// once any route exists on a mux, and this router already has some. The Group is
// a fresh inline mux over the same routing tree, which is where middleware and
// routes can still be attached together.
//
// api-meta.json is outside that Group deliberately: it is a static document that
// reads neither the config nor the database, and giving it a database
// transaction's worth of setup for every poll from meta would be work done for
// nobody.
func mountGraphQL(router chi.Router, conf ini.File, pool *sql.DB, gql http.Handler) {
	router.Group(func(r chi.Router) {
		r.Use(config.Middleware(conf, serviceName))
		r.Use(database.Middleware(pool))
		r.Handle(queryRoute, gql)
	})
	router.Get(apimeta.Path, apimeta.Handler(apiScopes...))
}

// pushNotifier is what the daemon does when a push lands.
//
// Phase 1 records it and nothing more. Reindexing and advancing the space's

M go.mod => go.mod +1 -1
@@ 22,7 22,7 @@ require (
	go.bigb.es/auxilia v0.7.0
	gopkg.in/yaml.v3 v3.0.1
	sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260718185800-dd418a200152
	sourcecraft.dev/bigbes/sr-ht-ecore v0.0.0-20260808210553-b6bf6d28db15
	sourcecraft.dev/bigbes/sr-ht-ecore v0.0.0-20260815100501-2e37ec734334
)

require (

M go.sum => go.sum +2 -2
@@ 421,5 421,5 @@ modernc.org/sqlite v1.38.2 h1:Aclu7+tgjgcQVShZqim41Bbw9Cho0y/7WzYptXqkEek=
modernc.org/sqlite v1.38.2/go.mod h1:cPTJYSlgg3Sfg046yBShXENNtPrWrDX8bsbAQBzgQ5E=
sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260718185800-dd418a200152 h1:9kQC+tDO2CO8avlKadb9Z0if4a6vJuEK80+4zcb6/fU=
sourcecraft.dev/bigbes/sr-ht-core v0.0.0-20260718185800-dd418a200152/go.mod h1:Mu1Vx39ws/OTKWGoVERXvkdRSPLBdhuFTYv0ftVV31c=
sourcecraft.dev/bigbes/sr-ht-ecore v0.0.0-20260808210553-b6bf6d28db15 h1:zVtZHaKHtdD5Q2Stw8OwuMENl6xvzp32IoHikrB0QWM=
sourcecraft.dev/bigbes/sr-ht-ecore v0.0.0-20260808210553-b6bf6d28db15/go.mod h1:JjrjFxb+PSG0AT9waiK7teFbpZmBPisX3MuqI7Z+ZO8=
sourcecraft.dev/bigbes/sr-ht-ecore v0.0.0-20260815100501-2e37ec734334 h1:xpY9JZpz+T924BI2VRiZcY8mFnZ7LtyrIAI8hjLsBzc=
sourcecraft.dev/bigbes/sr-ht-ecore v0.0.0-20260815100501-2e37ec734334/go.mod h1:JjrjFxb+PSG0AT9waiK7teFbpZmBPisX3MuqI7Z+ZO8=

A graph/credential_test.go => graph/credential_test.go +219 -0
@@ 0,0 1,219 @@
package graph

import (
	"bytes"
	"encoding/json"
	"net/http"
	"net/http/httptest"
	"testing"
	"time"

	"github.com/stretchr/testify/assert"
	"github.com/stretchr/testify/require"

	"sourcecraft.dev/bigbes/sr-ht-core/auth"
	"sourcecraft.dev/bigbes/sr-ht-ecore/bearer"

	"sourcecraft.dev/bigbes/sr-ht-spec/authn"
)

// The credential plane of /query, end to end through the real handler chain.
//
// grant_test.go exercises the gate as a unit, over a principal somebody handed
// it. These go in at the door instead — an HTTP request carrying a credential —
// because what changed in the conversion is which credentials reach the gate at
// all, and a test written against a Principal cannot see that.

// probeQuery is a cheap read: it needs no fixture beyond the fake reader and it
// is refused before parsing when the caller has no authority, so the status is
// the whole answer.
const probeQuery = `{ spaces { ref } }`

// request builds the POST the harness would send, and hands back the recorder
// as well, for the assertions that are about a header rather than a body.
func request(t *testing.T, q string, credential func(*http.Request)) (*http.Request, *httptest.ResponseRecorder) {
	t.Helper()
	body, err := json.Marshal(map[string]any{"query": q})
	require.NoError(t, err)
	req := httptest.NewRequest(http.MethodPost, "/query", bytes.NewReader(body))
	req.Header.Set("Content-Type", "application/json")
	if credential != nil {
		credential(req)
	}
	return req, httptest.NewRecorder()
}

// metaPAT mints a bearer token sealed with the instance's key but stamped with
// an OAuth client id rather than tokens.sr.ht's. That is exactly what a
// meta.sr.ht personal access token is on this instance — same format, same key,
// different issuer — and the client id is the only thing that tells the two
// apart (see sr-ht-ecore/bearer, step 2).
func metaPAT() string {
	bt := &auth.BearerToken{
		Version:  auth.TokenVersion,
		Expires:  auth.ToTimestamp(time.Now().Add(time.Hour)),
		Grants:   "",
		ClientID: "00000000-0000-0000-0000-00000000beef",
		Username: "bigbes",
	}
	return bt.Encode()
}

// foreignToken mints a working token belonging to somebody who is not the
// instance owner.
func foreignToken(grantString string) string {
	bt := &auth.BearerToken{
		Version:  auth.TokenVersion,
		Expires:  auth.ToTimestamp(time.Now().Add(time.Hour)),
		Grants:   grantString,
		ClientID: bearer.TokensClientID,
		Username: "somebody-else",
	}
	return bt.Encode()
}

// A tokens.sr.ht working token carrying spec:read reads. This is the credential
// the whole conversion is for: the same one /mcp and the REST write plane take,
// so a token that works against one surface of this service works against all of
// them.
func TestWorkingTokenWithTheReadGrantReads(t *testing.T) {
	h := newHarness(t, false)

	r := post(t, h, probeQuery, func(req *http.Request) {
		req.Header.Set("Authorization", "Bearer "+agentToken(authn.ActionRead))
	})

	require.Equal(t, http.StatusOK, r.status, "body %s", r.body)
	assert.Empty(t, r.errText())
	assert.Contains(t, string(r.Data), "~bigbes/rfcs")
}

// The universal grant covers spec:read like any other action, so a token minted
// with "*" reads too. It is asserted separately because "*" is not a member of
// the set and a grant check written as a set lookup would refuse it.
func TestUniversalGrantReads(t *testing.T) {
	h := newHarness(t, false)

	r := post(t, h, probeQuery, func(req *http.Request) {
		req.Header.Set("Authorization", "Bearer "+agentToken("*"))
	})

	require.Equal(t, http.StatusOK, r.status, "body %s", r.body)
	assert.Empty(t, r.errText())
}

// A working token that verifies but was not minted for reading is 403 and not
// 401: the credential is good and the holder is known, so retrying with it is
// pointless and what they need is a wider grant. The refusal names the grant,
// because a client that is not told which one it lacks cannot ask for it.
func TestWorkingTokenWithoutTheReadGrantIsRefused(t *testing.T) {
	h := newHarness(t, false)

	r := post(t, h, probeQuery, func(req *http.Request) {
		req.Header.Set("Authorization", "Bearer "+agentToken(authn.ActionPropose))
	})

	require.Equal(t, http.StatusForbidden, r.status, "body %s", r.body)
	assert.Contains(t, r.body, authn.ActionRead)
	assert.NotContains(t, r.body, "~bigbes/rfcs", "the refusal leaked content")
}

// The unified-login cookie is not a credential on this endpoint, and the owner's
// own cookie is refused along with everybody else's. That is the half of the
// conversion a status code alone would not prove, so it is asserted twice: on
// the bare handler, and mounted under the very middleware that would resolve the
// cookie into the owner principal. The second case is the one that matters —
// resolveCaller overwrites the principal rather than inheriting it, so no
// arrangement of middleware above the mount point can promote a browser session
// into read authority here.
func TestCookieIsNotACredentialHere(t *testing.T) {
	t.Run("bare handler", func(t *testing.T) {
		h := newHarness(t, false)
		r := post(t, h, probeQuery, func(req *http.Request) { login(req, "bigbes") })

		assert.Equal(t, http.StatusUnauthorized, r.status, "body %s", r.body)
		assert.NotContains(t, r.body, "~bigbes/rfcs", "the refusal leaked content")
	})

	t.Run("under a router that does resolve the cookie", func(t *testing.T) {
		resolver := testResolver(t)
		srv, err := New(Options{Reader: newFakeReader(), Searcher: &fakeSearcher{}, Resolver: resolver})
		require.NoError(t, err)

		// The cookie plane, installed above the endpoint. It resolves the
		// owner's cookie to authn.KindOwner, which CanRead admits.
		h := harness{handler: resolver.Middleware()(srv)}
		r := post(t, h, probeQuery, func(req *http.Request) { login(req, "bigbes") })

		assert.Equal(t, http.StatusUnauthorized, r.status, "body %s", r.body)
		assert.NotContains(t, r.body, "~bigbes/rfcs", "the refusal leaked content")
	})
}

// A meta.sr.ht personal access token is refused, and this is a deliberate
// difference from dolt.sr.ht rather than an oversight.
//
// spec.sr.ht authenticates through one issuer (authn's package comment) and
// publishes no OAuth scope for meta to grant against — cmd/specsrht serves
// api-meta.json with an empty scope list — so there is nothing a PAT could be
// scoped *for* here. bearer classifies it ErrNotOurs and authn calls that
// permanent, hence 401 with the challenge rather than a 503.
//
// It is the credential this endpoint used to take, when core-go's
// auth.Middleware stood in front of it, and the test exists to pin the change
// rather than to celebrate it: reopening that plane means giving spec.sr.ht a
// meta scope first.
func TestMetaPersonalAccessTokenIsRefused(t *testing.T) {
	h := newHarness(t, false)

	r := post(t, h, probeQuery, func(req *http.Request) {
		req.Header.Set("Authorization", "Bearer "+metaPAT())
	})

	assert.Equal(t, http.StatusUnauthorized, r.status, "body %s", r.body)
	assert.NotContains(t, r.body, "~bigbes/rfcs", "the refusal leaked content")
}

// A working token belonging to another meta.sr.ht account is 403, not 401 and
// not admitted as a second identity: the token verifies and the holder is who
// they say they are, there is simply nothing on this single-owner instance to
// grant them. This is the ownerOnly rule the conversion had to keep, moved from
// a comparison against auth.AuthContext.Username to authn's own.
func TestWorkingTokenOfAnotherOwnerIsRefused(t *testing.T) {
	h := newHarness(t, false)

	r := post(t, h, probeQuery, func(req *http.Request) {
		req.Header.Set("Authorization", "Bearer "+foreignToken(authn.ActionRead))
	})

	assert.Equal(t, http.StatusForbidden, r.status, "body %s", r.body)
	assert.NotContains(t, r.body, "~bigbes/rfcs", "the refusal leaked content")
}

// Every 401 carries the challenge, naming the scheme and this service's config
// section as the realm. RFC 9110 requires it, and the caller here is always a
// machine holding a bearer token: without it nothing tells the client which
// credential was refused.
func TestEveryUnauthorizedCarriesTheBearerChallenge(t *testing.T) {
	h := newHarness(t, false)

	cases := map[string]func(*http.Request){
		"no credential":  nil,
		"owner's cookie": func(req *http.Request) { login(req, "bigbes") },
		"a forged token": func(req *http.Request) {
			req.Header.Set("Authorization", "Bearer not-a-real-token")
		},
		"a meta PAT": func(req *http.Request) {
			req.Header.Set("Authorization", "Bearer "+metaPAT())
		},
	}
	for name, credential := range cases {
		t.Run(name, func(t *testing.T) {
			req, rec := request(t, probeQuery, credential)
			h.handler.ServeHTTP(rec, req)

			require.Equal(t, http.StatusUnauthorized, rec.Code, "body %s", rec.Body.String())
			assert.Equal(t, authn.Challenge(), rec.Header().Get("WWW-Authenticate"))
		})
	}
}

M graph/graph_test.go => graph/graph_test.go +12 -4
@@ 340,7 340,7 @@ func newHarness(t *testing.T, withProposals bool) harness {
	if err != nil {
		t.Fatalf("New: %v", err)
	}
	return harness{handler: srv.Handler(), searcher: searcher, proposals: proposals}
	return harness{handler: srv, searcher: searcher, proposals: proposals}
}

// response is one GraphQL response, decoded far enough to assert on.


@@ 364,10 364,18 @@ func (r response) errText() string {
	return strings.Join(msgs, "; ")
}

// query POSTs a GraphQL query as the instance owner.
// query POSTs a GraphQL query with the credential this endpoint accepts: a
// tokens.sr.ht working token belonging to the instance owner and carrying
// spec:read. It used to seal the owner's unified-login cookie, which /query no
// longer looks at — see TestCookieIsNotACredentialHere.
func query(t *testing.T, h harness, q string) response {
	t.Helper()
	return post(t, h, q, func(req *http.Request) { login(req, "bigbes") })
	return post(t, h, q, readToken)
}

// readToken presents a working token carrying authn.ActionRead.
func readToken(req *http.Request) {
	req.Header.Set("Authorization", "Bearer "+agentToken(authn.ActionRead))
}

func post(t *testing.T, h harness, q string, auth func(*http.Request)) response {


@@ 1071,7 1079,7 @@ func TestMountedOnAChiRouter(t *testing.T) {
		t.Fatalf("New: %v", err)
	}
	router := chi.NewRouter()
	router.Handle("/query", srv.Handler())
	router.Handle("/query", srv)

	h := harness{handler: router}
	var got struct{ Space struct{ Ref string } }

M graph/resolver.go => graph/resolver.go +12 -2
@@ 4,6 4,8 @@ import (
	"context"
	"time"

	"github.com/99designs/gqlgen/graphql"

	"sourcecraft.dev/bigbes/sr-ht-spec/core"
	"sourcecraft.dev/bigbes/sr-ht-spec/doc"
	"sourcecraft.dev/bigbes/sr-ht-spec/search"


@@ 97,10 99,18 @@ var (
	_ Searcher = (*search.Index)(nil)
)

// Resolver is the root resolver. It holds only the seams above; every field of
// the schema is answered by calling through them.
// Resolver is the root resolver. It holds the seams above — every read field of
// the schema is answered by calling through them — and the schema it is itself
// part of.
type Resolver struct {
	reader    Reader
	searcher  Searcher
	proposals Proposals

	// schema is this service's executable schema, set by newSchema once the
	// resolver it is built from exists. The webhook management resolvers need
	// it: createUserWebhook validates a subscriber's stored query against it.
	// It used to be read off core-go's server context, which is installed on
	// the authenticated router and which /query no longer runs behind.
	schema graphql.ExecutableSchema
}

M graph/schema.resolvers.go => graph/schema.resolvers.go +45 -57
@@ 11,16 11,13 @@ import (
	"fmt"
	"net/url"
	"strings"
	"time"

	sq "github.com/Masterminds/squirrel"
	"github.com/google/uuid"
	"github.com/lib/pq"
	"sourcecraft.dev/bigbes/sr-ht-core/auth"
	"sourcecraft.dev/bigbes/sr-ht-core/database"
	coreerrors "sourcecraft.dev/bigbes/sr-ht-core/errors"
	model1 "sourcecraft.dev/bigbes/sr-ht-core/model"
	"sourcecraft.dev/bigbes/sr-ht-core/server"
	corewebhooks "sourcecraft.dev/bigbes/sr-ht-core/webhooks"
	"sourcecraft.dev/bigbes/sr-ht-spec/core"
	"sourcecraft.dev/bigbes/sr-ht-spec/doc"


@@ 40,8 37,10 @@ func (r *mutationResolver) CreateUserWebhook(ctx context.Context, config model.U
		return nil, err
	}

	schema := server.ForContext(ctx).Schema
	if err := corewebhooks.Validate(schema, config.Query); err != nil {
	// The schema the root resolver holds, and not core-go's server context:
	// /query is served on the anonymous router, where that context does not
	// exist. It is the same schema either way — the daemon builds exactly one.
	if err := corewebhooks.Validate(r.schema, config.Query); err != nil {
		return nil, err
	}



@@ 459,61 458,37 @@ func (r *userWebhookSubscriptionResolver) Deliveries(ctx context.Context, obj *m
}

// Sample is the resolver for the sample field.
//
// It is the one field of this schema that /query cannot answer, and it says so
// rather than failing at a lower level with something a caller cannot read.
//
// The reason is core-go's, not this service's. corewebhooks.WebhookContext.Exec
// — which is what renders a sample, by running the subscriber's own stored query
// against a synthetic payload — reads the complexity limit off core-go's server
// context and panics when there is none. That context is installed by
// server.WithDefaultMiddleware on the *authenticated* router, and this endpoint
// is on the anonymous one because it authenticates with tokens.sr.ht working
// tokens rather than with meta's OAuth vocabulary. There is no exported way to
// put that value on a context; the alternatives are to fork Exec into this
// service or to let the panic surface as "internal system error", and a named
// refusal beats both.
//
// Delivery is unaffected: the webhook queue's own context comes from
// server.WithQueues, which does carry it, so a real delivery renders exactly as
// before.
func (r *userWebhookSubscriptionResolver) Sample(ctx context.Context, obj *model.UserWebhookSubscription, event model.WebhookEvent) (string, error) {
	payloadUUID := uuid.New()
	webhook := corewebhooks.WebhookContext{
		User:        auth.ForContext(ctx),
		PayloadUUID: payloadUUID,
		Name:        "user",
		Event:       event.String(),
		Subscription: &corewebhooks.WebhookSubscription{
			ID:         obj.ID,
			URL:        obj.URL,
			Query:      obj.Query,
			AuthMethod: obj.AuthMethod,
			TokenHash:  obj.TokenHash,
			Grants:     obj.Grants,
			ClientID:   obj.ClientID,
			Expires:    obj.Expires,
			NodeID:     obj.NodeID,
		},
	}

	switch event {
	case model.WebhookEventProposalOpened,
		model.WebhookEventProposalMerged,
		model.WebhookEventProposalRejected:
		// A synthetic proposal so the sample renders without a DB round-trip. The
		// payload carries a fully-populated *model.Proposal, so ProposalEvent's
		// generated field resolvers read it straight off the struct.
		now := time.Now().UTC()
		webhook.Payload = &model.ProposalEvent{
			UUID:  payloadUUID.String(),
			Event: event,
			Date:  now,
			Proposal: &model.Proposal{
				ID:           -1,
				Space:        "~owner/example",
				Title:        "Example proposal",
				Rationale:    "A sample proposal for webhook testing.",
				BaseRev:      "0000000000000000000000000000000000000000",
				Branch:       "proposals/0",
				State:        model.ProposalStateOpen,
				Agent:        "example-agent/sample",
				AgentSession: "00000000-0000-0000-0000-000000000000",
				Created:      now,
			},
		}
	default:
		return "", fmt.Errorf("unsupported event %s", event.String())
	}

	subctx := corewebhooks.Context(ctx, webhook.Payload)
	bytes, err := webhook.Exec(subctx, server.ForContext(ctx).Schema)
	if err != nil {
		return "", err
	}
	return string(bytes), nil
	return "", fmt.Errorf(
		"sample is not available on this endpoint: rendering one needs core-go's server " +
			"context, which exists only on the authenticated router, and spec.sr.ht serves " +
			"/query on the anonymous router so that a tokens.sr.ht working token is the " +
			"credential; subscribe and trigger the event to see a real delivery")
}

// Subscription is the resolver for the subscription field.


@@ 763,11 738,24 @@ func projectRef(owner, name string) (core.ProjectRef, error) {
	return ref, nil
}

// webhookAuthorized permits only the instance owner to manage webhooks. The
// /query owner-only middleware maps the owner (and only the owner) to
// AUTH_INTERNAL; core-go's auth would otherwise admit any authenticated meta
// user (it JIT-creates a row on a table miss), and spec.sr.ht is single-owner.
// A non-INTERNAL method here is a non-owner and is refused.
// webhookAuthorized permits only the instance owner, and the agents acting for
// it, to manage webhooks.
//
// The AuthContext it reads is coreauth's: the graph server derives one from the
// principal /query's credential gate already admitted, and coreauth maps the
// owner and its agents — and nobody else — to AUTH_INTERNAL. So a non-INTERNAL
// method here is a caller with no authority over this instance's webhooks and is
// refused.
//
// What it does NOT check is a grant, and that is a gap rather than a decision:
// the endpoint's gate admits authn.ActionRead, so a working token carrying only
// spec:read reaches these mutations and may manage subscriptions with them. The
// identity requirement is unchanged — the token has to belong to [sr.ht]
// owner-name or authn refuses it at the door — but the grant vocabulary has no
// entry that means "manage this service's webhooks", and inventing one here
// would be a string tokens.sr.ht has never minted and no existing token carries.
// Closing it is a vocabulary change, declared in authn beside ActionRead and
// ActionPropose, and it is deliberately not done in passing.
func webhookAuthorized(ctx context.Context) error {
	if auth.ForContext(ctx).AuthMethod != auth.AUTH_INTERNAL {
		return coreerrors.ErrAccessDenied

M graph/server.go => graph/server.go +177 -49
@@ 2,14 2,16 @@
//
// # Read only, deliberately
//
// There are no mutations here. The design defers them until the proposal state
// machine has settled, and the reason is technical rather than scope
// discipline: the write plane's concurrency story is `If-Match: <base-rev>`, an
// HTTP idiom with well-defined 409 semantics that agents get right by default,
// and a type that has been federated into api.sr.ht is a consumed contract —
// expensive to churn. Read types (space, document, project, search) are stable
// from the start; the review types are not, and that is where the line is
// drawn.
// There are no proposal mutations here. The design defers them until the
// proposal state machine has settled, and the reason is technical rather than
// scope discipline: the write plane's concurrency story is `If-Match:
// <base-rev>`, an HTTP idiom with well-defined 409 semantics that agents get
// right by default, and a type that has been federated into api.sr.ht is a
// consumed contract — expensive to churn. Read types (space, document, project,
// search) are stable from the start; the review types are not, and that is
// where the line is drawn. The webhook management mutations are the exception
// and are not a proposal write: core-go's webhook engine is GraphQL-native and
// has no other surface.
//
// Serving GraphQL at all is justified without federation: Phase 5's webhooks
// are GraphQL-native so gqlgen arrives regardless, and this is the read surface


@@ 18,15 20,32 @@
// nothing here depends on — `hut` builds its endpoint from the per-service
// origin and talks to this /query directly either way.
//
// # Who may read
// # Who may read, and with what
//
// The read plane is fail-closed and this is the same one-line ACL web/ applies:
// the instance owner and its agents may read, and nobody else may. A viewer
// with no read authority gets 401 before the query is parsed — including for
// introspection, which is why a gateway federating this schema has to present a
// token like any other client.
// The read plane is fail-closed and this is the same one-line ACL web/ and
// mcpsrv/ apply: the instance owner's agents may read, and nobody else may. A
// viewer with no read authority gets 401 before the query is parsed — including
// for introspection, which is why a gateway federating this schema has to
// present a token like any other client.
//
// # What the cmd layer must wire
// The credential is the bearer plane /mcp and the REST write plane already
// define, and nothing else:
//
//   - A tokens.sr.ht working token, verified through sr-ht-ecore's bearer
//     package by authn.Resolver, owned by [sr.ht] owner-name, and carrying
//     authn.ActionRead. A token that verifies but does not carry that grant is
//     403; one that does not verify is 401 with the bearer challenge.
//   - No cookie. An API client is not a browser. The unified-login cookie is
//     web/'s plane, and this endpoint is deliberately outside it — so the
//     principal is overwritten with the anonymous one when no bearer credential
//     is presented, rather than inherited from whatever middleware happens to
//     sit above the mount point.
//   - No meta.sr.ht personal access token. spec.sr.ht authenticates through one
//     issuer (see authn's package comment) and publishes no OAuth scope for meta
//     to grant against, so there is nothing a PAT could be scoped for here. A
//     PAT is bearer.ErrNotOurs and is refused at the door with 401.
//
// # What the cmd layer wires
//
//	gql, err := graph.New(graph.Options{
//		Reader:   svc,             // *service.Service


@@ 36,23 55,36 @@
//	if err != nil {
//		return err
//	}
//	router.Handle("/query", gql.Handler())
//	router.Handle("/query", gql)
//
// Server installs its own credential middleware, so it goes on the *anonymous*
// router: core-go's server.WithSchema would mount it on the authenticated one,
// whose auth.Middleware answers meta's OAuth vocabulary and 401s anything else
// — the vocabulary this service deliberately does not speak. A service that
// mounts its own /query owes the instance api-meta.json as well, because
// core-go serves that file only for the schemas it hosts itself; sr-ht-ecore's
// apimeta package is what serves it, and cmd/specsrht wires it beside the
// route.
//
// [Server.Handler] installs authn's principal middleware itself, so it can be
// mounted on a router that has none. A caller whose router already resolves a
// principal uses [Server.Endpoint] instead.
// The router it is mounted on must carry core-go's config and database
// middleware. The read path does not need either, but the webhook management
// resolvers open transactions through core-go's database context, and
// WithDefaultMiddleware installs that on the authenticated router only.
package graph

import (
	"fmt"
	"log/slog"
	"net/http"

	"github.com/99designs/gqlgen/graphql"
	"github.com/99designs/gqlgen/graphql/handler"
	"github.com/99designs/gqlgen/graphql/handler/extension"
	"github.com/99designs/gqlgen/graphql/handler/transport"
	"go.bigb.es/auxilia/scribe"

	"sourcecraft.dev/bigbes/sr-ht-spec/authn"
	"sourcecraft.dev/bigbes/sr-ht-spec/coreauth"
	"sourcecraft.dev/bigbes/sr-ht-spec/graph/api"
)



@@ 71,23 103,26 @@ type Options struct {
	// error saying so rather than answering "none".
	Proposals Proposals

	// Resolver turns the unified-login cookie or an agent bearer token into a
	// principal. Handler installs its middleware; Endpoint does not.
	// Resolver verifies the bearer credential a caller presents. Its cookie
	// plane is not used here: see the package comment.
	Resolver *authn.Resolver
}

// Server is the /query endpoint: the executable schema plus the read gate. It
// is built once at startup and is safe for concurrent use.
// Server is the /query endpoint: the executable schema behind the credential
// gate. It is built once at startup and is safe for concurrent use.
type Server struct {
	exec     http.Handler
	resolver *authn.Resolver
	http   http.Handler
	schema graphql.ExecutableSchema
}

// NewSchema builds the executable schema over the seams in opts. The daemon
// hands it to core-go's server.WithSchema (to serve /query) and to
// webhooks.NewQueue (which executes a subscription's stored query against it at
// delivery time), so both share exactly one schema.
func NewSchema(opts Options) (graphql.ExecutableSchema, error) {
// newSchema builds the executable schema over the seams in opts.
//
// It is unexported now that nothing outside this package wants a schema without
// an endpoint. The daemon needs both — the endpoint to serve /query, and the
// schema to hand to webhooks.NewQueue, which executes a subscription's stored
// query at delivery time — and takes them from one Server, so that the two
// cannot become two schemas.
func newSchema(opts Options) (graphql.ExecutableSchema, error) {
	if opts.Reader == nil {
		return nil, fmt.Errorf("graph: Reader is required")
	}


@@ 99,15 134,23 @@ func NewSchema(opts Options) (graphql.ExecutableSchema, error) {
		searcher:  opts.Searcher,
		proposals: opts.Proposals,
	}
	return api.NewExecutableSchema(api.Config{Resolvers: root}), nil
	schema := api.NewExecutableSchema(api.Config{Resolvers: root})
	// The root resolver holds the schema it is part of. The knot is deliberate:
	// the webhook resolvers validate and execute a subscriber's stored query
	// against this service's schema, and used to reach it through core-go's
	// server context — which exists on the authenticated router and nowhere
	// else. Holding it here is what lets /query move to the anonymous router
	// without those resolvers reaching for a context value that is not there.
	root.schema = schema
	return schema, nil
}

// New assembles the executable schema over the seams in opts.
// New assembles the /query endpoint over the seams in opts.
func New(opts Options) (*Server, error) {
	if opts.Resolver == nil {
		return nil, fmt.Errorf("graph: authn Resolver is required")
	}
	schema, err := NewSchema(opts)
	schema, err := newSchema(opts)
	if err != nil {
		return nil, err
	}


@@ 123,37 166,107 @@ func New(opts Options) (*Server, error) {
	exec.AddTransport(transport.POST{})
	exec.Use(extension.Introspection{})

	return &Server{exec: exec, resolver: opts.Resolver}, nil
	return &Server{
		schema: schema,
		http:   resolveCaller(opts.Resolver, gate(coreContext(exec))),
	}, nil
}

// Handler is the /query handler with authn's principal middleware installed, so
// it can be mounted on a router that has none:
// Schema is the executable schema this endpoint serves. The daemon hands it to
// webhooks.NewQueue so that the query a subscriber stored is executed against
// exactly the schema they wrote it for.
func (s *Server) Schema() graphql.ExecutableSchema { return s.schema }

// ServeHTTP serves /query behind the chain New built.
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { s.http.ServeHTTP(w, r) }

// resolveCaller turns the presented bearer credential into this request's
// principal, or refuses the request. It is the whole credential plane of this
// endpoint, and it is authn.Resolver's bearer arm and not its Middleware:
// Middleware also reads the unified-login cookie, and a cookie is not a
// credential here.
//
//	router.Handle("/query", gql.Handler())
// A request with no Authorization header is given the anonymous principal
// explicitly rather than being passed through untouched. That overwrite is the
// "no cookie" rule made structural: mounted under a router that already
// resolved a cookie identity, this endpoint still sees anonymous and still
// answers 401.
//
// Installing that middleware twice is harmless — it is idempotent — so a router
// that already applies it may use this too.
func (s *Server) Handler() http.Handler {
	return s.resolver.Middleware()(s.Endpoint())
// The statuses are authn.StatusFor's, which is the one table this service maps
// a credential failure with: 401 for a credential that does not verify — forged,
// expired, revoked, or issued by somebody else — 403 for one that verifies and
// belongs to a human this single-owner instance has nothing to grant, and 503
// for a credential that could not be *checked*. The last is not "your token is
// bad": answering 401 to a restart of tokens.sr.ht tells every agent to re-mint
// credentials that were never broken.
//
// The messages are written here from what the caller already knows, never from
// the error's own text: authn's errors name usernames and token ids.
func resolveCaller(rs *authn.Resolver, next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		presented := authn.BearerFromRequest(r)
		if presented == "" {
			next.ServeHTTP(w, r.WithContext(
				authn.WithPrincipal(r.Context(), authn.Anonymous())))
			return
		}

		p, err := rs.ResolveAgent(r.Context(), presented,
			r.Header.Get(authn.HeaderAgent), r.Header.Get(authn.HeaderAgentSession))
		if err != nil {
			status := authn.StatusFor(err)
			if status >= http.StatusInternalServerError {
				// Fail closed and loudly. The alternative — degrading to
				// anonymous — would turn an unreachable tokens.sr.ht into every
				// agent silently losing its read access.
				slog.ErrorContext(r.Context(), "a bearer credential could not be checked",
					"component", "graph", "path", r.URL.Path, "status", status, scribe.Err(err))
			}
			if status == http.StatusUnauthorized {
				// RFC 9110 requires the challenge on a 401, and every caller
				// here is a machine holding a bearer token: naming the scheme
				// and the realm is what tells it which credential was refused.
				w.Header().Set("WWW-Authenticate", authn.Challenge())
			}
			http.Error(w, refusalMessage(status), status)
			return
		}
		next.ServeHTTP(w, r.WithContext(authn.WithPrincipal(r.Context(), p)))
	})
}

// Endpoint is the /query handler without any middleware of its own. The router
// it is mounted on must already resolve a principal into the request context
// (authn.Resolver.Middleware), or every caller looks anonymous and is refused.
func (s *Server) Endpoint() http.Handler {
	return gate(s.exec)
// refusalMessage is what a refused caller is told. It is keyed on the status and
// not on the error, so that nothing about whose token it was, or whether a row
// exists, leaks to a caller holding a credential this service did not accept.
func refusalMessage(status int) string {
	switch status {
	case http.StatusUnauthorized:
		return "the bearer token presented was refused"
	case http.StatusForbidden:
		return "this token does not authorize requests to " + authn.ConfigSection
	default:
		return "the credential could not be verified, try again"
	}
}

// gate refuses a caller with no read authority before the query is parsed.
//
// The ACL is authn.Principal.CanRead — the owner and its agents may read and
// nobody else may — the same predicate web/ and mcpsrv/ apply, so the three read
// surfaces cannot drift into three policies, which is how a corpus leaks.
// surfaces cannot drift into three policies, which is how a corpus leaks. On
// this endpoint the owner half of it is unreachable in practice: the owner is
// recognised by a cookie, and resolveCaller above accepts none. It is asked
// anyway because it is the shared predicate and not this endpoint's own.
//
// A caller that clears it and authenticated with a tokens.sr.ht working token
// must also hold spec:read — the grant half of the same question, asked here
// because here is where the action ("read") is known. It is a no-op for the
// owner's cookie and for the local agent token, neither of which carries grants.
// because here is where the action ("read") is known.
//
// It is one check at the boundary rather than one per field, because every read
// field of this schema is a read and the surface has one action. The webhook
// mutations must NOT rely on it: they would be admitted by a read grant, which
// is not what a read grant says, so they carry their own owner gate in the
// resolver.
//
// The refusal is a 401 — or a 403 for the missing grant — with a line of text
// and never a redirect to meta's login: every caller here is a machine, and


@@ 177,3 290,18 @@ func gate(next http.Handler) http.Handler {
		next.ServeHTTP(w, r)
	})
}

// coreContext derives core-go's AuthContext from the principal the gate has
// already admitted, because core-go's webhook engine reads one out of the
// context and this endpoint no longer runs behind the middleware that puts it
// there.
//
// The user id is the credential's own: authn resolved the token's owner to a
// local row and refused the token outright if that row had no id, so there is
// nothing to substitute and no default to invent here.
func coreContext(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		p := authn.PrincipalFromContext(r.Context())
		next.ServeHTTP(w, r.WithContext(coreauth.Context(r.Context(), p, p.UserID)))
	})
}