Repository navigation
fix(qa): the reference IHttpServer adapter refuses, parses or reads req.body as the IHttpRequest.body contract says - #24242
Conversation
…pRequest.body contract says
Declared JSON that does not parse, and a non-empty body in a media type
neither adapter reads, are now refused from the read with 400
INVALID_REQUEST, and a multipart form is parsed into fields, mirroring the
standard transport's readRequestBody decision instead of inventing {}.
Claude-Session: https://claude.ai/code/session_01BVm1pAXVXQ3PjC42j1kPFf
Co-Authored-By: Claude <noreply@anthropic.com>
… fields and controls Claude-Session: https://claude.ai/code/session_01BVm1pAXVXQ3PjC42j1kPFf Co-Authored-By: Claude <noreply@anthropic.com>
…nformance-adapter-body
📓 Docs Drift CheckThis PR changes 1 package(s): 8 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 — 0 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 8429de97e523cb814b2acccd137e5a440588d1fb && git checkout 8429de97e523cb814b2acccd137e5a440588d1fb
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 7be3cc7280b9625af914b7b4a978396368f0ee92 6544c545a43182b45f413816cfb937676753f940 && git checkout -B drift-repro 7be3cc7280b9625af914b7b4a978396368f0ee92 && git merge --no-ff 6544c545a43182b45f413816cfb937676753f940
node scripts/docs-audit/affected-docs.mjs --json 7be3cc7280b9625af914b7b4a978396368f0ee92
|
…nformance-adapter-body
… with the refusal, as the standard transport does The fallback branch now drops what a fallback writes after reading a refused body and answers 400 INVALID_REQUEST on every arm (thrown, caught and answered, caught and silent), as plugin-hono-server's fallback does through the same runHandler as its routes. The README no longer lists multipart parsing as deliberately absent. Claude-Session: https://claude.ai/code/session_01BVm1pAXVXQ3PjC42j1kPFf Co-Authored-By: Claude <noreply@anthropic.com>
…apters, with the 404/405 and valid-body controls Claude-Session: https://claude.ai/code/session_01BVm1pAXVXQ3PjC42j1kPFf Co-Authored-By: Claude <noreply@anthropic.com>
席位核对 — ACCEPT ·
|
Fixes #24083
Clause-②: no
What changed
packages/qa/http-conformance/src/adapter.ts: the referenceNodeHttpServernow decidesreq.bodythe way the standard transport decides it (readRequestBodyinplugin-hono-server'ssrc/request-body.ts). Per the seat's ruling (MATCH) the decision is mirrored insideadapter.tsand not imported. The package keeps@objectstack/coreas its only runtime dependency, and no export was added anywhere.origin/main2bcd406a2){ not json)req.bodyis{}INVALID_REQUEST, thrown from the readtext/plainreq.bodyis the stringINVALID_REQUEST, thrown from the readText/Plain; charset=utf-8(case){}INVALID_REQUESTtext/plain""{}{}[]key collects an array, a file part is aFile{}INVALID_REQUESTa=1&a=2&b[]=3&b[]=4{"a":"2","b[]":"4"}{"a":"2","b[]":["3","4"]}INVALID_REQUEST: the refusal replaces the handler's answersetFallbackHandler) reads a refused body, then throws, answers its own, or writes nothingFallback handler failed, its own answer, or the 404/405INVALID_REQUESTon all three (patch round 1)rawBody()on a bodyreq.bodyrefusesI measured all 17 request shapes with one probe that drives both adapters over a real socket, and, in patch round 1, 12 fallback shapes with a second probe. After the change, every shape answers the same status and the same body bytes on both. The one difference is the
charset=utf-8parameter on the reference's JSONContent-Type. Every reference JSON answer already carried it before this change.How it works:
readRequestBodycopies the standard transport's arms and its four refusal messages word for word.{}, anything else that does not parse is refused.multipart/form-dataorapplication/x-www-form-urlencoded, is parsed by Node's own FetchResponse.formData(). This is the parser hono'sbufferToFormDatacalls. The field shape follows hono 4.13.12'sconvertFormDataToBodyDatawithall: false, dot: false, and its null prototype too.{}otherwise.@hono/node-serverhands them a request with no body.req.bodyis an accessor with a setter, as on the standard transport. A route that reads onlyrawBody()never meets the refusal.rawBody()now caches the read as one promise, so a body the decision already consumed hands back the same bytes, or the same read failure.req.bodymeets the refusal, anything the handler writes after it is dropped.writeBodyRefusalthen answers400 {"success":false,"error":{"code":"INVALID_REQUEST","message":...}}, the two keys the standard transport'sdeclaredEnvelopeForThrowrenders for this throw. In the fallback branch (patch round 1, seat ruling6244250024) it answers on every arm: the fallback threw, caught and answered, or caught and wrote nothing. This is what the standard transport's fallback answers, because it runs through the samerunHandleras its routes. Untouched: the generic catch arm, which readsstatusCodeonly and answers an{error}body (adapter.test.ts'smaps thrown errors with statusCodepins it), and the fallback's500 Fallback handler failedfor a throw that read no refused body (fallback-seam.conformance.test.tsG5-a pins it). Guarantee 4's docblock now names the one exception: a fallback that read a refused body.New
packages/qa/http-conformance/src/request-body.conformance.test.ts. Its route handlers readreq.bodyand run on BOTH adapters over a real socket. Per adapter, it covers:text/plain;[], a file part;rawBody()of a refused body;text/plainbody are each{}, and valid JSON is the document.Allow; one that reads a valid JSON body gets the document.A parity block then sends each of 16 rows to both adapters and asserts the same status and the same bytes, refusal messages included. A change to either side's wording therefore reds here.
packages/qa/http-conformance/README.md(patch round 1): the one line that listed multipart parsing as deliberately absent now says what the adapter does. JSON and forms (multipart included) are parsed intoreq.body, and every other media type stays raw behind the lazyreq.rawBody().PM mechanism assumptions, measured
bodyread, not before the handler, and a route that reads onlyrawBody()is unaffected.runHandlerhas a dedicatedrefusedBodyReadarm that answers the refusal whatever the handler made of the throw, a caught throw included (probe: a handler that caught it and wrote 500 still answered 400). Only the renderer,declaredEnvelopeForThrow, is shared.{ status: 400, code: 'INVALID_REQUEST' }answered500 {"error":"declared refusal"}. So the answer-side change above was required. It is the smallest change that reproduces the replacement, and it does not touch the generic catch arm.parseBody(). Node's built-inResponse.formData()matches every field shape the case reaches without a dependency: a single value, a repeated key (last wins), a[]key (array) and a file part (File).rawBody()reads the bytes of every body, refused ones included. The suite pins it for malformed JSON andtext/plain. MultipartrawBody()is the held-off hono cache question and is not asserted.adapter.test.tsis unchanged.fallback-seam.conformance.test.tsis unchanged too: its only 500 assert, G5-a, is a fallback that throws its own error on a body-less GET and never reads a refused body. These suites drive the reference and readbody, and all pass on6544c545a:adapter.test.ts(16)fallback-seam.conformance.test.ts(18)conformance.integration.test.ts(30)query-multiplicity.conformance.test.ts(8)response-observation.conformance.test.ts(14)Ablation (once, the card's acceptance 3)
node scripts/ablation-replace.mjs(WRAP mode, with anEXIT INT TERMtrap) restored the old armlet body: any = {}; try { body = text ? JSON.parse(text) : {}; } catch { body = {}; }in place of the malformed-JSON refusal. No build is involved: the suite imports./adapter.jsfrom source.8204c4fa1a35to9b952dea3e0e.pnpm --filter @objectstack/http-conformance exec vitest run --maxWorkers=2 src/request-body.conformance.test.ts, verify-lockVERDICT command-exit 1,Tests 2 failed | 22 passed (24). The two failures:... on the 'node' adapter > a malformed declared-JSON body is refused 400 INVALID_REQUEST at the read, never read as {}/echo · malformedJsonhonoleg stayed green. The parity row is cross-adapter by construction, so it reds whenever the reference drifts.8204c4fa1a35), andgit diff HEADis empty.9e5b8b1e2,VERDICT command-exit 0,Tests 24 passed (24).Patch round 1, once:
node scripts/ablation-replace.mjs --delete(WRAP mode, with anEXIT INT TERMtrap) removed the fallback branch's one refusal-answer line,if (refusedBodyRead) return this.writeBodyRefusal(nodeRes, refusedBodyRead);.d3cdbc3cfd7ato54509553a9f2.VERDICT command-exit 1,Tests 6 failed | 34 passed (40). The six failures:nodefallback legs: the refused-read-then-throw leg answered500 Fallback handler failed, and the caught-and-answered and caught-and-silent legs answered404 ENDPOINT_NOT_FOUND;/fallback/throw,/fallback/caught-answers,/fallback/caught-silent.honoleg stayed green, and so did both fallback controls.d3cdbc3cfd7a), andgit diff HEADis empty.6544c545a,Tests 190 passed (190), withrequest-body.conformance.test.tsat 40 of 40.Tests (final head
6544c545a, after mergingorigin/main7be3cc728)First rebuilt the closure under the verify lock:
pnpm turbo run build --filter='@objectstack/http-conformance^...' --concurrency=2,VERDICT command-exit 0, 36/36 tasks.pnpm --filter @objectstack/http-conformance exec vitest run --reporter=verbose: exit 0,Test Files 15 passed (15),Tests 190 passed (190).pnpm --filter @objectstack/http-conformance typecheck: exit 0. This and the line above ran joined by&&under oneVERDICT command-exit 0.tsc --noEmitpasses, and so doescheck:test-typecheck: 3 files / 27 errors / 10 signatures held, unchanged.test:repo, each exit captured on its own, all 0 (re-run in patch round 1, spec included, since its repo tests walkpackages/**):@objectstack/core@objectstack/objectql@objectstack/rest@objectstack/runtime@objectstack/types@objectstack/specGates (final head
6544c545a)pnpm exec eslint --no-inline-configon both changed.tsfiles: exit 0, no output.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived the same 52 commands as round 0. 51 exited 0.pnpm check:dual-build-cjs-loadsexited 3 (PREREQUISITE NOT MET): it reads every package'sdist/, and only this package's closure is built locally. NOT MEASURED locally; CI'sLint & Repo Gatesruns it and is green on6544c545a.dispatch-gates --ran:52 derived famil(ies) accounted for — 51 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3).scripts/pm/clause2-line.mjshas no CLI. Run bare it prints nothing and exits 0. ItsreadClause2Lineexport reads this body's line as{"kind":"declared","value":"no","arm":null}.node scripts/check-changeset-no-major.mjs --base origin/main --event(this body) on the final diff: exit 0, itsLEVEL AXISline for a declared clause-②no, with no direction arm. No narrowing face was read.Changeset
None.
@objectstack/http-conformanceis"private": trueand publishes nothing, which is the repo's private-package fast lane forskip-changeset. The package'sCHANGELOG.mdhas only ever carried dependency bumps, and the precedent test-only change6a53564b9shipped without one. Theskip-changesetlabel is applied after this PR is created.Acceptance notes
6244250024.6244250024lifted the hold-off for the answer path only.packages/spec/src/contracts/http-server.ts, thesetFallbackHandlersentence "A handler that writes no response leaves the adapter's standard unmatched-request answer in place" names no exception. Both adapters now answer a fallback that read a refused body and wrote nothing with400 INVALID_REQUEST, as theIHttpRequest.bodyparagraph says the standard transport does. The wording is the spec lane's; nothing here edits it.node:http: its bytes are already on the wire. The standard transport buffers the answer and still replaces it. The code documents this; no conformance case reaches it.req.body.use()middleware on the reference receives the real request, so a middleware that readsreq.bodymeets the refusal. The standard transport hands middleware an empty request. The middleware contract does not populatebody, and no case reaches it.Content-Type: application/json; charset=utf-8, and the standard transport's carryapplication/json. This predates the change and covers every reference JSON answer.rawBody()cache. The hold-off named by the card is not touched or asserted here.Generated by Claude Code