~bigbes/sr-ht-spec

021d955ae1dc960cf26517ebd5fe7e5f7c917336 — Eugene Blikh 25 days ago 8374a1e
feat(cmd,graph): wire /query onto core-go's server for webhooks (Phase 5a)

The faithful runtime wiring. /query moves from spec's anon-router handler
onto core-go's authenticated router, so the webhook engine gets the
auth/database/server context it requires.

- cmd: build the executable schema via graph.NewSchema and hand it to
  both webhooks.NewQueue and server.WithSchema (one schema, both). The
  server is coreserver.New().WithDefaultMiddleware() (core-go auth +
  database + server context + the delivery worker via WithQueues). web,
  MCP and REST stay on the anon router with spec's own authn — only
  /query changes. The owner "user" row is seeded at startup so core-go's
  LookupUser stays local.
- ownerOnly middleware on /query: 403s any non-owner (core-go auth admits
  any meta user; spec is single-owner) and remaps the owner to
  AUTH_INTERNAL so the webhook engine's NewAuthConfig/FilterWebhooks
  (which refuse AUTH_COOKIE) accept them.
- graph: NewSchema exposes the raw executable schema; the webhook
  resolver ACL now requires AUTH_INTERNAL (only the owner gets it, via
  ownerOnly) instead of spec's authn, which is no longer in the /query
  chain.

Accepted trade-off: agents lose GraphQL /query reads (they keep MCP +
REST). New deploy requirement: WithDefaultMiddleware needs [mail]
smtp-from (core-go's notification queue).

Verified against a live daemon on Postgres: owner cookie creates and
lists webhooks (row stored INTERNAL/user_id 1); non-owner 403; unauth
401; web UI 200 on the anon router.
4 files changed, 142 insertions(+), 32 deletions(-)

M .up/phase5a-webhooks.md
M cmd/specsrht/main.go
M graph/schema.resolvers.go
M graph/server.go
M .up/phase5a-webhooks.md => .up/phase5a-webhooks.md +27 -1
@@ 47,6 47,14 @@ Reference: /Users/blikh/data/home/tmp/pages.sr.ht (same fork).
  existing read resolvers onto database.Model — only the webhook models need it.
- uplan: webhooks are owner-scoped (`gql_user_wh_sub`), matching pages.sr.ht's
  user-scoped pattern; the single owner is the only user.
- uexecute: FAITHFUL wiring chosen by user — /query moves onto core-go's
  authenticated router (WithDefaultMiddleware → core-go auth + server/database
  contexts) so the core-go webhook engine works unchanged. Accepted trade-off:
  AGENTS lose GraphQL /query reads (they keep MCP + REST; owner keeps /query;
  web/MCP/REST stay on the anon router with spec's authn). A cookie→INTERNAL
  override on /query makes NewAuthConfig/FilterWebhooks accept the owner (they
  refuse AUTH_COOKIE). Owner "user" row seeded at startup so core-go's LookupUser
  never calls out to meta.sr.ht.

### Deferred (needs user input)
- extensions-go / `sr-ht-ext` module — extracting spec's agent model into a


@@ 54,4 62,22 @@ Reference: /Users/blikh/data/home/tmp/pages.sr.ht (same fork).
  "too early to judge"; building the bridge in-spec, factored for later
  extraction. Revisit when a second consumer exists or after this ships.
- gqlgen regeneration runs `go generate ./graph` (pulls gqlgen at generate-time
  and rewrites graph/api/generated.go). Will run during phase 4; flagged.
  and rewrites graph/api/generated.go). DONE in phase 4; reproducible.
- **Phase 3 runtime wiring — STRUCTURAL BLOCKER, needs user decision.** The
  core-go webhook engine's delivery (`WebhookContext.Exec`) requires
  `server.ForContext(ctx)` for Schema+MaxComplexity. `serverCtxKey` is
  UNEXPORTED with no public installer — the server context is installed ONLY by
  `WithDefaultMiddleware` (request path) and `WithQueues` (worker path, which
  needs WithDefaultMiddleware's db). WithDefaultMiddleware also installs core-go
  `auth.Middleware` on /query, which 401s spec's AGENT bearer tokens (core-go's
  bearer path is OAuth2-only). Net: faithful engine ⟹ /query behind core-go
  auth ⟹ agents lose GraphQL /query reads (they keep MCP/REST; owner keeps
  /query; web/MCP/REST unaffected on the anon router). Redis is a non-issue
  (lazy client, unused by outbound delivery). Two paths:
    (a) Adopt WithDefaultMiddleware for /query (faithful; accept the agent
        /query-read regression).
    (b) Spec-local firing: reuse dowork + crypto.SignWebhook + the gqlgen
        executor, skip core-go's server-context-coupled Exec (keeps agent
        /query, no core-go auth on /query; diverges from the "standard" engine).
  Phases 1-4 (DB, bridge, schema/models/resolvers) are committed and green
  regardless of this choice.

M cmd/specsrht/main.go => cmd/specsrht/main.go +76 -16
@@ 59,14 59,17 @@ 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-core/auth"
	"sourcecraft.dev/bigbes/sr-ht-core/config"
	coreserver "sourcecraft.dev/bigbes/sr-ht-core/server"
	"sourcecraft.dev/bigbes/sr-ht-core/webhooks"

	"sourcecraft.dev/bigbes/sr-ht-spec/api"
	"sourcecraft.dev/bigbes/sr-ht-spec/core"


@@ 249,6 252,14 @@ func run(log *slog.Logger) error {
		return err
	}

	// Seed the owner's user row before serving. core-go's auth.Middleware looks
	// a request's username up in the "user" table and, on a miss, calls out to
	// meta.sr.ht — seeding the single owner up front keeps that lookup local, and
	// it gives the webhook engine the user_id it scopes subscriptions by.
	if err := svc.EnsureOwnerUser(context.Background()); err != nil {
		return fmt.Errorf("seed the owner user row: %w", err)
	}

	// Refresh every space's hooks before anything can be pushed to it. This is
	// fatal on failure by design: a space whose hooks are missing accepts
	// pushes that are never validated, which is the one outcome the whole


@@ 286,7 297,21 @@ func run(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.
	srv := coreserver.New(serviceName, defaultBind, conf, os.Args)
	// /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.
	webhookQueue := webhooks.NewQueue(surf.schema, conf)
	srv := coreserver.New(serviceName, defaultBind, conf, os.Args).
		WithDefaultMiddleware().
		WithMiddleware(ownerOnly(cfg.Instance.OwnerName)).
		WithSchema(surf.schema, nil).
		WithQueues(webhookQueue.Queue)
	mountRoutes(srv.AnonRouter(), conf, surf)

	ctx, stop := context.WithCancel(context.Background())


@@ 436,8 461,15 @@ type surfaces struct {
	index *search.Index
	web   *web.Server
	mcp   http.Handler
	gql   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
}

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


@@ 484,7 516,7 @@ func newSurfaces(conf ini.File, cfg service.Config, svc *service.Service, versio
	// refuses — the ACL stays in service/, this only populates the identity.
	mcp = svc.Resolver().Middleware()(mcp)

	gql, err := graph.New(graph.Options{
	schema, err := graph.NewSchema(graph.Options{
		Reader:    svc,
		Searcher:  index,
		Proposals: graph.NewProposals(svc),


@@ 492,7 524,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 surface: %w", err)
		return nil, fmt.Errorf("assemble the GraphQL schema: %w", err)
	}

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


@@ 501,7 533,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, gql: gql.Handler(), api: rest.Handler()}, nil
	return &surfaces{index: index, web: site, mcp: mcp, api: rest.Handler(), schema: schema}, nil
}

func (s *surfaces) Close() error {


@@ 511,24 543,52 @@ func (s *surfaces) Close() error {
	return s.index.Close()
}

// mountWeb attaches the HTTP surfaces: the three read surfaces (web, MCP,
// GraphQL) and the REST write plane.
// 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, /query and /api are registered before the web
// UI, which mounts at "/" and would otherwise swallow them as document paths —
// "spec", "query" and "api" are all legal space names as far as the router is
// concerned.
// 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.
//
// Each surface installs its own authentication (they are fail-closed and agree
// on one ACL), so core-go's WithDefaultMiddleware is deliberately not used: its
// auth middleware 401s any un-cookied request, which would also block the agent
// bearer-token path these surfaces exist to serve.
// 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) {
	if s == nil {
		return
	}
	router.Handle("/mcp", s.mcp)
	router.Handle("/query", s.gql)
	router.Mount("/api", s.api)
	router.Mount("/", s.web.Handler())
}

M graph/schema.resolvers.go => graph/schema.resolvers.go +20 -9
@@ 22,7 22,6 @@ import (
	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/authn"
	"sourcecraft.dev/bigbes/sr-ht-spec/core"
	"sourcecraft.dev/bigbes/sr-ht-spec/doc"
	"sourcecraft.dev/bigbes/sr-ht-spec/graph/api"


@@ 37,8 36,8 @@ func (r *mutationResolver) CreateUserWebhook(ctx context.Context, config model.U
	// clients and no scopes, so there is no per-event grant to check the way
	// pages.sr.ht does — either you are the owner and may manage every webhook,
	// or you are refused here.
	if !authn.PrincipalFromContext(ctx).IsOwner() {
		return nil, coreerrors.ErrAccessDenied
	if err := webhookAuthorized(ctx); err != nil {
		return nil, err
	}

	schema := server.ForContext(ctx).Schema


@@ 104,8 103,8 @@ func (r *mutationResolver) CreateUserWebhook(ctx context.Context, config model.U

// DeleteUserWebhook is the resolver for the deleteUserWebhook field.
func (r *mutationResolver) DeleteUserWebhook(ctx context.Context, id int) (model.WebhookSubscription, error) {
	if !authn.PrincipalFromContext(ctx).IsOwner() {
		return nil, coreerrors.ErrAccessDenied
	if err := webhookAuthorized(ctx); err != nil {
		return nil, err
	}

	filter, err := corewebhooks.FilterWebhooks(ctx)


@@ 339,8 338,8 @@ func (r *queryResolver) Proposals(ctx context.Context, space string, state model

// UserWebhooks is the resolver for the userWebhooks field.
func (r *queryResolver) UserWebhooks(ctx context.Context, cursor *model1.Cursor) (*model.WebhookSubscriptionCursor, error) {
	if !authn.PrincipalFromContext(ctx).IsOwner() {
		return nil, coreerrors.ErrAccessDenied
	if err := webhookAuthorized(ctx); err != nil {
		return nil, err
	}
	if cursor == nil {
		cursor = model1.NewCursor(nil)


@@ 372,8 371,8 @@ func (r *queryResolver) UserWebhooks(ctx context.Context, cursor *model1.Cursor)

// UserWebhook is the resolver for the userWebhook field.
func (r *queryResolver) UserWebhook(ctx context.Context, id int) (model.WebhookSubscription, error) {
	if !authn.PrincipalFromContext(ctx).IsOwner() {
		return nil, coreerrors.ErrAccessDenied
	if err := webhookAuthorized(ctx); err != nil {
		return nil, err
	}

	filter, err := corewebhooks.FilterWebhooks(ctx)


@@ 763,6 762,18 @@ 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.
func webhookAuthorized(ctx context.Context) error {
	if auth.ForContext(ctx).AuthMethod != auth.AUTH_INTERNAL {
		return coreerrors.ErrAccessDenied
	}
	return nil
}
func orNull(err error) error {
	if errors.Is(err, service.ErrNotFound) {
		return nil

M graph/server.go => graph/server.go +19 -6
@@ 47,6 47,7 @@ import (
	"fmt"
	"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"


@@ 82,22 83,34 @@ type Server struct {
	resolver *authn.Resolver
}

// New assembles the executable schema over the seams in opts.
func New(opts Options) (*Server, error) {
// 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) {
	if opts.Reader == nil {
		return nil, fmt.Errorf("graph: Reader is required")
	}
	if opts.Searcher == nil {
		return nil, fmt.Errorf("graph: Searcher is required")
	}
	if opts.Resolver == nil {
		return nil, fmt.Errorf("graph: authn Resolver is required")
	}
	root := &Resolver{
		reader:    opts.Reader,
		searcher:  opts.Searcher,
		proposals: opts.Proposals,
	}
	return api.NewExecutableSchema(api.Config{Resolvers: root}), nil
}

// New assembles the executable schema 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)
	if err != nil {
		return nil, err
	}

	// The transport set is core-go's, not gqlgen's NewDefaultServer: POST and
	// introspection, and nothing else. NewDefaultServer would also install GET,


@@ 106,7 119,7 @@ func New(opts Options) (*Server, error) {
	// transport for a schema with no subscriptions. Every SourceHut service on
	// this instance answers /query over POST, so a client that works against
	// one works against this.
	exec := handler.New(api.NewExecutableSchema(api.Config{Resolvers: root}))
	exec := handler.New(schema)
	exec.AddTransport(transport.POST{})
	exec.Use(extension.Introspection{})