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.
api: advertise an empty scope list, not null
core-go serves the second argument of WithSchema verbatim at
/query/api-meta.json, so a nil slice reaches the wire as `"scopes": null`.
meta.sr.ht discovers every *.sr.ht service at import time and iterates that
field when rendering /oauth2/personal-token, so the null turned the whole
instance's personal-token page into a 500 — no token could be minted at all
while spec.sr.ht was up. The other custom services answer 404 there and are
skipped, which is why only this one broke it.
Empty is the accurate value: the service is owner-only and defines no
AccessScope enum to grant against.
logging: take the instance's log policy from ecore
logging: log through slog and scribe rather than stdlib log
sr-ht-ecore's panic middleware now reports through log/slog, and it reports
through the *default* logger — nothing can hand a middleware in another module
this service's *slog.Logger. So the daemon's scribe handler becomes the one
install point, and the packages that were still calling log.Printf go through
the default logger too: the read plane's render and encode failures, its 5xx
mapping, and the credential resolver's fail-closed line. Each carries the fields
that used to be interpolated into the sentence — method, path, status, page,
doc — and the error itself through scribe.Err.
The handler grows what it was missing: file:line, because most of what reaches
it is a failure and 'which of the six render sites' is the first question;
colour dropped when stderr is not a terminal; and the masks. This daemon handles
the unified-login cookie and tokens.sr.ht working tokens, and a struct logged
whole is how a live credential outlives its own request in a log file — masking
in the handler covers the log line nobody reviewed as well as these.
cmd/specsrht-migrate keeps stdlib log on purpose: it is a one-shot CLI whose
'specsrht-migrate: ...' progress an operator reads at the terminal during an
upgrade, and log.Fatalf is its error exit.
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).
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(web,service): the owner mints and revokes agent tokens in a browser
Issuing a credential required SSH to the host, which made the remote
agent write plane unusable from anywhere else: to hand an agent a token
the owner had to be at the machine. /tokens is that page — list, mint,
revoke — behind the same owner-only gate and same-origin guard as
approve/reject.
The mint is owner-only, and that rule is what revocation depends on: an
agent allowed to mint would survive having its own credential revoked by
issuing itself another, and "revoke the token" is the entire incident
response this design has. An agent asking for the page gets 403 rather
than the read plane's login redirect — it is authenticated already, so
bouncing it to meta would answer a question it did not ask.
The plaintext is rendered in the response to the POST rather than after a
redirect. A redirect would either drop the secret or carry it in a URL,
where it lands in history and in every proxy log on the way; the cost is
that a reload re-submits and mints a second token, which is one click to
revoke on that same page, whereas a lost token is not recoverable.
service.IssueAgentToken/ListAgentTokens/RevokeAgentToken hold the ACL and
the mint, and `specsrht token` now goes through them too, so the CLI and
the page cannot drift into two ideas of what issuing a token is.
spec-ejq.3
feat(cmd): agent tokens and host-side proposals get admin commands
Two entry points were missing, and both left a deployment unable to do
the thing it exists for.
`token create|list|revoke` — db/ has had the whole agent-token lifecycle
since Phase 1, but nothing called it: no CLI, no page. A fresh instance
therefore had no credential for the agent write plane, which refuses an
anonymous caller by design, and the only way to mint one was an operator
hand-writing an INSERT with a sha256 hash. The plaintext is printed once
and never logged; only its hash is stored, and the listing deliberately
omits the hash so nobody mistakes it for the credential.
`doc propose ~owner/space <file>...` — the two agent write surfaces are
remote and so need a bearer token. When the operator and the documents
are already on the host, that token is ceremony: the process can open
Postgres and the bare repositories directly, so it constructs the agent
principal itself rather than resolving one from an agent_token row.
Provenance is not waived — --agent and --session are recorded exactly as
a remote agent's are, so `git log` cannot tell the two apart, and neither
can a reviewer. It calls service.Propose, so If-Match, the branch cut,
the trailers and the auto-merge gate stay spelled once.
Flags parse before, after and between the positionals: Go's flag package
stops at the first non-flag, which would make `doc propose ~bigbes/rfcs
spec.md --title x` drop --title and fail one layer down complaining
about a missing title rather than the flag it ignored.
spec-rsb, spec-ovo
fix(mcpsrv): gate read tools to owner+agents (spec-jjo)
The MCP read tools (spec_search/spec_read/spec_list) enforced no ACL:
anyone clearing the Host allowlist could read approved content. graph's
/query and the web UI gate reads to owner+agents; /mcp did not. Add the
same fail-closed gate to the whole MCP surface — initialize, tools/list
and tools/call — mounted inside the resolver middleware so it sees the
resolved principal. spec_propose was already fail-closed in service.Propose;
this makes the read tools match.
Pre-existing since Phase 2, cheap now that /mcp resolves a principal.
Closes spec-jjo
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(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(api): REST write plane — PUT a document to propose (Phase 3)
The other agent-facing write surface, PUT /api/v1/spaces/~owner/name/
docs/<path> with If-Match and X-Proposal headers, returning
{proposal, url}. Like spec_propose it holds no proposal logic: it parses
the request into a service.ProposeRequest, calls the same service.Propose,
and maps the result and the service sentinels onto status codes — 201 on
open, 200 on add, 403 non-agent, 422 malformed document, 409 stale /
already-merged, 404 missing. The body is the whole document; title,
rationale and message ride in the query string so the body stays the
document. The acting agent is resolved from the bearer token by the
principal middleware the endpoint installs, and service.Propose is the
one ACL — an anonymous caller is a 403 there.
Split the service error taxonomy this surface exposed: ErrInvalid (422,
a malformed document the agent must fix) is now distinct from
ErrForbidden (403, a principal that may not propose at all). Conflating
them answered "bad document" with "you are not allowed", which is exactly
the distinction the retrying agent needs.
feat(mcpsrv): spec_propose write tool (Phase 3)
The agent-facing half of the write plane over MCP. spec_propose uploads
whole documents and returns {proposal, url, merged}, calling the same
service.Propose the REST PUT will — one implementation of If-Match,
provenance and auto-merge behind both surfaces, never two that drift.
- Writer is an optional Backend field: nil keeps the server read-only
(the three read tools, unchanged), so a read-only deploy or a test
needs no mutable backend. Set, it registers spec_propose with neither
the read-only nor the idempotent hint — proposing twice opens two
proposals.
- The acting agent is resolved from the bearer token on the tool call by
the resolver middleware now installed on /mcp; the read tools never
needed it. The ACL stays in service/: service.Propose refuses a
non-agent, so an anonymous or owner caller is rejected there.
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/.
feat(cmd): reindex a space when a push lands
The push notifier was a Phase 1 stub that logged 'reindex pending' because
bleve did not exist yet. It does now, so a pushed document was readable
but never searchable, and the reconciler reported permanent staleness.
Pushes touching only proposal branches skip the rebuild: the index holds
the approved revision, and making unreviewed text searchable is the same
leak as serving it from the read plane.
The index stamp is written last and only on success. Written earlier it
would assert the index reflects a revision it does not — the exact
staleness the reconciler exists to catch, and it would catch nothing.
The hook server is now built after the surfaces, since the notifier
reindexes through the same single-writer index they read from.
feat(cmd): specsrht space create/list
Spaces had no entry point at all: the read plane only reads, and the
proposal API operates on documents inside a space that already exists, so
a freshly deployed instance could not hold anything.
Creation installs the receive hooks itself rather than relying on the
daemon's startup refresh. A space created while the daemon runs would
otherwise accept unvalidated pushes until the next restart — the exact
fail-open the receive path exists to prevent.
feat(cmd): mount the read plane, MCP and GraphQL surfaces
The three Phase 2 surfaces were built but never served: each was written
under an instruction not to touch cmd/, so every one reported its mounting
call and none of them wired it. The daemon answered /healthz and 404'd
everything else.
They share one search.Index, because bleve is single-writer and a second
Open on the same directory is wrong rather than merely wasteful.
Route order is load-bearing: /mcp and /query register before the web UI
mounts at /, which would otherwise swallow them as document paths — the
router has no reason to think 'mcp' is not a space name.
The MCP handler takes the configured origin as its Host allowlist, which
is why Traefik must pass the Host header through.
feat: hooks — the receive path, its daemon RPC, and the specsrht daemon
The hooks are thin shims that RPC into the running daemon over a unix
socket under the repos root, so validation lives in one place and the
push path cannot drift from the API. They fail closed: an unreachable
daemon rejects the push.
Three hooks are installed, not two, because of two properties of git
verified against 2.55 rather than inferred:
- GIT_PUSH_OPTION_* reaches pre-receive and post-receive only; the
update hook observably runs without them, so it cannot see
--push-option=skip-validation on its own.
- during pre-receive the pushed objects are still in receive-pack's
quarantine and unreadable by any other process, so the daemon
cannot validate there. git migrates them out before the first
update hook, which makes update the earliest hook that works.
pre-receive therefore forwards the push options and the ref list; the
daemon records them keyed by (repository, receive-pack pid) and by the
exact ref update; update does the rejecting; post-receive notifies.
An update with no recorded pre-receive phase is refused rather than
assumed unskippable.
Each hook is a symlink to the specsrht binary, which dispatches on the
name git invoked it as. Install also sets receive.advertisePushOptions,
without which the escape hatch fails client-side. The daemon refreshes
every space's hooks at startup and treats a failure as fatal.
cmd/specsrht validates every config key at once before opening
anything, serves /healthz on -b (default localhost:5091), runs the
reconciler, and warm-shuts-down on SIGINT and SIGTERM alike by
bridging SIGTERM into the SIGINT core-go's server.Run waits for.
Tested with a real git push against a real bare repo with the hooks
installed: a valid push lands, bad frontmatter is rejected with a
readable message, skip-validation waives it, a force-push to the
approved branch is refused with or without skip-validation, a mistyped
push option is refused rather than ignored, and a push with no daemon
is rejected.
feat: specsrht-migrate — brant wrapper for the spec.sr.ht schema
Single-service wrapper around git.sr.ht/~bitfehler/brant, a close sibling of
doltsrht-migrate: the brant subcommands (up, down, current, list, stamp,
validate, ping) plus an extra `init` that applies schema.sql wholesale and
stamps to head for a fresh install.
The DSN comes from [spec.sr.ht]connection-string unless --dsn overrides it;
a missing or empty value is a fatal error rather than a default connection.
Migrations load from ./migrations in a checkout, else from
<[sr.ht]assets>/migrations/spec.sr.ht. -a honours [spec.sr.ht]migrate-on-upgrade
and exits early when it is off. lib/pq's "postgres" driver replaces brant's
pgx default, which this module does not link.
Tests cover flag parsing, DSN precedence and the migrations-directory
resolution order without a database; the init/up round trips against a scratch
schema skip unless SPECSRHT_TEST_PG is set.