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.
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.
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.
chore: promote go-arg to a direct dependency
specsrht-migrate embeds brant's cli.Args, which is go-arg based, so the
toolchain reclassifies it. No version changed and no module was added.
chore: promote fernet-go and go-ini to direct dependencies
authn/ imports both directly to decrypt the unified-login cookie and read
the instance ini, so the toolchain reclassifies them. No version changed
and no module was added.
Deliberately not running go mod tidy yet: bleve, chi, brant and the MCP
SDK have no importer until phases 2 and 3, and tidy would drop them from
go.mod, reintroducing it into every later parallel wave's file set.
feat: foundation — go.mod with every dependency, and the core/ domain
Phase 1 foundation commit. Two things, so that later parallel waves write
disjoint directories and never touch go.mod:
- go.mod / go.sum carrying every external dependency the whole module will
need (go-git, bleve, goldmark, chi, lib/pq, yaml.v3, the MCP SDK, brant,
auxilia, testify, and the sr-ht-core fork). Populated by building a
throwaway blank-import file, which is then deleted; `go mod tidy` runs
once, at the very end of the build-out.
- core/, the pure domain: owner/space names, safe relative paths, the
globally-unique document ID grammar, frontmatter parsing and schema
validation, `.spec.yml` policy with auto_merge glob matching, and the
proposal state machine. Standard library plus yaml.v3, nothing else.
Two design invariants are enforced here rather than documented and hoped for:
"approved" is not a status (it is a property of the branch a document is
reachable from), and the proposal machine has exactly open/merged/rejected.
A per-space `.spec.yml` cannot reintroduce either.
Note on the sr-ht-core pin: the design calls for a `replace` onto
git.srht.bigb.es/~bigbes/core-go at c2c2f38, but that commit's go.mod still
declares `module git.sr.ht/~sircmpwn/core-go`, so Go rejects the replacement.
Both siblings pin the later dd418a20 under the canonical path with no
replace; this does the same.