From 021d955ae1dc960cf26517ebd5fe7e5f7c917336 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Fri, 24 Jul 2026 12:37:28 +0300 Subject: [PATCH] feat(cmd,graph): wire /query onto core-go's server for webhooks (Phase 5a) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .up/phase5a-webhooks.md | 28 +++++++++++- cmd/specsrht/main.go | 92 ++++++++++++++++++++++++++++++++------- graph/schema.resolvers.go | 29 ++++++++---- graph/server.go | 25 ++++++++--- 4 files changed, 142 insertions(+), 32 deletions(-) diff --git a/.up/phase5a-webhooks.md b/.up/phase5a-webhooks.md index c1a24cfcd19ad9673775eea392ba150daeb43f6a..1b4ee49af5031671bda2205cc747f4b6706b41ab 100644 --- a/.up/phase5a-webhooks.md +++ b/.up/phase5a-webhooks.md @@ -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. diff --git a/cmd/specsrht/main.go b/cmd/specsrht/main.go index 301903495473f58cf62fc2481872d73feccdd522..db67e09098234260db9df14a12e9c52bb8810114 100644 --- a/cmd/specsrht/main.go +++ b/cmd/specsrht/main.go @@ -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()) } diff --git a/graph/schema.resolvers.go b/graph/schema.resolvers.go index 5a29918723659813c375a6ad1e8afe1c46c517ed..f391d51e147f4f4d2cccccbfd4c155062682fc47 100644 --- a/graph/schema.resolvers.go +++ b/graph/schema.resolvers.go @@ -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 diff --git a/graph/server.go b/graph/server.go index 529dbc81b708b449a8ba0e45374074366a13ac68..2d0c3d7e4f95846e231551f2559ff323083e38ad 100644 --- a/graph/server.go +++ b/graph/server.go @@ -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{})