~bigbes/sr-ht-spec

ref: 64cae3af81d4b0039edc8ec3946bed36166a447b sr-ht-spec/cmd/specsrht/graphql.go -rw-r--r-- 5.1 KiB
64cae3af — Eugene Blikh a day ago master
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.
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.