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.
bearer: refuse through the shared table and challenge
authn: accept tokens.sr.ht working tokens beside the agent token A second agent credential plane, next to the existing one rather than in place of it. The agent_token table, every agent configured with it, and the refs rule and provenance requirement around it are untouched; the local plane is removed in a later phase, not this one. The resolver tries the instance plane first and falls back to the local store on exactly two refusals, bearer.ErrInvalid and bearer.ErrNotOurs. spec's local token has no prefix to discriminate on — it is 32 random bytes in base64, which is precisely what "did not decode as one of ours" looks like — so the fallback replaces the shape test bench and cover can afford. ErrRevoked, ErrForbidden and ErrUnavailable are terminal: a withdrawn credential must not get a second chance at the old door, and an unreachable daemon must not silently degrade into the legacy plane. Grants ride on the principal and are checked where the action is known, never in the middleware, which runs upstream of the router: spec:propose in service.Propose, below both write surfaces, and spec:read in each read surface's gate. /mcp checks per tool rather than at its Gate, because one endpoint carries both kinds and a surface-wide read grant would refuse a propose-only token at initialize. Principal.Authorize is a no-op off the instance plane, which is what keeps the local token working. The instance plane brings an owner where the local token had none, so a working token belonging to anybody but [sr.ht] owner-name is refused rather than admitted as a second identity: Principal.Owner is read by the provenance committer, the refs rule's principal kind and the coreauth AuthContext, all written for one human. StatusFor is the one status table. ErrUnavailable is 503 and never 401 — reading "I could not ask tokens.sr.ht" as "revoked" would refuse every live instance token while a daemon that is deliberately off the hot path restarts. An instance with no [tokens.sr.ht] section builds no instance plane and starts anyway, serving its own agent token as before.
refactor(authn): one Principal.CanRead() for the read-plane ACL (spec-ejq.1) graph's gate, web's mayRead and mcpsrv's Gate each hand-spelled 'IsOwner() || IsAgent()' — three copies of the read ACL, which graph's own comment warned is how a corpus leaks when they drift. Define it once as authn.Principal.CanRead and route all three through it. coreauth.Derive keeps its own owner||agent test on purpose: it answers a different question (is this an owner-backed identity to bridge to AUTH_INTERNAL), and coupling it to the read ACL would misroute a future read-only viewer kind to the owner's UserID. Closes spec-ejq.1
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.
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.