go.mod: take the shared libraries' current heads ecore for mcphttp and the hot-path work, core for the errors fix: its seven gqlerror sentinels were package-level pointers that gqlgen stamped with the first field path they ever took, which was a wrong answer, a cross-request leak and a data race.
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.
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.
mcpsrv: mark /mcp uncacheable, fail closed on origin, split tool errors from faults Three gaps between this surface and the cov/dolt pattern the siblings settled on. The write tools are untouched, the per-tool grant scheme is untouched, and the transport stays stateful. The endpoint set no Cache-Control and no Vary at all. Every answer here depends entirely on the credential the request carried and says nothing about it in its URL, and some of them are the whole approved corpus, so a shared cache was free to keep one and replay it to the next caller. cache.go is a local copy of ecore/mcphttp.PrivateCache, byte for byte on the header values so the retrofit is a delete and an import once that commit is published. It commits the headers on Write and Flush as well as WriteHeader: the SDK answers a POST with an event stream that never calls WriteHeader, so a wrapper hooking only that one sets nothing on the response an agent actually gets, while passing every other header test. Measured — with only WriteHeader hooked the streamed answer leaves with the SDK's own `no-cache, no-transform` and no Vary. Vary names both planes, where dolt.sr.ht names Authorization alone. dolt is right for dolt: its /mcp is bearer-only, so naming Cookie would promise a cache a dependency the surface never reads. It is wrong here. authn.Resolver.Resolve prefers a bearer token when one is present but falls through to login.UsernameFromRequest when none is, and an owner cookie resolves to KindOwner — which is exactly what Gate admits. On this service the cookie is the difference between the whole corpus and a 401. This is the one string a future mcphttp retrofit has to reconcile between the two services. An origin with no host in it was a warning and then an unguarded endpoint. The Host allowlist is the only thing protecting /mcp once the SDK's own rebinding guard is disabled, so that path turned one unparseable config value into a silently open endpoint indistinguishable in every functional test from a correctly guarded one. It is a construction error now. The daemon cannot reach it either way: service.Config.Validate already refuses to start unless the origin parses and carries a host. Errors from below travelled to the agent as tool results carrying their own text, so a dead git object store and a missing document were the same kind of answer. A tool result means "the call was understood and the thing you asked for is not there", so an agent reading one for a store outage concludes the document does not exist and rewrites a specification around a document that is perfectly real — and the store's own words reached it. errors.go splits the two: service.ErrNotFound is a tool result whose sentence is built from the call's own arguments, and everything else is a jsonrpc protocol error saying "internal server error" with the detail logged. The old tests asserted the behaviour being removed — that the agent was shown the words "on fire" — and are replaced by ones that pin the split in both directions.
docs: drop a stray closing tag from ci.md An XML tag from some tool's output that was written into the file and committed with it. Predates this branch — it is in ef7bddf too.
ci: publish this build's own coverage and benchmarks Serving a revision is two passes over the whole corpus — FromDocuments builds the archive, LinkPass renders every document to fill the link graph — and a proposal view is a third over two whole revisions. All three grow with the space rather than with the request, and this repository had no Benchmark at all; doc/ and prosediff/ now measure them over a synthetic 208-document space and a 24-chapter specification carrying one of every change kind. The manifest gains a coverage and a bench task, both after publish so a rejected upload cannot cost a shipped apk, and both guarded: the profile must be non-empty, the benchmark names must be in the file (an empty benchfmt body uploads and reports success), and a build handed no token says so and exits 0 with the artifact still attached.
ci: publish the apk into artifacts.sr.ht as well The abuild output goes on to the S3 bucket phoebe re-indexes, and now also to the ~bigbes/main channel of artifacts.sr.ht, which indexes and signs it in the same request. One PUT per file with the shared working token; a 409 says the job ran twice and is a warning rather than a failure.
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.
ci: run the suites against a real Postgres on the builder 93 test files across 16 packages had never run on builds.sr.ht. The manifest brings up a Postgres in the VM, exports SPECSRHT_TEST_PG and runs gofmt, go vet and `make test` before abuild. The DSN guard is the point of it. db/db_test.go, service/fixture_test.go and cmd/specsrht-migrate/main_test.go all gate on that variable, so an unset DSN does not fail anything — it skips 67 tests (counted, with and against a local Postgres) and leaves the build green over the whole persistence layer, the schema/migrations agreement check included. options="!check" in the APKBUILD means this task is the only place the suites run at all, so the guard is what makes that line true. TEST_TIMEOUT lands with it rather than after the first goroutine dump: the toolchain's default is a silent ten minutes.
ci: export the version instead of sed-ing a tracked APKBUILD The version reaches abuild as $PKGVER now, and the tracked APKBUILD is never rewritten: Go reads vcs.modified from `git status --porcelain`, so a sed of a tracked file stamps every packaged binary dirty for the whole abuild run. The export alone would not have fixed it. .gitignore covered the intermediate web/static/main.css and not the content-hashed main.min.<sha>.css that `make css` renames it to — the file that survives, and that build() writes BEFORE go build — so every packaging run compiled with an untracked file in the tree. /src/, /pkg/ and /tmp/ are the three directories abuild works in inside this checkout, GOTMPDIR among them. Also: the cacher bootstrap splits into install + init so the installer's PATH export is sourced by the next task, and goes through install.sh for its checksum verification; scss collapses into the --exec miss-and-seed form; cache_restore takes --optional plus the half-restored module cache repair; cache_save moves after publish and drops the exists-guards; publish gates on a readable ~/.apk-ci.env; tags build. package() stages what build() produced (make install-files) instead of recompiling both binaries in a fresh fakeroot process with no cache pins, and stops installing static assets that //go:embed already put in the binary. The `[ -d ./cmd/... ]` skip guards are gone: a deleted cmd/ was a green build that packaged nothing. The reasoning lives in docs/ci.md, because the manifest is a varchar(16384) and a submission over the cap gets no CI at all.
bearer: draw the 401 arm from IsAuthFailure so the two cannot drift
deps: tidy after the third uplift
chrome: the resolved favicon and the queue as a shared table
pages: read a form's body, bounded, and never its URL
bearer: refuse through the shared table and challenge
chimw: the request line, the HEAD twins and the routing refusals
logging: take the instance's log policy from ecore
login: decode the unified-login cookie through ecore
instconf: one reading of this instance's origins
deps: the ecore packages of the third uplift