chore(guard): extend the route-envelope check to the dispatcher domains (#4038 follow-up) - #4056
Merged
Merged
Conversation
…ns (#4038 follow-up) The platform answers REST from two kinds of file. Route modules (*-routes.ts) call `res.json(...)`, which this scan counted. Dispatcher domains (runtime/src/domains/*.ts) RETURN `{ status, body }` for a central sender, so they never call `res.json` and were invisible to it by construction. That gap cost something real: /share-links emitted its payload under both `data` and a legacy `link`/`links` for as long as nothing looked, and it surfaced only because #3983's consumer sweep happened to walk past it (#4038, fixed in #4049). I noted the blind spot there; this closes it. The new table counts hand-built `response: { … }` literals per domain. A domain answering only through the deps.* helpers hand-builds zero and cannot drift; any hand-built one now needs a declared count plus a `note` saying why. Hand-building is not automatically wrong — four kinds show up and only the last is drift: 1. enveloped, but the helper cannot express it — deps.success hardcodes 200 so a 201 is assembled by hand, and deps.error carries no headers so a 405 with `Allow:` is too (keys.ts, share-links.ts, one in mcp.ts) 2. passthrough of a body this dispatcher does not own (mcp.ts, ai.ts) 3. a foreign wire format a client library requires — /auth answers better-auth's shapes because better-auth's client parses them (auth.ts x4) 4. drift 16 domains audited: 11 helper-only, 5 declaring hand-built responses, 1 ratcheted. The ratchet is ai.ts -> #4053, where GET /ai/agents is SDK-addressable but unenveloped and the SDK compensates by reading `.agents` off the raw body — converting it without changing cloud and the SDK together makes `client.ai.agents.list()` return an empty list silently, which is indistinguishable from the legitimate "no AI service configured" state. That warning now fails CI for whoever tries it, instead of sitting in an issue nobody reads. Five self-test assertions cover the new scan. One caught a real false positive while it was being written: matching the `response` key alone counted a PAYLOAD field named response (`deps.success({ response: … })`) as a hand-built response. It now requires `response` to be a sibling of `handled`, which is what makes the pair an HttpDispatcherResult rather than just an object with that key. Verified the guard actually bites: adding a hand-built response to a helper-only domain fails with the site and the four-kind classification prompt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CYbS3kS8xzsHNXFTzp4e2z
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
os-zhuang
marked this pull request as ready for review
July 30, 2026 07:21
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes the blind spot I documented in #4049, and turns #4053's warning into a CI failure instead of an issue nobody reads.
Guard-only — no product code changes. One file plus a changeset.
The gap
The platform answers REST from two kinds of file:
*-routes.ts)res.json(...)runtime/src/domains/*.ts){ status, body }for a central senderThe second never calls
res.json, so the scan couldn't see it by construction — not an oversight in the scan, a category it wasn't built for.That cost something real.
/share-linksemitted its payload under bothdataand a legacylink/linksfor as long as nothing looked, and it surfaced only because #3983's consumer sweep happened to walk past it — #4038, fixed in #4049. Exactly the "a module nobody thought to check never gets audited" argument that made #3843's scan repo-wide in the first place, one level up.What it counts
Hand-built
response: { … }literals per domain. A domain answering only throughdeps.success/deps.error/deps.routeNotFound/deps.errorFromThrownhand-builds zero and cannot drift — the envelope lives in one place for all of them. Any hand-built response now needs a declared count plus anote.Hand-building is not automatically wrong, which is the part worth getting right rather than banning outright. Four kinds show up and only the last is drift:
deps.successhardcodes status 200, so a 201 must be assembled by hand;deps.errorcarries no headers, so a 405 withAllow:must be too. →keys.ts,share-links.ts, one inmcp.tsmcp.ts,ai.ts/authanswers better-auth's shapes because better-auth's client parses them. →auth.ts×4The ratchet is the point
ai.ts→ #4053.GET /ai/agentsisdisposition: 'sdk'but unenveloped, and the SDK compensates by reading.agentsoff the raw body. Convert it the way the other five were converted — payload directly underdata— without changing cloud and the SDK in the same batch, andclient.ai.agents.list()returns an empty list, silently. Which is indistinguishable from the legitimate "no AI service configured" state, so nobody notices.I filed that as an issue an hour ago. An issue only helps someone who happens to read it; a ratchet stops the person who doesn't. That's the actual value here — the rest of the table currently catches nothing, and I'd rather say so than oversell it.
A false positive the self-test caught mid-write
Matching the
responsekey alone counted a payload field namedresponse—deps.success({ response: … })— as a hand-built response. It now requiresresponseto be a sibling ofhandled, which is what makes the pair anHttpDispatcherResultrather than just an object with that key.Worth flagging because it's the same over-broad-match class as
privateOkin #4037, and because the assertion that caught it was one I wrote expecting it to pass.Verification
check:route-envelope --self-testresponseignored)check:route-enveloperesponseto a helper-only domain (ui.ts) → fails with the site and the four-kind prompt, exit 1check:error-code-casing·check:console-sha·check:nul-bytes·check:role-word·check:org-identifiereslint --no-inline-configEmpty changeset — CI guard, releases nothing.
Generated by Claude Code