Skip to content

Commit 75ced06

Browse files
fix(plugin-hono-server,hono,cloud-connection)!: refuse an unparseable request body with 400 INVALID_REQUEST instead of reading it as {} (#24005)
Fixes #23914 Clause-②: no (narrowing) ## What A request body that an HTTP door cannot parse is now refused with `400 INVALID_REQUEST` and the ADR-0112 envelope. It is no longer handed to the handler as `{}`. `INVALID_REQUEST` is an existing `ERROR_CODE_LEDGER` code; no code or ledger row is added. The message names the failure and never echoes the body. | package | door | what changed | |---|---|---| | `@objectstack/plugin-hono-server` | `HonoHttpServer.runHandler` (every route registered through `IHttpServer`, plus the fallback seam): the `objectstack dev` / `os serve` door | `req.body` is an accessor. Reading a body the adapter could not parse throws the refusal, and the adapter answers it whatever the handler made of the throw | | `@objectstack/hono` | `createHonoApp`'s `readDispatchBody`, used by the `${prefix}/*` catch-all and the `/auth/*` legacy fallback | a body declared `application/json` that is not blank and does not parse is refused when the dispatched domain READS it, after the dispatcher's gates; a domain that forwards the request reads its own bytes and answers as before | | `@objectstack/cloud-connection` | its own Hono routes: `/bind/start`, `/bind/poll`, `/install`, and `marketplace/install-local` | same rule, through a module-private `readJsonRequestBody` | ## Measured at the door These are fresh `objectstack dev --seed-admin` showcase boots, base `2054a0888c` against `b9df5b1d3` (`origin/main` merged in; the same answers were read on `0660b5a16` before the merge). This door is `plugin-hono-server`, which is byte-identical at the current head `97d881a2e`. Every body was sent from a file with `curl --data-binary`. The admin session came from `POST /api/v1/auth/sign-in/email`. | request | base | head | |---|---|---| | step 1, `POST /data/showcase_account/query` `{"where": {"status": "churned"}}` | 200, 2 rows | 200, 2 rows | | step 2, the same with the closing brace missing | **200, 14 of 14 rows** | 400 `INVALID_REQUEST` "The request body could not be parsed as JSON (it was sent as application/json). …" | | step 3, `{ not json` | **200, 14 rows** | 400 `INVALID_REQUEST`, same message | | step 4, the valid where-body sent as `text/plain` | **200, 14 rows** | 400 `INVALID_REQUEST` "The request body was sent as text/plain, which this server does not read into a request body. …" | | step 5, `PATCH /data/showcase_account/ID` `{"name": "HACKED"` | **200**, name unchanged, `updated_at` bumped | 400 `INVALID_REQUEST`; name and `updated_at` unchanged | | step 6, `POST /data/showcase_account` truncated, with `name` | 400 `VALIDATION_FAILED` "Account Name is required" | 400 `INVALID_REQUEST`, the parse refusal | | CONTROL: empty body, `application/json` | 200, 14 rows (empty query) | 200, 14 rows | | CONTROL: `POST` with no body and no `Content-Type` | 200, 14 rows | 200, 14 rows | | CONTROL: valid `PATCH` / valid create / empty-body create | 200 / 201 / 400 `VALIDATION_FAILED` | 200 / 201 / 400 `VALIDATION_FAILED` | | CONTROL: presign then raw `PUT` of a `.txt` file as `text/plain`, then complete | 200 / 200 / 200 | 200 / 200 / 200 | | CONTROL: presign then raw `PUT` of a JSON-with-comments file as `application/json`, then complete | 200 / 200 / 200 | 200 / 200 / 200 | | dispatcher-bridged doors (`0660b5a16`): `PUT /meta/object/showcase_account`, `POST /keys`, truncated | (not run on base) | 400 `INVALID_REQUEST`, the parse refusal | | `POST /cloud-connection/bind/poll` truncated `{"device_code": "dc_1"` / `{}` | (not run on base) | 400 the parse refusal / 400 "device_code is required" (unchanged) | | `POST /marketplace/install-local` truncated `{"packageId": "com.example.crm"` | (not run on base) | 400 `INVALID_REQUEST`, the parse refusal | On this boot the door is served by `plugin-hono-server`. `createHonoApp` (`@objectstack/hono`) is the embed door, and nothing in `packages/cli` mounts it. Both transports carried the swallow, so both change, as triage asked. ## Why `plugin-hono-server` refuses when `req.body` is read, not before the handler runs The PM's mechanism assumption 3 ("who else relies on `{}`") was measured, and it moved the design. One transport serves JSON doors and byte doors, and only the route knows which one it is: - The storage presign answers `headers: { "content-type": "application/json" }` for a `.json` file, so the platform's own upload flow PUTs a JSON-with-comments file (a `tsconfig.json`, say) as declared JSON that does not parse. The raw-PUT route reads `req.rawBody()` and stores it with `200` (measured on both boots above). objectui's `UploadProvider` sends `Content-Type: mimeType` on that PUT. - The dogfood suites PUT raw bytes as `text/plain` (`attachments-authz-harness.ts`, `field-file-collection`, and others). Refusing before the handler would have turned both uploads into `400`s. Raised on read, the refusal reaches exactly the handlers that would otherwise have acted on an invented `{}`, and it stops them at the read, so nothing after it runs and nothing is written. Body presence for the media types the adapter does not read is judged by the RFC 9112 framing headers (`Content-Length` other than `0`, or `Transfer-Encoding`). Reading the bytes to find out would buffer an upload before storage's declared-length check refuses it. Reading `Request.body` cannot answer either, because `@hono/node-server` gives every non-GET/HEAD request a stream. ## One rule on both transports: the reader refuses (seat ruling B) `createHonoApp` follows the same rule. Its body is a `dispatch()` argument rather than a `req` property, so for a declared-JSON body that does not parse, `readDispatchBody` hands `dispatch()` a body value whose every read (a property, `in`, its keys, a spread, `JSON.stringify`) throws `RequestBodyRefusal` and records that it was read. Each arm (the `${prefix}/*` catch-all and the `/auth/*` legacy fallback) then answers that refusal if, and only if, the body was read, whatever the domain answered. This is the same override `plugin-hono-server` applies after its `req.body` getter. There is no new export and no list of routes: whether a domain reads the body is answered by the domain reading it. - `dispatch()` itself never reads `body`. Its liveness short-circuit, scope resolution, the ADR-0069 auth gate and project membership read the path and headers, and `body` goes straight to the domain's handler. So the gates still answer first, as they do ahead of a route handler on `plugin-hono-server`. - A domain that forwards the request reads its own bytes and never touches `body`: `/webhooks/redeliver`, the inbound automation hooks, and the `/approvals/act` form `POST`. It answers exactly as at base, a malformed body included, after its own sign-in or signature check. `hono-webhooks-redeliver.conformance.test.ts`'s `not JSON` row, which holds the hosted door byte-equal to the self-hosted mount, stays as pinned and is green. `b9df5b1d3` refused before `dispatch()` instead. That reddened exactly that row: the hosted door answered this adapter's message where the mount answers the member's `"Request body must be JSON."`. It also answered an anonymous caller `400` where the mount says `401`. The seat's contract review ruled B. ## Step 4 (`text/plain`): refuse, not parse `content/docs/api/wire-format.mdx` documents the contract: "All requests and responses use `application/json`", and `Content-Type: application/json` is a required request header. Parsing `text/plain` as JSON would be a lenient second dialect. On `plugin-hono-server` it would also mean reading every text upload eagerly. So step 4 is refused there. `createHonoApp` was already content-type-blind (it parses a `text/plain` body as JSON when it parses), and that is unchanged. See Acceptance notes. ## Which doors treat an empty body as `{}`, legitimately All three keep it. A blank or whitespace-only body declared as JSON, and a request with no body whatever it declares, are still `{}`. The REST query handler documents the reliance ("An empty request body is not `null`: the HTTP adapter hands it over as `{}`, an empty query"). Bodiless action `POST`s rely on it too (`/bind/start`, `/automation/NAME/trigger`). The empty-body controls above and in every suite pin it. ## Sibling sweep (lane `domain:cli`) The sweep covered `.json().catch(`, `catch {}` around a body parse, and `return {}` from a body reader, over `packages/{cli,runtime,verify,types,rest,mcp,observability,client*,cloud-connection,create-objectstack,adapters/*,qa}` plus `plugin-hono-server` and `plugin-dev`. In scope (a door that takes a client body), changed here: - `plugin-hono-server/src/adapter.ts` `runHandler`: the `c.req.json()` / `parseBody()` pair. - `adapters/hono/src/index.ts` `readDispatchBody`: both `{}` fallbacks, which serve both arms. - `cloud-connection/src/cloud-connection-plugin.ts`: `/bind/start`, `/bind/poll`, `/install`. - `cloud-connection/src/marketplace-install-local-plugin.ts`: `handleInstall`. Out, with the reason: - `cloud-connection-plugin.ts` `tokResp.json().catch`, `bindResp.json().catch` and the install relay's `resp.json().catch`: these read the cloud control plane's answers, a body the platform wrote itself. - `packages/qa/http-conformance/src/adapter.ts` `NodeHttpServer` (`catch { body = {} }`): `private: true`, ships nothing, and serves only the conformance suite's own requests. It is a reference harness, not a deployed door (Acceptance notes). - `packages/qa/http-conformance` itself (this lane) gains `src/hono-body-refusal.conformance.test.ts`, which pins ruling B against the real dispatcher (Tests). - `runtime/src/domains/meta.ts:1314`: a comment, not a body reader. It describes the catch-all's pre-#22576 line. Its point, that falsy JSON values arrive falsy, still holds, so `meta.ts` is untouched. Not in this lane, so not changed here: the same swallow in `plugin-auth`, measured at the door. Truncated `POST /api/v1/auth/admin/unlock-user` `{"userId": "usr_nobody"` answers `400 "userId is required"`. That is reported to the seat. `plugin-webhooks` already refuses ("Request body must be JSON."). `metadata`'s HMR reload reads a body the CLI's own watch loop posts. ## Tests All runs are under `scripts/pm/os-verify-lock.sh`, with each exit code captured before any pipe. The current head is `97d881a2e`: `b9df5b1d3` (which merges `origin/main` at `adce19297`) plus ruling B in `@objectstack/hono`, its pins, and the corrected `hono` changeset. `plugin-hono-server` and `cloud-connection` are byte-identical to `b9df5b1d3`, so their runs there stand. - Ruling B on `0cee7c3d9` (the code of `97d881a2e`, whose last commit only rewrites the `hono` changeset): - `pnpm --filter @objectstack/hono exec vitest run --maxWorkers=2`: 9 files, 180 passed. - `pnpm --filter @objectstack/http-conformance exec vitest run --maxWorkers=2`: 14 files, 150 passed, including the redeliver row as pinned. - `pnpm --filter @objectstack/hono typecheck` and `pnpm --filter @objectstack/http-conformance typecheck`: exit 0. - `@objectstack/http-conformance` at the merge base `adce19297` (a detached compare worktree, closure built there): 13 files, 144 passed. At `b9df5b1d3` it was 1 failed, 143 passed: `hono-webhooks-redeliver.conformance.test.ts` line 273, row `not JSON`. - `pnpm turbo run build --filter='!@objectstack/docs' --concurrency=2` on `97d881a2e`: exit 0, 75 tasks. - After the merge (AGENTS.md §9/§10): `pnpm install --frozen-lockfile` exit 0; `pnpm turbo run build --filter='!@objectstack/docs' --concurrency=2` exit 0, 75 tasks; `pnpm --filter @objectstack/spec check:generated` exit 0. - `pnpm --filter @objectstack/plugin-hono-server typecheck` on `b9df5b1d3`: exit 0, including `check:test-typecheck` OK. `@objectstack/hono` exit 0 and `@objectstack/cloud-connection` exit 0, each `tsc --noEmit` plus `-p tsconfig.test.json`. - On `b9df5b1d3`, `pnpm --filter @objectstack/plugin-hono-server exec vitest run --maxWorkers=2`: 30 files, 386 tests passed. - `pnpm --filter @objectstack/hono exec vitest run --maxWorkers=2`: 9 files, 176 passed. - `pnpm --filter @objectstack/cloud-connection exec vitest run --maxWorkers=2`: 46 files, 561 passed. New pins: - `plugin-hono-server/src/request-body-refusal.test.ts` (20 cases): - each card body refused, with the envelope parsed against `ApiErrorSchema` / `envelopeViolations`, the handler never acting, and no echo; - the handler's own catch arm answering 500 loses to the refusal; - the fallback seam both ways; - a real socket for step 4; - CONTROL: valid, empty, form, valid PATCH, and three byte doors stored byte for byte. - `adapters/hono/src/hono-body-refusal.test.ts` (18 cases), over a fixture dispatcher with a READER domain (reads `body` inside a `try` whose catch answers its own 500) and a FORWARDER (reads the forwarded request): - the card bodies refused 400 by the reader, though it answered 500; - a read that escapes `dispatch()`; - the `/auth/*` fallback, `DELETE` included; - the forwarder answering its own refusal with every byte; - CONTROL rows; - parity with `HonoHttpServer`. - `packages/qa/http-conformance/src/hono-body-refusal.conformance.test.ts` (6 cases), on the real dispatcher and data domain through `createHonoApp`: - steps 2, 3, 5 and 6 refused, with the store read back unchanged; - byte parity with a `HonoHttpServer` route; - order: an active ADR-0069 gate whose session read fails answers 503, not the body's 400; - CONTROL: a valid query filters, and an empty body is the empty query. - `hono-catch-all-raw-request.test.ts` gains the case that a declared-JSON body that does not parse still reaches a forwarding domain unread, byte for byte. - `cloud-connection`: four refusal cases plus an empty-body CONTROL in `error-envelope.conformance.test.ts`. In `marketplace-install-local-install-modes.test.ts`, a refusal with zero writes (no register, no sync, no import, no ledger entry), plus a CONTROL. - Fixture triage: `hono-catch-all-raw-request.test.ts`'s `['invalid JSON', 'application/json', '{ not json', {}]` row pinned the branch this removes, so it is replaced. The refusal moved to the new file, and the row now pins the undeclared spelling, which is unchanged. ## Ablation (one per package; every suite resolves its subject from source, so no rebuild) Each leg ran through `node scripts/ablation-replace.mjs` in WRAP mode, with the anchor hitting once, the write proven on disk, and the restore proven. | leg | mutation | blob | result | |---|---|---|---| | plugin-hono-server | the accessor's refusal branch becomes `return {}` | `a331a0f6ed81` to `dee7fcca5045` | 10 failed: every refusal case, the fallback-reads case, socket step 4. 10 passed: every CONTROL | | hono, swallow (on `0cee7c3d9`) | `refusedOnRead(…)` becomes `readable({})` | `fb199e0ebefb` to `9c5714c12488` | `hono`: 10 failed (every reader refusal, the escaping read, `/auth/*`, parity), 8 passed. http-conformance: 4 failed (steps 2, 3, 5 and 6, parity), 9 passed (the order case, CONTROL, and every redeliver row) | | hono, refuse before `dispatch()` (the `b9df5b1d3` shape, on `0cee7c3d9`) | `refusedOnRead(…)` becomes an immediate throw | `fb199e0ebefb` to `4f27b2629aad` | `hono`: 7 failed (the reader-ownership cases, `/auth/*`, the forwarder), 11 passed. http-conformance: 2 failed (the redeliver byte-parity row and the gate-order case), 11 passed | | cloud-connection (on `2d952cae1`, the file unchanged since) | the `ok: false` parse refusal becomes `{ ok: true, body: {} }` | `61561f4f3e17` to `7c27524a66d0` | 4 failed: the four refusals. 41 passed, including both empty-body CONTROLs | The plugin-hono-server leg ran on `0660b5a16`, and its file is byte-identical at the current head. Restore proof after each run: `git diff HEAD` is 0 bytes and `git status --porcelain` is 0 lines. Each file's `git hash-object` equals its `HEAD:` blob (`a331a0f6ed81…`, `fb199e0ebefb…`, `61561f4f3e17…`), and the marker count is 0. ## Gates - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on `97d881a2e` (no paths, merge base `adce19297`): 66 families. All 66 ran and every one exited 0, including `check:route-envelope`, `check:dispatcher-error-vocabulary`, `check:error-status-conformance`, `check:nul-bytes`, `check:published-files`, `check:dts-closure`, `check:test-source-alias`, `check:cross-package-test-inputs`, `check:adr-0087-registration`, `check:empty-changeset` and `check-changeset-no-major`. `--ran` reconciliation with `COMMAND :: exit CODE` records: "66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN". - The derivation flags one stale input: `scripts/check-route-envelope.mjs` changed on `main` after the merge base (`d132140eb`, #23050 slice 10). That change only moves declared counts from `rest-server.ts` to the new `ui-routes.ts`, and this diff touches neither. The branch is merge-clean against `origin/main`, so it is not merged (`git merge-tree` exit 0). - `pnpm --filter @objectstack/http-conformance test` (the suite that went red in CI's `Test Core (3/6)` on `b9df5b1d3`): base `adce19297` 13 files, 144 passed; head 14 files, 150 passed. - `pnpm exec eslint --no-inline-config` over the 12 changed `.ts` files: exit 0, no findings. The repo-wide run is CI's `Lint & Repo Gates`. - On `0660b5a16`, the first sweep reddened `check:route-envelope`: the new `cloud-connection` helper built a response, so the gate wanted a `PLUGIN_ROUTE_MODULES` row. That script is outside this card's file surface, so the helper now answers a message and each declared route module builds its own `400` body. ## Acceptance notes - **`createHonoApp` reads every content type as JSON.** This is pre-existing and unchanged. A JSON body sent as `text/plain` is parsed (step 4 is honoured there, not refused), and a non-JSON body that does not declare JSON is `{}`. The reader-refuses mechanism could carry that input too, but it would narrow a second input and is not this card's. Carrier: none. - **Three copies of one message.** The refusal text lives in `plugin-hono-server/src/request-body.ts`, `adapters/hono/src/index.ts` and `cloud-connection/src/request-json-body.ts`, each module-private. A shared export would enlarge a package's public surface, which this card's `Clause-②: no (narrowing)` does not declare. The hono parity case holds the two transports' answers equal. - **`@objectstack/http-conformance`'s `NodeHttpServer`** keeps its `catch { body = {} }`. It is a private reference harness. The suites do drive a malformed body: `hono-webhooks-redeliver`'s `not JSON` row and the new `hono-body-refusal` file, both through the two Hono doors. Neither drives one through `NodeHttpServer`. (The v1 sentence said no suite drives a malformed body at all, which was false.) - **`runtime/src/domains/meta.ts:1314`'s comment** still quotes the catch-all's pre-#22576 line, `c.req.json().catch(() => ({}))`. Its subject (falsy JSON values arrive falsy) is still true. - **Ordering.** On both Hono transports the refusal comes from the body's reader, so every gate ahead of the reader answers first: the dispatcher's gates on `createHonoApp`, and a route's own checks on `plugin-hono-server`. On `cloud-connection`'s `/bind/start` and `/bind/poll`, the body is read before the session check, as it always was. Neither refusal discloses anything. ## Contract review `Clause-②: no (narrowing)`: the seat serves the `CONTRACT_REVIEW_TIER` review before the queue. Three changesets (`.changeset/23914-*.md`) are `minor` under the launch-window convention for breaking changes. Each carries the `Clause-②` line, an ADR-0087 marker of kind `not-required (no-migration-prescription)` with the other categories closed on facts, and the FROM / TO for an integrator. --- _Generated by [Claude Code](https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 2c702fd commit 75ced06

15 files changed

Lines changed: 1276 additions & 51 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@objectstack/cloud-connection': minor
3+
---
4+
5+
fix(cloud-connection)!: the cloud-connection and install-local routes refuse a declared JSON body that does not parse with `400 INVALID_REQUEST`, instead of answering as if no body was sent
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, no stored row is rewritten or deleted, and no exported declaration is added, removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is the accept set of four of this package's own routes for one input: a body declared as JSON that does not parse, which used to be read as no body. The remedy is in the requests a client sends, never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
10+
11+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
`POST /api/v1/cloud-connection/bind/start`, `/bind/poll`, `/install` and `POST /api/v1/marketplace/install-local` read their bodies as `try { body = await c.req.json() } catch { body = {} }`, so a truncated body was answered as if the client had sent none: `/bind/poll` answered `400 "device_code is required"` for a body that carried `device_code` and lacked a closing brace.
14+
15+
What changes for an integrator. A body sent as `application/json` (matched case-insensitively) that is not blank and does not parse: FROM the route's answer for an empty body, TO `400` with `{ "success": false, "error": { "code": "INVALID_REQUEST", "message": "The request body could not be parsed as JSON (it was sent as application/json). It was refused rather than read as an empty body: send a well-formed JSON document, or no body at all." } }`. Install-local refuses it after its sign-in and capability checks and before anything is fetched, registered or written.
16+
17+
**Remedy, in one line.** Send a JSON body as well-formed JSON with `Content-Type: application/json`, or send no body.
18+
19+
**Unchanged.** An empty or whitespace-only JSON body is still no body, and every route answers it as before (for `/bind/poll`, `400 "device_code is required"`). A body that parses, and a body that does not declare JSON, are read as before.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@objectstack/hono': minor
3+
---
4+
5+
fix(hono)!: `createHonoApp` refuses a declared JSON body that does not parse with `400 INVALID_REQUEST` when the dispatched domain reads it, instead of dispatching it as `{}`
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, no stored row is rewritten or deleted, and no exported declaration is added, removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is the accept set of the `createHonoApp` catch-all and its `/auth/*` fallback for one input: a body declared as JSON that does not parse, which used to be dispatched as `{}` and is now refused when a domain reads it. The remedy is in the requests a client sends, never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
10+
11+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
The `${prefix}/*` catch-all handed `dispatch()` `{}` for a body that did not parse, and the data domain read that as an empty query or an empty update: a truncated `POST /data/OBJECT/query` answered every row the caller could read, and a truncated `PATCH` answered `200` for an update that changed nothing.
14+
15+
What changes for an integrator, on a host that serves `createHonoApp`:
16+
17+
- A `POST`, `PUT` or `PATCH` body sent as `application/json` (matched case-insensitively) that is not blank and does not parse, to a domain that reads the parsed body (the data, metadata and every other domain that takes `body`): FROM that domain's answer for `{}`, TO `400` with `{ "success": false, "error": { "code": "INVALID_REQUEST", "message": "The request body could not be parsed as JSON (it was sent as application/json). It was refused rather than read as an empty body: send a well-formed JSON document, or no body at all." } }`. The domain acts on nothing past its read of the body, and the dispatcher's own gates still answer first. This is the same status and body `@objectstack/plugin-hono-server` answers for the same request.
18+
- The same applies to the `/auth/*` legacy fallback that runs when no auth service is registered, for every method it hands a body (all but `GET` and `HEAD`).
19+
20+
**Remedy, in one line.** Send a JSON body as well-formed JSON with `Content-Type: application/json`, or send no body.
21+
22+
**Unchanged.**
23+
24+
- An empty or whitespace-only JSON body is dispatched as `{}` (an empty query), as before.
25+
- A body that does not declare JSON is read as before: parsed when it is JSON, `{}` when it is not.
26+
- The request handed on beside the body still arrives unread, in every case. A domain that forwards it and reads its own bytes answers exactly as before, a malformed JSON body included: `/webhooks/redeliver` and the inbound automation hooks give their own refusal after their own sign-in or signature check, and the `/approvals/act` form `POST` reads its form.
27+
- The catch-all dispatches `GET`, `HEAD` and `DELETE` with no body, and the `/auth/*` fallback hands `GET` and `HEAD` `{}`, as before.
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/plugin-hono-server': minor
3+
---
4+
5+
fix(plugin-hono-server)!: a request body the adapter cannot parse is refused `400 INVALID_REQUEST` when a route handler reads it, never handed to the handler as `{}`
6+
7+
Clause-②: no (narrowing)
8+
9+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, no stored row is rewritten or deleted, and no exported declaration is added, removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is the accept set of the HTTP transport for request bodies it cannot parse: a malformed declared-JSON body, a malformed form, or a body in a media type it does not read, each of which used to reach the handler as `{}`. The remedy is in the requests a client sends, never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->
10+
11+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
12+
13+
The adapter used to build a route's `req.body` as `{}` and overwrite it only when a parse succeeded, so a body it could not parse reached the handler as an empty object. Measured on a fresh `objectstack dev` showcase boot: `POST /api/v1/data/showcase_account/query` with its closing brace missing, with `{ not json`, or with a valid body sent as `text/plain` answered `200` with all 14 rows where the filter matched 2; a truncated `PATCH` answered `200`, changed no field and moved `updated_at`; and a truncated create answered `400 VALIDATION_FAILED` "Account Name is required", for a body that carried a name.
14+
15+
What changes for an integrator. Each refusal is `400` with `{ "success": false, "error": { "code": "INVALID_REQUEST", "message": "…" } }`, raised when the handler reads `req.body`, so the handler acts on nothing past that read and nothing is written. The message names the failure and never echoes the body.
16+
17+
- A body sent as `application/json` (matched case-insensitively) that is not blank and does not parse: FROM the handler reading `{}`, TO the refusal "The request body could not be parsed as JSON (it was sent as application/json). It was refused rather than read as an empty body: send a well-formed JSON document, or no body at all."
18+
- A body in a media type the adapter does not parse (`text/plain`, `application/octet-stream`, any other type, or no `Content-Type` at all) sent to a route that reads `req.body`: FROM `{}`, TO the refusal "The request body was sent as text/plain, which this server does not read into a request body. It was refused rather than read as an empty body: send it as application/json."
19+
- A `multipart/form-data` or `application/x-www-form-urlencoded` body that does not parse: FROM `{}`, TO the refusal naming form data.
20+
21+
**Remedy, in one line.** Send a JSON body as well-formed JSON with `Content-Type: application/json`, or send no body.
22+
23+
**Unchanged.**
24+
25+
- An empty or whitespace-only JSON body is still `{}`, which the REST data doors read as an empty query; a request with no body is still `{}`, whatever it declares.
26+
- Well-formed JSON and well-formed forms reach the handler parsed, as before.
27+
- A route that reads `req.rawBody()` and never `req.body` receives any media type byte for byte, as before: the storage raw upload stores a JSON-with-comments file sent as `application/json` (the type its presign tells the client to send for a `.json` file) and a text file sent as `text/plain`.
28+
- A route, or the unmatched-request fallback, that never reads `req.body` answers as before.
29+
- A body is judged present by its framing headers (`Content-Length` other than `0`, or `Transfer-Encoding`), as RFC 9112 defines it; nothing is read to find out.
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#23914] `createHonoApp` refuses a declared JSON body that does not parse
5+
* with `400 INVALID_REQUEST` when a dispatched domain READS the body, and only
6+
* then — the rule `@objectstack/plugin-hono-server` applies to `req.body` — and
7+
* answers it with the same status and body as that package.
8+
*
9+
* The catch-all used to hand `dispatch()` `{}` for such a body, which the data
10+
* domain read as an empty query (every row) or an empty update (a `200` for a
11+
* write that changed nothing).
12+
*
13+
* The dispatcher is a fixture (this package's config aliases
14+
* `@objectstack/runtime` to a stub), standing in for the two kinds of domain:
15+
* a READER (`/data/*`, `/meta/*`), which reads `body` inside a `try` whose
16+
* catch answers its own `500`, so a pin that passes proves the adapter's
17+
* answer won; and a FORWARDER (`/webhooks/redeliver`), which reads its own
18+
* bytes from the forwarded request and never touches `body`, as the real
19+
* webhooks, inbound-hook and `/approvals/act` domains do. The real dispatcher
20+
* and the real domains are driven in
21+
* `packages/qa/http-conformance/src/hono-body-refusal.conformance.test.ts`.
22+
* `@objectstack/plugin-hono-server` resolves to its source here
23+
* (`vitest.config.ts`), so the parity case drives that package's real request
24+
* path, not a copy of it.
25+
*/
26+
27+
import { describe, it, expect, vi, beforeEach } from 'vitest';
28+
import { HonoHttpServer } from '@objectstack/plugin-hono-server';
29+
30+
/** What each domain did: the body it read, or that it read the forwarded request. */
31+
const seen: Array<{ path: string; read?: unknown; forwarded?: string }> = [];
32+
33+
/** A domain that reads the parsed body, and answers a throw out of that read with its own 500. */
34+
function readerDomain(path: string, body: unknown) {
35+
try {
36+
const read = JSON.parse(JSON.stringify(body ?? {}));
37+
seen.push({ path, read });
38+
return { handled: true, response: { status: 200, body: { success: true, data: read } } };
39+
} catch {
40+
return { handled: true, response: { status: 500, body: { success: false, error: { code: 'INTERNAL_ERROR', message: 'domain fault' } } } };
41+
}
42+
}
43+
44+
/** A domain that forwards the request and reads its own bytes, as `/webhooks/redeliver` does. */
45+
async function forwarderDomain(path: string, request: Request) {
46+
const text = await request.text();
47+
seen.push({ path, forwarded: text });
48+
try {
49+
JSON.parse(text);
50+
return { handled: true, response: { status: 200, body: { success: true, data: { ok: true } } } };
51+
} catch {
52+
return { handled: true, response: { status: 400, body: { success: false, error: { code: 'INVALID_REQUEST', message: 'Request body must be JSON.' } } } };
53+
}
54+
}
55+
56+
const mockDispatcher = {
57+
getDiscoveryInfo: vi.fn().mockReturnValue({ version: '1.0', routes: {} }),
58+
handleAuth: vi.fn(async (path: string, _method: string, body: unknown) => readerDomain(`/auth/${path}`, body)),
59+
dispatch: vi.fn(async (_method: string, path: string, body: unknown, _query: unknown, ctx: { request: Request }) => {
60+
if (path === '/webhooks/redeliver') return forwarderDomain(path, ctx.request);
61+
if (path === '/throws') {
62+
// A reader that lets the throw escape `dispatch()`.
63+
void (body as Record<string, unknown>).deliveryId;
64+
return { handled: true, response: { status: 200, body: { success: true } } };
65+
}
66+
return readerDomain(path, body);
67+
}),
68+
};
69+
70+
vi.mock('@objectstack/runtime', () => ({
71+
HttpDispatcher: function HttpDispatcher() { return mockDispatcher; },
72+
}));
73+
74+
import { createHonoApp } from './index';
75+
76+
const PREFIX = '/api/v1';
77+
const kernel = { name: 'test-kernel' } as any;
78+
79+
const send = (path: string, method: string, contentType: string | undefined, body: string) =>
80+
createHonoApp({ kernel, prefix: PREFIX, cors: false }).request(`http://hosted.test${PREFIX}${path}`, {
81+
method,
82+
headers: contentType ? { 'Content-Type': contentType } : {},
83+
body,
84+
});
85+
86+
/**
87+
* The declared refusal, read whole. Structural rather than `ApiErrorSchema`
88+
* (this package does not depend on `@objectstack/spec`): the parity case below
89+
* holds this body equal to `@objectstack/plugin-hono-server`'s, whose own suite
90+
* (`request-body-refusal.test.ts`) parses it against the declared envelope.
91+
*/
92+
async function expectRefused(res: Response, body: string) {
93+
const json = await res.json() as any;
94+
expect(res.status, JSON.stringify(json)).toBe(400);
95+
expect(json).toEqual({
96+
success: false,
97+
error: { code: 'INVALID_REQUEST', message: expect.stringMatching(/parsed as JSON/) },
98+
});
99+
expect(json.error.message).not.toContain(body.trim());
100+
return json;
101+
}
102+
103+
beforeEach(() => {
104+
seen.length = 0;
105+
mockDispatcher.dispatch.mockClear();
106+
mockDispatcher.handleAuth.mockClear();
107+
});
108+
109+
describe('[#23914] a domain that READS the body refuses the card\'s bodies, with this adapter\'s answer', () => {
110+
it.each([
111+
['step 2 — a truncated query', 'POST', '/data/showcase_account/query', '{"where": {"status": "churned"}'],
112+
['step 3 — `{ not json`', 'POST', '/data/showcase_account/query', '{ not json'],
113+
['step 5 — a truncated PATCH', 'PATCH', '/data/showcase_account/rec_1', '{"name": "HACKED"'],
114+
['step 6 — a truncated create', 'POST', '/data/showcase_account', '{"name": "Door Truncated Create"'],
115+
['a truncated PUT', 'PUT', '/meta/object/account', '{"name": "account"'],
116+
])('%s: refused 400, though the domain answered its own 500', async (_label, method, path, body) => {
117+
await expectRefused(await send(path, method, 'application/json', body), body);
118+
expect(mockDispatcher.dispatch).toHaveBeenCalledTimes(1);
119+
expect(seen).toEqual([]);
120+
});
121+
122+
it('a declared JSON media type is matched case-insensitively', async () => {
123+
await expectRefused(await send('/data/task/query', 'POST', 'Application/JSON; charset=utf-8', '{ not json'), '{ not json');
124+
});
125+
126+
it('a reader whose read escapes dispatch() is answered the refusal by the catch arm', async () => {
127+
await expectRefused(await send('/throws', 'POST', 'application/json', '{"deliveryId":'), '{"deliveryId":');
128+
});
129+
130+
it('the /auth/* legacy fallback refuses the same body the same way, DELETE included', async () => {
131+
await expectRefused(await send('/auth/sign-in/email', 'POST', 'application/json', '{"email": "a@b.c"'), '{"email": "a@b.c"');
132+
await expectRefused(await send('/auth/organization/delete', 'DELETE', 'application/json', '{"id": "org_1"'), '{"id": "org_1"');
133+
expect(mockDispatcher.handleAuth).toHaveBeenCalledTimes(2);
134+
});
135+
});
136+
137+
describe('[#23914] a domain that FORWARDS the request answers it itself, exactly as before', () => {
138+
it('the forwarder reads every byte the client sent and answers its own refusal; this adapter adds nothing', async () => {
139+
const body = '{deliveryId:';
140+
const res = await send('/webhooks/redeliver', 'POST', 'application/json', body);
141+
expect(res.status).toBe(400);
142+
expect(await res.json()).toEqual({ success: false, error: { code: 'INVALID_REQUEST', message: 'Request body must be JSON.' } });
143+
expect(seen).toEqual([{ path: '/webhooks/redeliver', forwarded: body }]);
144+
});
145+
146+
it('a well-formed body reaches the forwarder untouched', async () => {
147+
const body = '{ "deliveryId" : "d_1" }';
148+
expect((await send('/webhooks/redeliver', 'POST', 'application/json', body)).status).toBe(200);
149+
expect(seen).toEqual([{ path: '/webhooks/redeliver', forwarded: body }]);
150+
});
151+
});
152+
153+
describe('[#23914] CONTROL — what was readable is handed on as before', () => {
154+
it.each([
155+
['a valid JSON body', 'application/json', '{"where": {"status": "churned"}}', { where: { status: 'churned' } }],
156+
['an empty JSON body (an empty query)', 'application/json', '', {}],
157+
['a whitespace-only JSON body', 'application/json', ' \n', {}],
158+
['a form body', 'application/x-www-form-urlencoded', 'token=abc', {}],
159+
['a JSON body sent as text/plain (the content-type-blind read, unchanged)', 'text/plain', '{"a":1}', { a: 1 }],
160+
['text that does not declare JSON (unchanged)', 'text/plain', 'hello', {}],
161+
])('%s', async (_label, contentType, body, expected) => {
162+
const res = await send('/data/task/query', 'POST', contentType, body);
163+
expect(res.status).toBe(200);
164+
expect(seen).toEqual([{ path: '/data/task/query', read: expected }]);
165+
});
166+
});
167+
168+
describe('[#23914] both Hono doors answer one unparseable body alike', () => {
169+
it.each([
170+
['a truncated query', '{"where": {"status": "churned"}'],
171+
['`{ not json`', '{ not json'],
172+
])('%s: the same status and the same body', async (_label, body) => {
173+
const hosted = await send('/data/showcase_account/query', 'POST', 'application/json', body);
174+
175+
const server = new HonoHttpServer(0);
176+
server.post(`${PREFIX}/data/:object/query`, (req, res) => { res.json({ query: req.body }); });
177+
const direct = await server.getRawApp().fetch(new Request(`http://direct.test${PREFIX}/data/showcase_account/query`, {
178+
method: 'POST',
179+
headers: { 'content-type': 'application/json' },
180+
body,
181+
}));
182+
183+
expect(direct.status).toBe(hosted.status);
184+
expect(await direct.json()).toEqual(await expectRefused(hosted, body));
185+
});
186+
});

0 commit comments

Comments
 (0)