Skip to content

Commit f0c5d3b

Browse files
committed
test(hono): hold both Hono doors equal over one media-type table; move the raw-request rows the new rule answers
Claude-Session: https://claude.ai/code/session_018t6a5rvqPRvEDMCVVW9kYT Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6f3052e commit f0c5d3b

3 files changed

Lines changed: 264 additions & 49 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)