~bigbes/sr-ht-spec

ref: 64cae3af81d4b0039edc8ec3946bed36166a447b sr-ht-spec/graph d---------
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.
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.
b643b0be — Eugene Blikh 9 days ago
bearer: refuse through the shared table and challenge
b7d1bf89 — Eugene Blikh 9 days ago
login: decode the unified-login cookie through ecore
d4b6373d — Eugene Blikh 9 days ago
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.
a4d8cc52 — Eugene Blikh 9 days ago
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).
c7477607 — Eugene Blikh 10 days ago
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.
a0fa81b3 — Eugene Blikh 24 days ago
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
d8a5164f — Eugene Blikh 25 days ago
feat(webhooks): fire on proposal open/merge/reject (Phase 5a)

The firing half — proposal lifecycle events now deliver GraphQL-native
webhooks. Verified end to end against a live daemon: an agent REST
propose delivers a signed POST whose body is the subscription's stored
query executed against the ProposalEvent payload.

- service: an EventSink seam (service/events.go). Propose emits
  PROPOSAL_OPENED for a new proposal, mergeProposal emits PROPOSAL_MERGED
  (the single merge point — both auto-merge and the human approve reach
  it), Reject emits PROPOSAL_REJECTED. Nil-safe; a Service with no sink
  emits nothing.
- graph.NewProposalEvent builds the *model.ProposalEvent payload from a
  service.Proposal (reusing the existing service→graph→model mapping).
- cmd webhookEventSink: proposal events happen in the service layer,
  which has none of core-go's request context, so the sink enqueues a
  dowork task onto the webhook queue. The task runs in the queue's worker
  context (server+database+config, from WithQueues), adds the owner's
  INTERNAL auth, and calls Schedule — which renders each subscriber's
  query and delivers it Ed25519-signed. Fire-and-forget off the write
  path: a webhook never blocks or fails a proposal write.

Phase 5a (webhooks) is complete: DB, the authn→AuthContext bridge, the
GraphQL surface, the core-go server wiring, and firing.
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.
f8a7a174 — Eugene Blikh 26 days ago
feat(graph): wire the proposals read to service.ListProposals (Phase 3)

The Proposals port declared a proposal listing and left Options.Proposals
nil, so the `proposals` query failed loudly with "service/ exposes no
proposal listing yet". Phase 3 supplies it: an adapter maps
service.Proposal onto graph.Proposal at the edge — the two structs are
identical, but service/ must not import graph/, so the rename lives here
beside web.NewReader's equivalent — and cmd wires graph.NewProposals(svc)
into the schema. The `proposals` field now answers from service/.
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.