graph: accept a meta.sr.ht token, so /query can be federated api.sr.ht forwards ONE client Authorization header to every service a federated query touches — AuthMiddleware copies it verbatim into the request context, and the Internal credential it can mint is used only to fetch schemas at startup. So a federated caller arrives here holding whatever credential the client had, and the only credential that works across the whole instance is a meta.sr.ht personal access token. /query refused those, which made federating it one `api-origin=` line that would produce 401s. api-meta.json was the stronger half of the same refusal: with an empty scope list meta.sr.ht renders no checkbox for this service, so no PAT could be scoped for it even in principle — no amount of code in graph/ could have worked around that. It now publishes SPECS, derived from authn.ScopeRead rather than spelled twice, with a test on each side asserting the two agree. A scope published and not checked admits what should be refused, one checked and not published cannot be minted at all, and neither is visible from inside one file. The plane is built in cmd/specsrht beside the graph.New call and NOT on the *authn.Resolver every surface shares, which is the whole scope of the exception: the REST write plane, /mcp and the push hook resolve through that resolver, which knows nothing of PlaneMeta, so a PAT is not a way around the tokens.sr.ht grant they require. Routing is metapat.PlaneOf on the presented credential rather than a fall-through from a failed plane, because an instance with no [tokens.sr.ht] section has no working-token validator to fail first. Two things this service needs that the cov and bench donors did not. MetaAuth is told the instance owner and refuses a PAT belonging to anybody else with the same ErrNotInstanceOwner a foreign working token gets: a PAT is the credential every account on the instance can mint for itself, so without that check the widest credential in existence would have been the one that skipped the narrowest identity rule, and any user could have read the corpus. And a PAT resolves to KindAgent, never KindOwner, so webhook management and proposal approval stay with the human at a browser. The sentinels are spec's own — ErrMissingScope, ErrInvalidPersonalToken, ErrMetaUnavailable — rather than reuses of bearer's, because bearer's prose names tokens.sr.ht and every word of it is wrong for this plane; an operator reading "tokens.sr.ht could not be reached" while that daemon is up goes looking in the wrong place. What must agree between the planes is the status, not the wording, and StatusFor is the one table that decides it for both. The two vocabularies do not overlap and are refused differently, which the tests pin: a PAT naming spec.sr.ht/spec:read is a well-formed OAuth grant for a scope this service does not publish (403), while a working token naming spec.sr.ht/SPECS does not parse as a grant string at all (401). A 403 names the permission of the plane the caller actually used — telling a PAT holder to obtain "spec:read" would send them looking for a checkbox meta.sr.ht cannot render. cmd/specsrht's api-meta assertion is not Postgres-gated here, unlike the bench donor's: it ran locally and went red on the stale empty-list assertion, rather than hiding until CI.
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.
login: decode the unified-login cookie through ecore
graph: seed the test keyset from ecoretest The same fernet-key-and-webhook-seed TestMain the web package had, and the same reason to delete it: ecore's keys are constants, so both packages of this service can install them without the second rotating what the first sealed with.
authn: remove the local agent-token plane The agent_token table and everything that minted, verified, listed or revoked it. Agent credentials are tokens.sr.ht working tokens now: signed, expiring, owned by a meta.sr.ht account and carrying grants, verified locally by sr-ht-ecore's bearer package. One door, and nothing behind it — a credential the instance plane refuses is refused, where it used to be offered to a second store that might say yes. DEPLOY GATE: do not deploy this until every agent configured with the shared secret holds a tokens.sr.ht token with spec:propose (spec:read to read). Migration 0005 drops the table, migrate-on-upgrade runs it on deploy, and deploying early locks out every agent at once — including the SSH push path. hooks/ no longer reads agent_token directly. The push path goes through the same authn.Resolver the HTTP surfaces use and demands spec:propose, because a push by an agent is a proposal by another transport. The refs rule and the provenance requirement are untouched on both counts: a universal grant is still only an agent to the receive path, and X-Agent / X-Agent-Session are still mandatory on every agent write. bearer.ErrNotOurs is now a permanent refusal. A meta.sr.ht PAT used to fall through to the local store and miss there; with no store to fall through to it earns a 401 rather than the 503 an unclassified error would. [tokens.sr.ht] origin becomes a required config key: with no issuer there is no credential to check, so the daemon fails startup instead of serving reads and refusing every agent write one request at a time. /tokens redirects to the daemon that issues (tokens SPEC ch. 7).
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.
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.