~bigbes/sr-ht-spec

ref: 3cb1c03d8078d5748cc13a2e9bd7ba7d078e1b37 sr-ht-spec/graph/schema.resolvers.go -rw-r--r-- 28.3 KiB
01999c70 — Eugene Blikh 2 days ago
graph: keep webhook management with the owner, not with any agent

Moving /query to the anonymous router admitted tokens.sr.ht working tokens, and
webhookAuthorized asked only whether the caller was AUTH_INTERNAL. coreauth maps
the owner and its agents alike to INTERNAL — deliberately, because INTERNAL is
what core-go's NewAuthConfig and FilterWebhooks demand of anyone at all — so a
token carrying nothing but spec:read could create and delete subscriptions and
read every subscription's URL, stored query and delivery bodies. A read grant
was buying a mutation, which is the one thing a grant vocabulary exists to
prevent. Measured before the fix: a spec:read token created two subscriptions
against a live daemon.

The guard now asks spec's own principal for IsOwner, which is the only value
left that still tells the owner from an agent. That restores exactly what the
daemon's ownerOnly wrapper enforced before the conversion, and it needs no new
grant string: naming a third grant beside spec:read and spec:propose is a
vocabulary decision for tokens.sr.ht to mint, not something to invent at a call
site.

One guard covers the whole webhook chapter. createUserWebhook,
deleteUserWebhook, userWebhooks and userWebhook all call it, and the two field
resolvers that carry webhook data are reachable only through an object one of
those four returned. Query.webhook has no ACL and needs none — outside a
delivery there is no payload, so it answers an error; asserted rather than
assumed.

The consequence, and it is not small: webhook management is now unreachable over
/query, because this endpoint accepts no credential that resolves to the owner —
authn produces KindOwner from the unified-login cookie alone and this endpoint
reads no cookie. The check is written against the right predicate anyway, so a
plane that does yield the owner works the moment it exists. Delivery is
untouched, and was verified end to end against a subscription inserted directly.

Also stop returning coreerrors.ErrAccessDenied itself. It is a package-level
*gqlerror.Error and gqlgen assigns the field path onto the error it is handed,
so the shared object carried the previous refusal's path: against a live daemon
a refused deleteUserWebhook, userWebhooks and userWebhook all reported
"path":["createUserWebhook"]. A wrong answer, a cross-request leak of which
field somebody else asked for, and a data race on a global. The bug predates
this endpoint but was latent while ownerOnly refused non-owners before any
resolver ran; refusing agents here makes it the common path.
5bb0bb13 — Eugene Blikh 2 days ago
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.
021d955a — Eugene Blikh 25 days ago
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.
8374a1ef — Eugene Blikh 25 days ago
feat(graph): GraphQL-native webhook surface (Phase 5a)

The webhook types, mutations, and resolvers, adapted from the pages.sr.ht
core-go template for spec's single-owner model.

- SDL: WebhookEvent (PROPOSAL_OPENED/MERGED/REJECTED), WebhookSubscription
  interface + UserWebhookSubscription, WebhookDelivery, WebhookPayload
  interface + ProposalEvent (carries a Proposal), cursor wrappers,
  `webhook` payload root field, and a `type Mutation` with
  createUserWebhook / deleteUserWebhook. No OAuth `client` field and no
  @access/@private directives — spec has no OAuth clients or scopes, so
  the owner gate is the entire ACL.
- Models: hand-written database.Model impls (UserWebhookSubscription,
  WebhookDelivery) so gqlgen autobinds rather than generates them; events
  via pq.Array; cursor keyset pagination.
- Resolvers: all owner-gated via authn (spec's ACL), using core-go's
  webhook engine — Validate, NewAuthConfig (INTERNAL, via the coreauth
  bridge), FilterWebhooks, WebhookContext.Exec for the sample, and the
  `webhook`→Payload(ctx) root. Proposal writes deliberately stay off this
  surface (only webhook mutations; the schema test now asserts exactly
  that).
- gqlgen.yml binds Cursor to core-go's model.Cursor; generated code
  regenerated with the pinned gqlgen v0.17.36 (reproducible).

Compiles and vets clean; existing graph read tests still pass. Runtime
context wiring and event firing are the next slices.
6f4efc2b — bigbes 27 days ago
feat(graph): the read-only GraphQL schema at /query

Eight query fields over the service layer, no Mutation and no
Subscription — the design defers mutations until the proposal state
machine settles, and TestSchemaHasNoMutations stands guard on that.

Access is fail-closed and gated before parse, matching web's ACL exactly,
so introspection is treated as content too. A federating api.sr.ht must
therefore present a token or skip us, which costs one log line.

A malformed rev is reported as a GraphQL error rather than folded into
null. service/ deliberately hides malformed-versus-absent from probing,
but the caller here is already authenticated as the owner or its own
agent, and a bare null for rev=proposals/42 is indistinguishable from an
absent document — it reads as a silently dropped argument.

Proposal listing declares its port but is unwired: service/ exposes no
proposal read yet, and returning an empty list would tell a reviewer their
queue is clear when it is merely unread.