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{})