Repository navigation
fix(hono)!: createHonoApp reads a dispatched body by plugin-hono-server's rule, so a text/plain or undeclared body is refused when a domain reads it - #24071
Conversation
…r's rule, media type by media type
A JSON body sent as text/plain, and any other body that declares itself
without declaring JSON or a form, is refused with 400 INVALID_REQUEST when
the dispatched domain reads it, through the refuse-on-read value. A form is
parsed from a clone, so the forwarded request stays unread. No body is {}.
Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT
Co-authored-by: Claude <noreply@anthropic.com>
…e the raw-request rows the new rule answers Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 6 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 7b67774f7d9dee4d41659762c3d4cb4244448fcc && git checkout 7b67774f7d9dee4d41659762c3d4cb4244448fcc
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8133cc78052c95b6efd59487b64fe78f69860759 c71a087e64086358935cae6c6d4f9f004ee85c6b && git checkout -B drift-repro 8133cc78052c95b6efd59487b64fe78f69860759 && git merge --no-ff c71a087e64086358935cae6c6d4f9f004ee85c6b
node scripts/docs-audit/affected-docs.mjs --json 8133cc78052c95b6efd59487b64fe78f69860759
|
…and the 24042 sentence that pointed at it Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude <noreply@anthropic.com>
…4042 sentence that supersedes its bullet The #17712 guard refuses an edit of a pending changeset this PR did not add. Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude <noreply@anthropic.com>
Contract reviewServed-tier: Net diff against the merge base ① Derived judgments1.
2. The mirrored prose is the twin's, word for word ( 3. Every refusal rides the refuse-on-read value and nothing else moved ( 4. The accept-set changes the diff implies, each named.
5. A forwarding domain still reads its own bytes. Right. Both reading branches read a clone, the third reads nothing, so 6. Published surface. Nothing changes. 7. Pins: every removed assertion is re-pinned with its new answer, and no pin loosens. Right.
8. Legitimate callers. None refused in what can be read from here. ② Semver levelOne changeset, Sentences against the diff:
③ Boundary flags
Implemented-by: VERDICT: PASS Generated by Claude Code |
席位核对ACCEPT · #24042 ·
Escalated by the review, carried by this seat:
Landing: Generated by Claude Code |
Fixes #24042
Clause-②: no (narrowing)
What
createHonoApp(@objectstack/hono) now reads a dispatched body by the rule@objectstack/plugin-hono-serverapplies (readRequestBody,packages/plugins/plugin-hono-server/src/request-body.ts), media type by media type. Every refusal still rides the refuse-on-read value PR #24005 introduced for #23914, so a refusal is answered only when a dispatched domain reads the body, after the dispatcher's gates. One file changes insrc:readDispatchBody, its docblock, and module-private copies of the twin's messages and ofdeclaresBodybesideRequestBodyRefusal. Nothing new is exported.POST/PUT/PATCHon the catch-all; every method butGET/HEADon the/auth/*fallback)application/json(case-insensitiveincludes){}; unparseable is refused on readmultipart/form-dataorapplication/x-www-form-urlencoded{}parseBody(the functionc.req.parseBody()runs on the twin); a form that does not parse is refused on read, with the twin's form messageContent-Lengthother than0, orTransfer-Encoding){}otherwise{}(or parsed){}The JSON-sent-as-
text/plaincase (#23914's step 4) is the "other media type" row: refused, not parsed.content/docs/api/wire-format.mdxalready namesContent-Type: application/jsonas required (:11,:594); no sentence there becomes false, so it is untouched.Measured at the door: the real dispatcher and data domain through
createHonoAppA temporary file in
packages/qa/http-conformance(thehono-body-refusal.conformance.test.tsboot: a realLiteKernel, ObjectQL, sqlite-wasm, the realHttpDispatcherand/datadomain), each body sent withContent-Lengthas a client sends it. The base column is the same file with the base reader put back throughscripts/ablation-replace.mjs(bloba4b335ae969dtod060fe2070b0, restored,git diff HEADempty). The file was deleted after both runs and is not in the diff.POST /data/task/query{"where": {"status": "churned"}}astext/plainINVALID_REQUEST, "sent as text/plain"POST /data/task/queryhelloastext/plain{}, the empty query)INVALID_REQUESTPATCH /data/task/ID{"name":"HACKED2"}astext/plainINVALID_REQUEST, the row unchangedPOST /data/task/queryurlencodedwhere=...{}, the empty query)VALIDATION_FAILED(query.whereis a string)POST /data/taskurlencodedname=from_form&status=activefrom_formPATCH /data/task/IDurlencodedname=HACKEDThe three form rows are the parsed fields reaching the domain, which is what the self-hosted door already hands it (see Acceptance notes).
PM mechanism assumptions, measured at base
b1d5569d9application/json, or a body with noContent-Type, to the hosted door. Holds.createHonoApphost: every importer is a test (packages/adapters/hono,packages/qa/http-conformance), docs or a comment.packages/climounts none.packages/client:this.fetchdefaultsContent-Type: application/json(index.ts:7450); the one directfetchImplwith a body is the presigned upload, which goes to the storage surface, not throughdispatch()(the dispatcher has no/storagedomain).examples/,packages/qa/dogfood,scripts/,packages/cli/srcandpackages/client/src: every platform poster declares JSON; the rest are GitHub API calls, flow-node configs or response shapes.f8267df93aba(.objectui-sha):approvalsApi.call,CloudConnectionPanel.getJson,FormPage'sapiFetch,useRecordApprovals'sfetchJson,DatasourceResourcePage'sapi, plugin-chatbot'scallandserverActionHandlerall setapplication/json, and none of their body-carrying calls passes headers that would replace it.ApiDataSourcesendstext/plainonly for a string body, and its three console instances areGETdoors. NosendBeacon.UploadProvider's XHR PUTs to the presigned raw door.data-objectstack'suploadFile/uploadFilespost multipart to/api/data/OBJECT/upload, but nothing calls them at the pin and no server route serves that path.createHonoApphost, is not in this container.bodyUsed === falseat dispatch and the exact bytes on the request; a new row pins the cache path (an upstreamc.req.parseBody(), then the catch-all hands the parsed form from Hono's cache).body(data, meta, packages, actions, analytics, automation, keys, ai, notifications, security, share-links, permission-set-clone, catalog-activation, mcp) now receives a form's fields where it received{}, as it does onplugin-hono-server. The forwarders (/approvals/act,/webhooks/redeliver, the inbound automation hooks) never readbody, and the legacy auth domain never reads its_body. No first-party caller posts a form to a body-reading domain (assumption 1); the one first-party form post,/approvals/act, is a forwarder.dispatch()reads no body before its gates. Holds.dispatch()holdsbodyin the handler's argument literal only (http-dispatcher.ts:2890,:2989) and its gates take the path and headers. Measured on the real dispatcher and the real plugin-webhooks member, hosted door against the self-hosted mount over a socket, with a temporary copy ofhono-webhooks-redeliver.conformance.test.ts(deleted after): anonymoustext/plaingarbage, anonymousapplication/octet-streamand anonymous no-Content-Typebodies all answer401 UNAUTHENTICATEDat both doors; signed-intext/plainand no-Content-Typegarbage answer the member's400 "Request body must be JSON."at both; a signed-in valid JSON body sent astext/plainfor an unknown delivery answers404 RESOURCE_NOT_FOUNDat both. Status, headers and body bytes equal on every row.Tests
All runs are under
scripts/pm/os-verify-lock.shon headf0c5d3bdc, each exit code captured before any pipe.pnpm --filter @objectstack/hono exec vitest run --maxWorkers=2: 9 files, 207 passed.pnpm --filter @objectstack/hono typecheck: exit 0 (tsc --noEmitand-p tsconfig.test.json; both edited test files are in that program by--listFiles).pnpm --filter @objectstack/plugin-hono-server exec vitest run --maxWorkers=2: 30 files, 386 passed.pnpm --filter @objectstack/http-conformance test: 14 files, 150 passed.hono-webhooks-redeliver.conformance.test.ts(itsnot JSONrow included),hono-approvals-act.conformance.test.ts(the formPOSTredeeming a live token throughcreateHonoApp) andhono-body-refusal.conformance.test.tsare unchanged and green: 3 files, 17 passed in a verbose run.pnpm --workspace-concurrency=2 --filter '@objectstack/hono...' --filter '@objectstack/plugin-hono-server...' --filter '@objectstack/http-conformance...' build, exit 0.Pins:
hono-body-refusal.test.ts(43 cases). The parity case drives BOTH doors over one 21-row table and holds status and body equal: for a refusal the samecodeandmessage, for a read body the same value. Rows: valid, truncated,{ not json, another case spelling, blank and whitespace-only JSON; a urlencoded form, a malformed urlencoded form (read leniently by Hono's parser on both doors, so both answer 200 with the same fields), a multipart form, and a multipart form that does not parse; step 4 astext/plainand astext/plain;charset=UTF-8, plain text, a body with noContent-Type,application/octet-stream, a+jsonsuffix type, a media type that is not a token pair, and a body framed byTransfer-Encoding; and three no-body rows. New reader rows: step 4, plain text,octet-stream, noContent-Type, the/auth/*fallback and a malformed multipart form, each refused 400 though the domain answered its own 500. A new forwarder row: atext/plaingarbage body reaches the forwarder, which answers its own refusal with every byte read. The CONTROL form row now expects the parsed fields.hono-catch-all-raw-request.test.ts: as hono: createHonoApp still hands dispatch() {} for a non-JSON body that does not declare JSON, and parses a JSON body sent as text/plain; the two transports answer #23914's step 4 differently #24042 mandates, the rowsJSON sent as text/plain(:101),invalid JSON that does not declare JSON(:106) andplain text(:109) move to the new answers, and so doesa form body(:108), whose answer the form row of the rule changes. They move out of the table (which compares against the old reader line) into a new describe, each sent withContent-Length: the three are handed on as a body whose read throwsstatus 400/INVALID_REQUEST, a no-Content-Typerow is added beside them, and the form is handed on parsed ({ token: 'def', x: '1' }); every one still assertsbodyUsed === falseand the exact bytes on the forwarded request. One more row pins the form cache path. Every other row of the file is unchanged and green.Red at base, green at head, and one ablation per row group
Each leg through
node scripts/ablation-replace.mjsin WRAP mode (anchor hit once, the write proven on disk by the anchor count and the blob, the restore proven: blob equal toHEAD'sa4b335ae969d,git diff HEADempty). The suite imports./indexand resolves@objectstack/plugin-hono-serverto its source (vitest.config.ts), so no rebuild sits between a mutation and its run. Run:pnpm --filter @objectstack/hono exec vitest run --maxWorkers=2 src/hono-body-refusal.test.ts src/hono-catch-all-raw-request.test.ts(63 cases).readDispatchBodyreplaced by the base bodya4b335ae969dtod060fe2070b0a4b335ae969dto9858d8bde495{}againa4b335ae969dtoae5e64d9423ca4b335ae969d17 plus 8 is the 25 that are red at base.
Gates
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackonf0c5d3bdc(no paths, merge baseb1d5569d9): 64 commands. In the first sweep, over the closure build, 63 exited 0 andpnpm check:dual-build-cjs-loadsexited 3, PREREQUISITE NOT MET (it reads every package'sdist/). Afterpnpm turbo run build --filter='!@objectstack/docs' --concurrency=2(exit 0, 75 tasks) it exited 0: "110 published require entry point(s) across 68 package(s) load". Reconciliation with that reading: "64 derived, 64 run, 0 NOT-MEASURED, 0 UNRUN".pnpm exec eslint --no-inline-configover the three changed.tsfiles: exit 0, no findings. The repo-wide run is CI'sLint & Repo Gates.@objectstack/hono's builtdist/index.js(CJS,require("hono/utils/body")) anddist/index.mjsboth load and exportcreateHonoApp.Acceptance notes
bodywas{}here and is its parsed fields now, as onplugin-hono-server: on the data domain a form query is400 VALIDATION_FAILEDwhere it read every row, a form create writes the form's fields where it wrote an empty row, and a formPATCHapplies where it changed nothing (table above). The rule is the seat's; no first-party caller posts such a form.text/plain, like the two form types, is a media type a browser sends cross-origin without a preflight, and a JSON body sent that way is no longer honoured here.6226688716, supersedes6226633450). The pending.changeset/23914-hono-body-refusal.mdbullet "A body that does not declare JSON is read as before: parsed when it is JSON,{}when it is not." is superseded by this PR's changeset. It is left in place because the finding: random changeset filenames collide silently across parallel agents — a round overwrote a sibling PR'sminorchangeset and every gate stayed green #17712 guard gates any edit of another PR's pending changeset on a person's confirmation.carrier:the release owner, atchangeset versiontime: drop that bullet from the compiled@objectstack/honoentry.Content-Lengthother than0orTransfer-Encoding. An in-processRequestbuilt without either reads as no body on both doors, and so would a runtime that delivered a body with neither header. Carrier: none.NOT_EMPTY,namedMediaType,malformedFormMessage,unreadMediaTypeMessageanddeclaresBodyare mirrored module-private, beside the two messages PR fix(plugin-hono-server,hono,cloud-connection)!: refuse an unparseable request body with 400 INVALID_REQUEST instead of reading it as {} #24005 mirrored; the parity table holds the two doors' answers equal for every row./auth/*arm. The only in-repo reader on that arm, the legacy auth domain, never reads its_body, so no answer changes there today.origin/main.git merge-treeagainstorigin/mainb6805ac0fis clean; of the five incoming commits, one touchespackages/runtime/src/domains(the read-only refusal onPOST /packages) and none touches this reader or its tests.test:repo(CI'sTest Core).Contract review
Clause-②: no (narrowing): the seat serves theCONTRACT_REVIEW_TIERreview before the queue. One changeset,.changeset/24042-hono-undeclared-body-refusal.md,minorfor@objectstack/honounder the launch-window convention for breaking changes, with theClause-②line, the adr-0087 HTML-comment markernot-required (no-migration-prescription)with the other categories closed on facts (as #23914's carry), FROM / TO per row, and the integrator's fix: sendContent-Type: application/json.Generated by Claude Code