Skip to content

Commit 76d9dca

Browse files
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)
Fixes #24042 Clause-②: no (narrowing) ## What `createHonoApp` (`@objectstack/hono`) now reads a dispatched body by the rule `@objectstack/plugin-hono-server` applies (`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 in `src`: `readDispatchBody`, its docblock, and module-private copies of the twin's messages and of `declaresBody` beside `RequestBodyRefusal`. Nothing new is exported. | body (`POST` / `PUT` / `PATCH` on the catch-all; every method but `GET` / `HEAD` on the `/auth/*` fallback) | base | head | |---|---|---| | declares `application/json` (case-insensitive `includes`) | parsed; blank is `{}`; unparseable is refused on read | unchanged | | `multipart/form-data` or `application/x-www-form-urlencoded` | `{}` | parsed from a CLONE by Hono's `parseBody` (the function `c.req.parseBody()` runs on the twin); a form that does not parse is refused on read, with the twin's form message | | any other media type, or none, when the request declares a body (RFC 9112 section 6.3: `Content-Length` other than `0`, or `Transfer-Encoding`) | parsed as JSON when it parsed (step 4 honoured), `{}` otherwise | refused on read, with the twin's message ("The request body was sent as text/plain, which this server does not read into a request body. ..."), naming the media type only when it is a plain token pair | | no body declared | `{}` (or parsed) | `{}` | The JSON-sent-as-`text/plain` case (#23914's step 4) is the "other media type" row: refused, not parsed. `content/docs/api/wire-format.mdx` already names `Content-Type: application/json` as required (`:11`, `:594`); no sentence there becomes false, so it is untouched. ## Measured at the door: the real dispatcher and data domain through `createHonoApp` A temporary file in `packages/qa/http-conformance` (the `hono-body-refusal.conformance.test.ts` boot: a real `LiteKernel`, ObjectQL, sqlite-wasm, the real `HttpDispatcher` and `/data` domain), each body sent with `Content-Length` as a client sends it. The base column is the same file with the base reader put back through `scripts/ablation-replace.mjs` (blob `a4b335ae969d` to `d060fe2070b0`, restored, `git diff HEAD` empty). The file was deleted after both runs and is not in the diff. | request | base reader | head | |---|---|---| | step 4: `POST /data/task/query` `{"where": {"status": "churned"}}` as `text/plain` | 200, records (parsed and honoured) | 400 `INVALID_REQUEST`, "sent as text/plain" | | `POST /data/task/query` `hello` as `text/plain` | 200, records (`{}`, the empty query) | 400 `INVALID_REQUEST` | | `PATCH /data/task/ID` `{"name":"HACKED2"}` as `text/plain` | 200, the name changed | 400 `INVALID_REQUEST`, the row unchanged | | `POST /data/task/query` urlencoded `where=...` | 200, records (`{}`, the empty query) | 400 `VALIDATION_FAILED` (`query.where` is a string) | | `POST /data/task` urlencoded `name=from_form&status=active` | 201, a row with no name | 201, a row named `from_form` | | `PATCH /data/task/ID` urlencoded `name=HACKED` | 200, no field changed | 200, the name changed | The 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 `b1d5569d9` 1. **No first-party caller sends a JSON body without `application/json`, or a body with no `Content-Type`, to the hosted door.** Holds. - This repo has no production `createHonoApp` host: every importer is a test (`packages/adapters/hono`, `packages/qa/http-conformance`), docs or a comment. `packages/cli` mounts none. - `packages/client`: `this.fetch` defaults `Content-Type: application/json` (`index.ts:7450`); the one direct `fetchImpl` with a body is the presigned upload, which goes to the storage surface, not through `dispatch()` (the dispatcher has no `/storage` domain). - A windowed scan of 307 request-body sites in 1309 files of `examples/`, `packages/qa/dogfood`, `scripts/`, `packages/cli/src` and `packages/client/src`: every platform poster declares JSON; the rest are GitHub API calls, flow-node configs or response shapes. - The console at the pin `f8267df93aba` (`.objectui-sha`): `approvalsApi.call`, `CloudConnectionPanel.getJson`, `FormPage`'s `apiFetch`, `useRecordApprovals`'s `fetchJson`, `DatasourceResourcePage`'s `api`, plugin-chatbot's `call` and `serverActionHandler` all set `application/json`, and none of their body-carrying calls passes headers that would replace it. `ApiDataSource` sends `text/plain` only for a string body, and its three console instances are `GET` doors. No `sendBeacon`. `UploadProvider`'s XHR PUTs to the presigned raw door. `data-objectstack`'s `uploadFile` / `uploadFiles` post multipart to `/api/data/OBJECT/upload`, but nothing calls them at the pin and no server route serves that path. - NOT MEASURED: the cloud repository, the main `createHonoApp` host, is not in this container. 2. **Parsing a form from a clone leaves the forwarded request unread.** Holds: the raw-request form row asserts `bodyUsed === false` at dispatch and the exact bytes on the request; a new row pins the cache path (an upstream `c.req.parseBody()`, then the catch-all hands the parsed form from Hono's cache). 3. **What domains do with a form body now.** Measured on the data domain (table above). Every domain that reads `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 on `plugin-hono-server`. The forwarders (`/approvals/act`, `/webhooks/redeliver`, the inbound automation hooks) never read `body`, 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. 4. **`dispatch()` reads no body before its gates.** Holds. `dispatch()` holds `body` in 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 of `hono-webhooks-redeliver.conformance.test.ts` (deleted after): anonymous `text/plain` garbage, anonymous `application/octet-stream` and anonymous no-`Content-Type` bodies all answer `401 UNAUTHENTICATED` at both doors; signed-in `text/plain` and no-`Content-Type` garbage answer the member's `400 "Request body must be JSON."` at both; a signed-in valid JSON body sent as `text/plain` for an unknown delivery answers `404 RESOURCE_NOT_FOUND` at both. Status, headers and body bytes equal on every row. ## Tests All runs are under `scripts/pm/os-verify-lock.sh` on head `f0c5d3bdc`, 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 --noEmit` and `-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` (its `not JSON` row included), `hono-approvals-act.conformance.test.ts` (the form `POST` redeeming a live token through `createHonoApp`) and `hono-body-refusal.conformance.test.ts` are unchanged and green: 3 files, 17 passed in a verbose run. - Closure first: `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 same `code` and `message`, 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 as `text/plain` and as `text/plain;charset=UTF-8`, plain text, a body with no `Content-Type`, `application/octet-stream`, a `+json` suffix type, a media type that is not a token pair, and a body framed by `Transfer-Encoding`; and three no-body rows. New reader rows: step 4, plain text, `octet-stream`, no `Content-Type`, the `/auth/*` fallback and a malformed multipart form, each refused 400 though the domain answered its own 500. A new forwarder row: a `text/plain` garbage 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 #24042 mandates, the rows `JSON sent as text/plain` (`:101`), `invalid JSON that does not declare JSON` (`:106`) and `plain text` (`:109`) move to the new answers, and so does `a 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 with `Content-Length`: the three are handed on as a body whose read throws `status 400` / `INVALID_REQUEST`, a no-`Content-Type` row is added beside them, and the form is handed on parsed (`{ token: 'def', x: '1' }`); every one still asserts `bodyUsed === false` and 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.mjs` in WRAP mode (anchor hit once, the write proven on disk by the anchor count and the blob, the restore proven: blob equal to `HEAD`'s `a4b335ae969d`, `git diff HEAD` empty). The suite imports `./index` and resolves `@objectstack/plugin-hono-server` to 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). | leg | mutation | blob | result | |---|---|---|---| | base | `readDispatchBody` replaced by the base body | `a4b335ae969d` to `d060fe2070b0` | 25 failed (every new row), 38 passed | | ablation A | the "other media type" branch puts back the content-type-blind parse | `a4b335ae969d` to `9858d8bde495` | 17 failed: exactly the undeclared-body rows of both files; every form row green | | ablation B | the form branch hands `{}` again | `a4b335ae969d` to `ae5e64d9423c` | 8 failed: exactly the form rows (parity urlencoded, malformed urlencoded, multipart, malformed multipart; the CONTROL form row; the reader's malformed-form row; the raw-request form row and its cache row) | | head | none | `a4b335ae969d` | 63 passed | 17 plus 8 is the 25 that are red at base. ## Gates - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on `f0c5d3bdc` (no paths, merge base `b1d5569d9`): 64 commands. In the first sweep, over the closure build, 63 exited 0 and `pnpm check:dual-build-cjs-loads` exited 3, PREREQUISITE NOT MET (it reads every package's `dist/`). After `pnpm 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-config` over the three changed `.ts` files: exit 0, no findings. The repo-wide run is CI's `Lint & Repo Gates`. - Control-byte scan over the four changed files: no hits. - `@objectstack/hono`'s built `dist/index.js` (CJS, `require("hono/utils/body")`) and `dist/index.mjs` both load and export `createHonoApp`. ## Acceptance notes - **Form bodies now reach the domains on this door.** A form posted to a domain that reads `body` was `{}` here and is its parsed fields now, as on `plugin-hono-server`: on the data domain a form query is `400 VALIDATION_FAILED` where it read every row, a form create writes the form's fields where it wrote an empty row, and a form `PATCH` applies 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. - **Seat ruling B (`6226688716`, supersedes `6226633450`).** The pending `.changeset/23914-hono-body-refusal.md` bullet "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 #17712 guard gates any edit of another PR's pending changeset on a person's confirmation. `carrier:` the release owner, at `changeset version` time: drop that bullet from the compiled `@objectstack/hono` entry. - **Presence by framing headers.** As on the twin, a body is present only when the request declares `Content-Length` other than `0` or `Transfer-Encoding`. An in-process `Request` built without either reads as no body on both doors, and so would a runtime that delivered a body with neither header. Carrier: none. - **Message copies.** The twin's `NOT_EMPTY`, `namedMediaType`, `malformedFormMessage`, `unreadMediaTypeMessage` and `declaresBody` are mirrored module-private, beside the two messages PR #24005 mirrored; the parity table holds the two doors' answers equal for every row. - **The `/auth/*` arm.** The only in-repo reader on that arm, the legacy auth domain, never reads its `_body`, so no answer changes there today. - **Not merged with `origin/main`.** `git merge-tree` against `origin/main` `b6805ac0f` is clean; of the five incoming commits, one touches `packages/runtime/src/domains` (the read-only refusal on `POST /packages`) and none touches this reader or its tests. - **Not run locally:** `test:repo` (CI's `Test Core`). ## Contract review `Clause-②: no (narrowing)`: the seat serves the `CONTRACT_REVIEW_TIER` review before the queue. One changeset, `.changeset/24042-hono-undeclared-body-refusal.md`, `minor` for `@objectstack/hono` under the launch-window convention for breaking changes, with the `Clause-②` line, the adr-0087 HTML-comment marker `not-required (no-migration-prescription)` with the other categories closed on facts (as #23914's carry), FROM / TO per row, and the integrator's fix: send `Content-Type: application/json`. --- _Generated by [Claude Code](https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8a83ceb commit 76d9dca

4 files changed

Lines changed: 396 additions & 122 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
'@objectstack/hono': minor
3+
---
4+
5+
fix(hono)!: `createHonoApp` reads a dispatched body by `@objectstack/plugin-hono-server`'s rule, so a JSON body sent as `text/plain`, or any other body that declares neither JSON nor a form, is refused `400 INVALID_REQUEST` when the dispatched domain reads it, instead of being parsed or dispatched 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 bodies that declare neither JSON nor a form, which used to be parsed as JSON whatever they declared or dispatched as `{}`, and are now refused when a domain reads them. 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 and the `/auth/*` legacy fallback parsed every body as JSON whatever its `Content-Type`, and handed `dispatch()` `{}` for any body that did not parse. So the two Hono doors answered one request two ways: `@objectstack/plugin-hono-server` (`objectstack dev`, `os serve`) refused a valid query body sent as `text/plain`, and this door honoured it; a garbled body that did not declare JSON reached the data domain here as `{}`, the empty query, and read every row the caller could read.
14+
15+
What changes for an integrator, on a host that serves `createHonoApp`, for a `POST`, `PUT` or `PATCH` body read by a domain that reads the parsed body (the data, metadata and every other domain that takes `body`), and on the `/auth/*` legacy fallback for every method it hands a body (all but `GET` and `HEAD`):
16+
17+
- A JSON body sent as `text/plain`, or as any media type other than JSON or a form: FROM parsed and honoured, TO `400` with `{ "success": false, "error": { "code": "INVALID_REQUEST", "message": "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." } }`, the message naming the media type that was sent.
18+
- Any other body that declares neither JSON nor a form (`text/plain`, `application/octet-stream`, a `+json` suffix type such as `application/merge-patch+json`, or no `Content-Type` at all), when the request declares a body (`Content-Length` other than `0`, or `Transfer-Encoding`): FROM `{}`, TO the same refusal ("sent with no Content-Type" when there is none).
19+
- A `multipart/form-data` or `application/x-www-form-urlencoded` body: FROM `{}`, TO its parsed fields, as `plugin-hono-server` hands them (Hono's `parseBody`: the last value of a repeated name). A form that does not parse: TO the refusal "The request body could not be parsed as form data (it was sent as multipart/form-data). It was refused rather than read as an empty body."
20+
21+
The domain acts on nothing past its read of the body, and the dispatcher's own gates still answer first. Each answer is the status and body `@objectstack/plugin-hono-server` gives the same request. This replaces the earlier `createHonoApp` entry's statement that a body that does not declare JSON is read as before.
22+
23+
**Remedy, in one line.** Send a JSON body with `Content-Type: application/json`, or send no body.
24+
25+
**Unchanged.**
26+
27+
- A body sent as `application/json` (matched case-insensitively) is parsed when well-formed, `{}` when blank or whitespace-only, and refused when it does not parse, as before.
28+
- A request that declares no body is dispatched as `{}`, whatever its media type.
29+
- 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, whatever the body declares: `/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.
30+
- The catch-all dispatches `GET`, `HEAD` and `DELETE` with no body, and the `/auth/*` fallback hands `GET` and `HEAD` `{}`, as before.

‎packages/adapters/hono/src/hono-body-refusal.test.ts‎

Lines changed: 147 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,18 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
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.
4+
* [#23914, #24042] `createHonoApp` reads a dispatched body by
5+
* `@objectstack/plugin-hono-server`'s rule, media type by media type, and
6+
* refuses what it cannot read with `400 INVALID_REQUEST` when a dispatched
7+
* domain READS the body, and only then — the rule `plugin-hono-server` applies
8+
* to `req.body` — answering it with the same status and body as that package.
89
*
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).
10+
* The catch-all used to hand `dispatch()` `{}` for a declared JSON body that
11+
* did not parse (#23914), and, until #24042, it parsed every other body as
12+
* JSON whatever it declared: a JSON body sent as `text/plain` was honoured,
13+
* and any other body that did not declare JSON, a form included, was `{}`.
14+
* The data domain read `{}` as an empty query (every row) or an empty update
15+
* (a `200` for a write that changed nothing).
1216
*
1317
* The dispatcher is a fixture (this package's config aliases
1418
* `@objectstack/runtime` to a stub), standing in for the two kinds of domain:
@@ -22,6 +26,11 @@
2226
* `@objectstack/plugin-hono-server` resolves to its source here
2327
* (`vitest.config.ts`), so the parity case drives that package's real request
2428
* path, not a copy of it.
29+
*
30+
* In-process `Request`s carry no `Content-Length` of their own, and both doors
31+
* judge whether a body is present by its framing headers (RFC 9112 §6.3), so
32+
* a case that stands for a client's body sends `Content-Length` the way a
33+
* client does.
2534
*/
2635

2736
import { describe, it, expect, vi, beforeEach } from 'vitest';
@@ -74,29 +83,53 @@ vi.mock('@objectstack/runtime', () => ({
7483
import { createHonoApp } from './index';
7584

7685
const PREFIX = '/api/v1';
86+
const QUERY = '/data/showcase_account/query';
7787
const kernel = { name: 'test-kernel' } as any;
7888

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-
});
89+
/**
90+
* How a case frames its body. `length` sends `Content-Length` as a client
91+
* does; `chunked` sends `Transfer-Encoding` instead. `bytes` sends the body as
92+
* a `Uint8Array`: undici stamps `text/plain;charset=UTF-8` on any STRING body
93+
* that declares no type, so a request with no `Content-Type` at all can only
94+
* be built from bytes.
95+
*/
96+
interface Framing { length?: boolean; chunked?: boolean; bytes?: boolean }
97+
98+
function requestInit(method: string, contentType: string | undefined, body: string | undefined, framing: Framing = {}): RequestInit {
99+
const headers: Record<string, string> = {};
100+
if (contentType) headers['content-type'] = contentType;
101+
if (framing.length && body !== undefined) headers['content-length'] = String(Buffer.byteLength(body));
102+
if (framing.chunked) headers['transfer-encoding'] = 'chunked';
103+
const payload = framing.bytes && body !== undefined ? new TextEncoder().encode(body) : body;
104+
return { method, headers, body: payload };
105+
}
106+
107+
/** The hosted door: `createHonoApp`'s catch-all, in front of the fixture dispatcher. */
108+
const send = (path: string, method: string, contentType: string | undefined, body: string | undefined, framing?: Framing) =>
109+
createHonoApp({ kernel, prefix: PREFIX, cors: false })
110+
.request(`http://hosted.test${PREFIX}${path}`, requestInit(method, contentType, body, framing));
111+
112+
/** The self-hosted door: a `plugin-hono-server` route that reads `req.body` and answers what it read. */
113+
function sendDirect(method: string, contentType: string | undefined, body: string | undefined, framing?: Framing) {
114+
const server = new HonoHttpServer(0);
115+
server.post(`${PREFIX}/data/:object/query`, (req, res) => { res.json({ success: true, data: req.body }); });
116+
return server.getRawApp().fetch(new Request(`http://direct.test${PREFIX}${QUERY}`, requestInit(method, contentType, body, framing)));
117+
}
85118

86119
/**
87120
* The declared refusal, read whole. Structural rather than `ApiErrorSchema`
88121
* (this package does not depend on `@objectstack/spec`): the parity case below
89122
* holds this body equal to `@objectstack/plugin-hono-server`'s, whose own suite
90123
* (`request-body-refusal.test.ts`) parses it against the declared envelope.
91124
*/
92-
async function expectRefused(res: Response, body: string) {
125+
async function expectRefused(res: Response, body: string, names: RegExp = /parsed as JSON/) {
93126
const json = await res.json() as any;
94127
expect(res.status, JSON.stringify(json)).toBe(400);
95128
expect(json).toEqual({
96129
success: false,
97-
error: { code: 'INVALID_REQUEST', message: expect.stringMatching(/parsed as JSON/) },
130+
error: { code: 'INVALID_REQUEST', message: expect.stringMatching(names) },
98131
});
99-
expect(json.error.message).not.toContain(body.trim());
132+
if (body.trim() !== '') expect(json.error.message).not.toContain(body.trim());
100133
return json;
101134
}
102135

@@ -108,8 +141,8 @@ beforeEach(() => {
108141

109142
describe('[#23914] a domain that READS the body refuses the card\'s bodies, with this adapter\'s answer', () => {
110143
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'],
144+
['step 2 — a truncated query', 'POST', QUERY, '{"where": {"status": "churned"}'],
145+
['step 3 — `{ not json`', 'POST', QUERY, '{ not json'],
113146
['step 5 — a truncated PATCH', 'PATCH', '/data/showcase_account/rec_1', '{"name": "HACKED"'],
114147
['step 6 — a truncated create', 'POST', '/data/showcase_account', '{"name": "Door Truncated Create"'],
115148
['a truncated PUT', 'PUT', '/meta/object/account', '{"name": "account"'],
@@ -134,6 +167,38 @@ describe('[#23914] a domain that READS the body refuses the card\'s bodies, with
134167
});
135168
});
136169

170+
describe('[#24042] a body that declares neither JSON nor a form is refused by the domain that reads it', () => {
171+
it.each([
172+
['step 4 — a valid query body sent as text/plain', 'text/plain', '{"where": {"status": "churned"}}', /sent as text\/plain/],
173+
['text that does not declare JSON', 'text/plain', 'hello', /sent as text\/plain/],
174+
['bytes sent as application/octet-stream', 'application/octet-stream', 'raw bytes', /sent as application\/octet-stream/],
175+
])('%s: refused 400, though the domain answered its own 500', async (_label, contentType, body, names) => {
176+
await expectRefused(await send(QUERY, 'POST', contentType, body, { length: true }), body, names);
177+
expect(mockDispatcher.dispatch).toHaveBeenCalledTimes(1);
178+
expect(seen).toEqual([]);
179+
});
180+
181+
it('a body with no Content-Type at all is refused, not read as empty', async () => {
182+
const body = '{"where": {"status": "churned"}}';
183+
await expectRefused(await send(QUERY, 'POST', undefined, body, { length: true, bytes: true }), body, /with no Content-Type/);
184+
});
185+
186+
it('the /auth/* legacy fallback refuses it the same way', async () => {
187+
await expectRefused(
188+
await send('/auth/sign-in/email', 'POST', 'text/plain', '{"email": "a@b.c"}', { length: true }),
189+
'{"email": "a@b.c"}',
190+
/sent as text\/plain/,
191+
);
192+
expect(mockDispatcher.handleAuth).toHaveBeenCalledTimes(1);
193+
});
194+
195+
it('a form that does not parse is refused by its reader', async () => {
196+
const body = '--nope\r\nnot a part';
197+
await expectRefused(await send(QUERY, 'POST', 'multipart/form-data; boundary=elsewhere', body), body, /form data/);
198+
expect(seen).toEqual([]);
199+
});
200+
});
201+
137202
describe('[#23914] a domain that FORWARDS the request answers it itself, exactly as before', () => {
138203
it('the forwarder reads every byte the client sent and answers its own refusal; this adapter adds nothing', async () => {
139204
const body = '{deliveryId:';
@@ -143,44 +208,87 @@ describe('[#23914] a domain that FORWARDS the request answers it itself, exactly
143208
expect(seen).toEqual([{ path: '/webhooks/redeliver', forwarded: body }]);
144209
});
145210

211+
it('[#24042] so does a body that does not declare JSON: the forwarder\'s own answer, every byte read', async () => {
212+
const body = '{deliveryId:';
213+
const res = await send('/webhooks/redeliver', 'POST', 'text/plain', body, { length: true });
214+
expect(res.status).toBe(400);
215+
expect(await res.json()).toEqual({ success: false, error: { code: 'INVALID_REQUEST', message: 'Request body must be JSON.' } });
216+
expect(seen).toEqual([{ path: '/webhooks/redeliver', forwarded: body }]);
217+
});
218+
146219
it('a well-formed body reaches the forwarder untouched', async () => {
147220
const body = '{ "deliveryId" : "d_1" }';
148221
expect((await send('/webhooks/redeliver', 'POST', 'application/json', body)).status).toBe(200);
149222
expect(seen).toEqual([{ path: '/webhooks/redeliver', forwarded: body }]);
150223
});
151224
});
152225

153-
describe('[#23914] CONTROL — what was readable is handed on as before', () => {
226+
describe('[#23914, #24042] CONTROL — what is readable is handed on', () => {
154227
it.each([
155228
['a valid JSON body', 'application/json', '{"where": {"status": "churned"}}', { where: { status: 'churned' } }],
156229
['an empty JSON body (an empty query)', 'application/json', '', {}],
157230
['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', {}],
231+
['[#24042] a form body, parsed as plugin-hono-server parses it', 'application/x-www-form-urlencoded', 'token=abc', { token: 'abc' }],
232+
['[#24042] a request that declares no body, whatever its media type', 'text/plain', undefined, {}],
161233
])('%s', async (_label, contentType, body, expected) => {
162234
const res = await send('/data/task/query', 'POST', contentType, body);
163235
expect(res.status).toBe(200);
164236
expect(seen).toEqual([{ path: '/data/task/query', read: expected }]);
165237
});
166238
});
167239

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));
240+
/**
241+
* [#24042] One table, both doors. Each row is sent to the hosted door (the
242+
* catch-all, in front of the reading domain) and to the self-hosted door (a
243+
* `plugin-hono-server` route that reads `req.body`), and the two answers must
244+
* be equal: the same status, and the same body — for a refusal, the same
245+
* `code` and the same `message`; for a body that is read, the same value.
246+
*
247+
* [label, Content-Type, body, framing, the expected status]
248+
*/
249+
const PARITY: Array<[string, string | undefined, string | undefined, Framing, number]> = [
250+
// Declared JSON.
251+
['a valid JSON body', 'application/json', '{"where": {"status": "churned"}}', { length: true }, 200],
252+
['a truncated query (step 2)', 'application/json', '{"where": {"status": "churned"}', { length: true }, 400],
253+
['`{ not json` (step 3)', 'application/json', '{ not json', {}, 400],
254+
['declared JSON in another spelling', 'Application/JSON; charset=utf-8', '{ not json', { length: true }, 400],
255+
['a blank JSON body', 'application/json', '', { length: true }, 200],
256+
['a whitespace-only JSON body', 'application/json', ' \n', { length: true }, 200],
257+
// A form.
258+
['a urlencoded form', 'application/x-www-form-urlencoded', 'token=abc&token=def&x=1', { length: true }, 200],
259+
['a malformed urlencoded form (read leniently by the parser on both doors)', 'application/x-www-form-urlencoded', 'a=%zz&b=%&=&&{"a":1}', { length: true }, 200],
260+
['a multipart form', 'multipart/form-data; boundary=b', '--b\r\nContent-Disposition: form-data; name="a"\r\n\r\n1\r\n--b--\r\n', { length: true }, 200],
261+
['a multipart form that does not parse', 'multipart/form-data; boundary=elsewhere', '--nope\r\nnot a part', { length: true }, 400],
262+
// Any other media type, or none, with a body.
263+
['step 4 — `{"a":1}` sent as text/plain', 'text/plain', '{"a":1}', { length: true }, 400],
264+
['step 4 — the query body sent as text/plain;charset=UTF-8', 'text/plain;charset=UTF-8', '{"where": {"status": "churned"}}', { length: true }, 400],
265+
['plain text', 'text/plain', 'hello', { length: true }, 400],
266+
['a body with no Content-Type', undefined, '{"a":1}', { length: true, bytes: true }, 400],
267+
['application/octet-stream', 'application/octet-stream', 'raw bytes', { length: true }, 400],
268+
['a +json suffix type', 'application/merge-patch+json', '{"a":1}', { length: true }, 400],
269+
['a media type that is not a token pair (named as unrecognised)', 'text/plain extra', 'hello', { length: true }, 400],
270+
['a body framed by Transfer-Encoding', 'text/plain', '{"a":1}', { chunked: true }, 400],
271+
// No body declared.
272+
['a text/plain request with no body', 'text/plain', undefined, {}, 200],
273+
['a text/plain body framed as zero-length', 'text/plain', '', { length: true }, 200],
274+
['an in-process text/plain body with no framing headers (read as no body on both doors)', 'text/plain', 'hello', {}, 200],
275+
];
276+
277+
describe('[#23914, #24042] both Hono doors answer one body alike, media type by media type', () => {
278+
it.each(PARITY)('%s: the same status and the same body', async (_label, contentType, body, framing, status) => {
279+
const hosted = await send(QUERY, 'POST', contentType, body, framing);
280+
const direct = await sendDirect('POST', contentType, body, framing);
281+
282+
const hostedJson = await hosted.json() as any;
283+
const directJson = await direct.json() as any;
284+
expect(hosted.status, JSON.stringify(hostedJson)).toBe(status);
285+
expect(direct.status, JSON.stringify(directJson)).toBe(status);
286+
expect(hostedJson).toEqual(directJson);
287+
if (status === 400) {
288+
expect(hostedJson).toEqual({ success: false, error: { code: 'INVALID_REQUEST', message: expect.any(String) } });
289+
if (body && body.trim() !== '') expect(hostedJson.error.message).not.toContain(body.trim());
290+
} else {
291+
expect(hostedJson.success).toBe(true);
292+
}
185293
});
186294
});

0 commit comments

Comments
 (0)