diff --git a/.changeset/23914-cloud-connection-body-refusal.md b/.changeset/23914-cloud-connection-body-refusal.md new file mode 100644 index 0000000000..227b685f31 --- /dev/null +++ b/.changeset/23914-cloud-connection-body-refusal.md @@ -0,0 +1,19 @@ +--- +'@objectstack/cloud-connection': minor +--- + +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 + +Clause-②: no (narrowing) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +`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. + +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. + +**Remedy, in one line.** Send a JSON body as well-formed JSON with `Content-Type: application/json`, or send no body. + +**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. diff --git a/.changeset/23914-hono-body-refusal.md b/.changeset/23914-hono-body-refusal.md new file mode 100644 index 0000000000..f1c571b0cc --- /dev/null +++ b/.changeset/23914-hono-body-refusal.md @@ -0,0 +1,27 @@ +--- +'@objectstack/hono': minor +--- + +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 `{}` + +Clause-②: no (narrowing) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +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. + +What changes for an integrator, on a host that serves `createHonoApp`: + +- 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. +- 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`). + +**Remedy, in one line.** Send a JSON body as well-formed JSON with `Content-Type: application/json`, or send no body. + +**Unchanged.** + +- An empty or whitespace-only JSON body is dispatched as `{}` (an empty query), as before. +- A body that does not declare JSON is read as before: parsed when it is JSON, `{}` when it is not. +- 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. +- The catch-all dispatches `GET`, `HEAD` and `DELETE` with no body, and the `/auth/*` fallback hands `GET` and `HEAD` `{}`, as before. diff --git a/.changeset/23914-hono-server-body-refusal.md b/.changeset/23914-hono-server-body-refusal.md new file mode 100644 index 0000000000..0e74e31a1f --- /dev/null +++ b/.changeset/23914-hono-server-body-refusal.md @@ -0,0 +1,29 @@ +--- +'@objectstack/plugin-hono-server': minor +--- + +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 `{}` + +Clause-②: no (narrowing) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +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. + +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. + +- 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." +- 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." +- A `multipart/form-data` or `application/x-www-form-urlencoded` body that does not parse: FROM `{}`, TO the refusal naming form data. + +**Remedy, in one line.** Send a JSON body as well-formed JSON with `Content-Type: application/json`, or send no body. + +**Unchanged.** + +- 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. +- Well-formed JSON and well-formed forms reach the handler parsed, as before. +- 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`. +- A route, or the unmatched-request fallback, that never reads `req.body` answers as before. +- 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. diff --git a/packages/adapters/hono/src/hono-body-refusal.test.ts b/packages/adapters/hono/src/hono-body-refusal.test.ts new file mode 100644 index 0000000000..06ebc0b3e6 --- /dev/null +++ b/packages/adapters/hono/src/hono-body-refusal.test.ts @@ -0,0 +1,186 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23914] `createHonoApp` refuses a declared JSON body that does not parse + * with `400 INVALID_REQUEST` when a dispatched domain READS the body, and only + * then — the rule `@objectstack/plugin-hono-server` applies to `req.body` — and + * answers it with the same status and body as that package. + * + * The catch-all used to hand `dispatch()` `{}` for such a body, which the data + * domain read as an empty query (every row) or an empty update (a `200` for a + * write that changed nothing). + * + * The dispatcher is a fixture (this package's config aliases + * `@objectstack/runtime` to a stub), standing in for the two kinds of domain: + * a READER (`/data/*`, `/meta/*`), which reads `body` inside a `try` whose + * catch answers its own `500`, so a pin that passes proves the adapter's + * answer won; and a FORWARDER (`/webhooks/redeliver`), which reads its own + * bytes from the forwarded request and never touches `body`, as the real + * webhooks, inbound-hook and `/approvals/act` domains do. The real dispatcher + * and the real domains are driven in + * `packages/qa/http-conformance/src/hono-body-refusal.conformance.test.ts`. + * `@objectstack/plugin-hono-server` resolves to its source here + * (`vitest.config.ts`), so the parity case drives that package's real request + * path, not a copy of it. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { HonoHttpServer } from '@objectstack/plugin-hono-server'; + +/** What each domain did: the body it read, or that it read the forwarded request. */ +const seen: Array<{ path: string; read?: unknown; forwarded?: string }> = []; + +/** A domain that reads the parsed body, and answers a throw out of that read with its own 500. */ +function readerDomain(path: string, body: unknown) { + try { + const read = JSON.parse(JSON.stringify(body ?? {})); + seen.push({ path, read }); + return { handled: true, response: { status: 200, body: { success: true, data: read } } }; + } catch { + return { handled: true, response: { status: 500, body: { success: false, error: { code: 'INTERNAL_ERROR', message: 'domain fault' } } } }; + } +} + +/** A domain that forwards the request and reads its own bytes, as `/webhooks/redeliver` does. */ +async function forwarderDomain(path: string, request: Request) { + const text = await request.text(); + seen.push({ path, forwarded: text }); + try { + JSON.parse(text); + return { handled: true, response: { status: 200, body: { success: true, data: { ok: true } } } }; + } catch { + return { handled: true, response: { status: 400, body: { success: false, error: { code: 'INVALID_REQUEST', message: 'Request body must be JSON.' } } } }; + } +} + +const mockDispatcher = { + getDiscoveryInfo: vi.fn().mockReturnValue({ version: '1.0', routes: {} }), + handleAuth: vi.fn(async (path: string, _method: string, body: unknown) => readerDomain(`/auth/${path}`, body)), + dispatch: vi.fn(async (_method: string, path: string, body: unknown, _query: unknown, ctx: { request: Request }) => { + if (path === '/webhooks/redeliver') return forwarderDomain(path, ctx.request); + if (path === '/throws') { + // A reader that lets the throw escape `dispatch()`. + void (body as Record).deliveryId; + return { handled: true, response: { status: 200, body: { success: true } } }; + } + return readerDomain(path, body); + }), +}; + +vi.mock('@objectstack/runtime', () => ({ + HttpDispatcher: function HttpDispatcher() { return mockDispatcher; }, +})); + +import { createHonoApp } from './index'; + +const PREFIX = '/api/v1'; +const kernel = { name: 'test-kernel' } as any; + +const send = (path: string, method: string, contentType: string | undefined, body: string) => + createHonoApp({ kernel, prefix: PREFIX, cors: false }).request(`http://hosted.test${PREFIX}${path}`, { + method, + headers: contentType ? { 'Content-Type': contentType } : {}, + body, + }); + +/** + * The declared refusal, read whole. Structural rather than `ApiErrorSchema` + * (this package does not depend on `@objectstack/spec`): the parity case below + * holds this body equal to `@objectstack/plugin-hono-server`'s, whose own suite + * (`request-body-refusal.test.ts`) parses it against the declared envelope. + */ +async function expectRefused(res: Response, body: string) { + const json = await res.json() as any; + expect(res.status, JSON.stringify(json)).toBe(400); + expect(json).toEqual({ + success: false, + error: { code: 'INVALID_REQUEST', message: expect.stringMatching(/parsed as JSON/) }, + }); + expect(json.error.message).not.toContain(body.trim()); + return json; +} + +beforeEach(() => { + seen.length = 0; + mockDispatcher.dispatch.mockClear(); + mockDispatcher.handleAuth.mockClear(); +}); + +describe('[#23914] a domain that READS the body refuses the card\'s bodies, with this adapter\'s answer', () => { + it.each([ + ['step 2 — a truncated query', 'POST', '/data/showcase_account/query', '{"where": {"status": "churned"}'], + ['step 3 — `{ not json`', 'POST', '/data/showcase_account/query', '{ not json'], + ['step 5 — a truncated PATCH', 'PATCH', '/data/showcase_account/rec_1', '{"name": "HACKED"'], + ['step 6 — a truncated create', 'POST', '/data/showcase_account', '{"name": "Door Truncated Create"'], + ['a truncated PUT', 'PUT', '/meta/object/account', '{"name": "account"'], + ])('%s: refused 400, though the domain answered its own 500', async (_label, method, path, body) => { + await expectRefused(await send(path, method, 'application/json', body), body); + expect(mockDispatcher.dispatch).toHaveBeenCalledTimes(1); + expect(seen).toEqual([]); + }); + + it('a declared JSON media type is matched case-insensitively', async () => { + await expectRefused(await send('/data/task/query', 'POST', 'Application/JSON; charset=utf-8', '{ not json'), '{ not json'); + }); + + it('a reader whose read escapes dispatch() is answered the refusal by the catch arm', async () => { + await expectRefused(await send('/throws', 'POST', 'application/json', '{"deliveryId":'), '{"deliveryId":'); + }); + + it('the /auth/* legacy fallback refuses the same body the same way, DELETE included', async () => { + await expectRefused(await send('/auth/sign-in/email', 'POST', 'application/json', '{"email": "a@b.c"'), '{"email": "a@b.c"'); + await expectRefused(await send('/auth/organization/delete', 'DELETE', 'application/json', '{"id": "org_1"'), '{"id": "org_1"'); + expect(mockDispatcher.handleAuth).toHaveBeenCalledTimes(2); + }); +}); + +describe('[#23914] a domain that FORWARDS the request answers it itself, exactly as before', () => { + it('the forwarder reads every byte the client sent and answers its own refusal; this adapter adds nothing', async () => { + const body = '{deliveryId:'; + const res = await send('/webhooks/redeliver', 'POST', 'application/json', body); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ success: false, error: { code: 'INVALID_REQUEST', message: 'Request body must be JSON.' } }); + expect(seen).toEqual([{ path: '/webhooks/redeliver', forwarded: body }]); + }); + + it('a well-formed body reaches the forwarder untouched', async () => { + const body = '{ "deliveryId" : "d_1" }'; + expect((await send('/webhooks/redeliver', 'POST', 'application/json', body)).status).toBe(200); + expect(seen).toEqual([{ path: '/webhooks/redeliver', forwarded: body }]); + }); +}); + +describe('[#23914] CONTROL — what was readable is handed on as before', () => { + it.each([ + ['a valid JSON body', 'application/json', '{"where": {"status": "churned"}}', { where: { status: 'churned' } }], + ['an empty JSON body (an empty query)', 'application/json', '', {}], + ['a whitespace-only JSON body', 'application/json', ' \n', {}], + ['a form body', 'application/x-www-form-urlencoded', 'token=abc', {}], + ['a JSON body sent as text/plain (the content-type-blind read, unchanged)', 'text/plain', '{"a":1}', { a: 1 }], + ['text that does not declare JSON (unchanged)', 'text/plain', 'hello', {}], + ])('%s', async (_label, contentType, body, expected) => { + const res = await send('/data/task/query', 'POST', contentType, body); + expect(res.status).toBe(200); + expect(seen).toEqual([{ path: '/data/task/query', read: expected }]); + }); +}); + +describe('[#23914] both Hono doors answer one unparseable body alike', () => { + it.each([ + ['a truncated query', '{"where": {"status": "churned"}'], + ['`{ not json`', '{ not json'], + ])('%s: the same status and the same body', async (_label, body) => { + const hosted = await send('/data/showcase_account/query', 'POST', 'application/json', body); + + const server = new HonoHttpServer(0); + server.post(`${PREFIX}/data/:object/query`, (req, res) => { res.json({ query: req.body }); }); + const direct = await server.getRawApp().fetch(new Request(`http://direct.test${PREFIX}/data/showcase_account/query`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body, + })); + + expect(direct.status).toBe(hosted.status); + expect(await direct.json()).toEqual(await expectRefused(hosted, body)); + }); +}); diff --git a/packages/adapters/hono/src/hono-catch-all-raw-request.test.ts b/packages/adapters/hono/src/hono-catch-all-raw-request.test.ts index 2ca20de3d7..5331892d0b 100644 --- a/packages/adapters/hono/src/hono-catch-all-raw-request.test.ts +++ b/packages/adapters/hono/src/hono-catch-all-raw-request.test.ts @@ -20,7 +20,11 @@ * * 1. PRESERVATION — the `body` argument is the value the old line produced, for * every input: each case is checked against a reference Hono app that runs - * that exact line, and against the literal value written out. + * that exact line, and against the literal value written out. One input + * left this table on purpose (#23914): a declared `application/json` body + * that does not parse is handed on as a body whose READ is refused with a + * 400 (`hono-body-refusal.test.ts`); the request beside it still arrives + * unread, every byte readable. * 2. THE FIX — at dispatch the request is unread (`bodyUsed === false`) and * reads back the exact bytes the client sent, JSON included. * @@ -95,7 +99,11 @@ const CASES: ReadonlyArray { + it('a domain that forwards the request reads every byte the client sent, and is not refused for it', async () => { + const app = createHonoApp({ kernel, prefix: PREFIX, cors: false }); + const text = '{ not json'; + const res = await app.request(`http://hosted.test${PATH}`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, body: text, + }); + // The fixture domain records the body without reading it and forwards + // nothing of it, so nothing refuses: the answer is the domain's. + expect(res.status).toBe(200); + expect(seen).toHaveLength(1); + expect(seen[0]!.bodyUsed).toBe(false); + expect(seen[0]!.text).toBe(text); + }); +}); diff --git a/packages/adapters/hono/src/index.ts b/packages/adapters/hono/src/index.ts index 446655b1e9..fb199e0ebe 100644 --- a/packages/adapters/hono/src/index.ts +++ b/packages/adapters/hono/src/index.ts @@ -318,17 +318,128 @@ export function objectStackMiddleware(kernel: ObjectKernel) { * holds the body's bytes, so a body is buffered twice instead of once for the * life of the request. These are API bodies the old path already buffered * whole; nothing here streams. + * + * ## A declared JSON body that does not parse is refused BY ITS READER (#23914) + * + * …and is no longer `{}`. That `{}` reached the data domain as an empty query, + * so a truncated `POST /data/:object/query` answered `200` with every row the + * caller could read, and a truncated `PATCH` answered `200` for an update that + * changed nothing. + * + * One rule on both Hono transports: the code that READS the parsed body + * refuses it, nothing earlier. `@objectstack/plugin-hono-server` does that + * with its `req.body` accessor; here the body is a `dispatch()` argument, so + * for a body whose `Content-Type` declares `application/json` (matched + * case-insensitively), is not blank, and does not parse, the value handed on + * is {@link refusedOnRead}'s: any read of it — a property, `in`, its keys, a + * spread, `JSON.stringify` — throws {@link RequestBodyRefusal} (a declared + * `400` with the registered code `INVALID_REQUEST`) and records that it was + * read. Both arms that call this then answer that refusal, through + * `thrownJson` as the ADR-0112 envelope, whatever the domain made of the throw + * — the same override `plugin-hono-server` applies — and only then. + * + * Why on read and not before `dispatch()`. A domain that forwards the request + * reads its own bytes from it and never reads `body`: `/webhooks/redeliver` + * and the inbound automation hooks answer a body that is not JSON with their + * own refusal, after their own sign-in or signature check, and the + * `/approvals/act` form `POST` reads a form. Refusing before `dispatch()` + * pre-empted them at this door only (measured: `/webhooks/redeliver` then + * answered this adapter's message where the self-hosted mount answers the + * member's, and an anonymous caller got `400` where the mount says `401`). + * `dispatch()` itself never reads `body` (its gates read headers and the + * path), so the gates still run first, as they do ahead of a route handler on + * `plugin-hono-server`. No list of routes is kept here: whether a domain reads + * the body is answered by the domain reading it. + * + * `@objectstack/plugin-hono-server` answers the same body with the same `code` + * and `message` (`hono-body-refusal.test.ts` drives both doors). + * + * Every other input is read exactly as before, deliberately: + * - an empty or whitespace-only body is `{}` (an empty query); + * - a body that does not declare JSON keeps the content-type-blind parse and + * its `{}` on failure; + * - the request handed on beside it is unread in every case, so a domain that + * forwards it reads every byte the client sent, a malformed body included. */ -async function readDispatchBody(c: Context): Promise { +async function readDispatchBody(c: Context): Promise { + const declaresJson = (c.req.header('content-type') ?? '').toLowerCase().includes('application/json'); const raw = c.req.raw; - if (raw.bodyUsed) return c.req.json().catch(() => ({})); + let text: string; try { - return JSON.parse(await raw.clone().text()); + // A body something upstream already read through `c.req` is in Hono's + // body cache, which is the only copy left; `text()` reads that cache. + text = raw.bodyUsed ? await c.req.text() : await raw.clone().text(); } catch { - return {}; + return declaresJson ? refusedOnRead(new RequestBodyRefusal(UNREADABLE_BODY_MESSAGE)) : readable({}); + } + try { + return readable(JSON.parse(text)); + } catch { + return declaresJson && text.trim() !== '' + ? refusedOnRead(new RequestBodyRefusal(MALFORMED_JSON_BODY_MESSAGE)) + : readable({}); + } +} + +/** What {@link readDispatchBody} hands an arm: the `body` argument, and whether a refused body was read. */ +interface DispatchBody { + /** The `body` argument for `dispatch()` / `handleAuth()`. */ + readonly value: any; + /** The refusal, once something READ a body this adapter could not parse; `undefined` otherwise. */ + readonly refusalRead: () => RequestBodyRefusal | undefined; +} + +const readable = (value: any): DispatchBody => ({ value, refusalRead: () => undefined }); + +/** + * [#23914] A `body` argument whose every READ throws `refusal` and is recorded: + * property reads, `in`, key enumeration (and so a spread, `Object.keys`, + * `JSON.stringify`), descriptor and prototype lookups, and writes. Holding it + * is not a read: `typeof` is `'object'`, `Array.isArray` is `false`, and + * passing it along, comparing it or testing it with `??` touch nothing. + */ +function refusedOnRead(refusal: RequestBodyRefusal): DispatchBody { + let read = false; + const refuse = (): never => { + read = true; + throw refusal; + }; + const value = new Proxy(Object.create(null), { + get: refuse, + has: refuse, + ownKeys: refuse, + getOwnPropertyDescriptor: refuse, + getPrototypeOf: refuse, + set: refuse, + defineProperty: refuse, + deleteProperty: refuse, + }); + return { value, refusalRead: () => (read ? refusal : undefined) }; +} + +/** + * [#23914] The refusal a read of {@link refusedOnRead}'s body throws: a DECLARED ADR-0112 + * throw (`status` and the registered `INVALID_REQUEST`), so `thrownJson` + * renders it through `declaredEnvelopeForThrow`. Module-private, like its twin + * in `@objectstack/plugin-hono-server` (`src/request-body.ts`); the messages + * are the twin's, word for word, and a test holds the two doors' answers equal. + */ +class RequestBodyRefusal extends Error { + readonly status = 400; + readonly code = 'INVALID_REQUEST'; + + constructor(message: string) { + super(message); + this.name = 'RequestBodyRefusal'; } } +const MALFORMED_JSON_BODY_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.'; + +const UNREADABLE_BODY_MESSAGE = 'The request body could not be read. It was refused rather than read as an empty body.'; + /** * The query the catch-all hands `dispatch()`: a REPEATED name as an array, a * single-valued one as a plain string (#23214). @@ -726,6 +837,8 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono { // --- Auth (needs auth service integration) --- app.all(`${authMount}/*`, async (c, next) => { + // [#23914] Declared outside the `try` so the catch arm can ask it too. + let read: DispatchBody | undefined; try { const path = c.req.path.substring(authMount.length + 1); const method = c.req.method; @@ -824,10 +937,13 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono { } // Fallback to legacy dispatcher - const body = method === 'GET' || method === 'HEAD' - ? {} - : await readDispatchBody(c); + if (method !== 'GET' && method !== 'HEAD') read = await readDispatchBody(c); + const body = read ? read.value : {}; const result = await dispatcher.handleAuth(path, method, body, { request: c.req.raw }); + // [#23914] A body this adapter could not parse, READ by the domain: the + // refusal is the answer, whatever the domain answered (see `readDispatchBody`). + const refusal = read?.refusalRead(); + if (refusal) return thrownJson(c, refusal); // `handled: false` is the dispatcher saying no auth domain claimed it — // an explicit ownership signal, better than inferring one from a status. if (!result.handled) return yieldUnowned(c, next, () => toResponse(c, result)); @@ -837,7 +953,8 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono { // in-repo producer was found that reaches this arm with a declared // envelope (better-auth answers its own failures as a `Response`), so it // takes the rule for uniformity: one rule for both arms, not two. - return thrownJson(c, err); + // [#23914] …and a refused body that was read is answered as that refusal. + return thrownJson(c, read?.refusalRead() ?? err); } }); @@ -868,6 +985,8 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono { // ExecutionContext). Revisit only if the gate stages are middleware-ized // for an independent reason — reopen conditions are archived on #3608. app.all(`${prefix}/*`, async (c) => { + // [#23914] Declared outside the `try` so the catch arm can ask it too. + let read: DispatchBody | undefined; try { const subPath = c.req.path.substring(prefix.length); const method = c.req.method; @@ -875,20 +994,24 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono { // [#22576] Parsed from a clone: `c.req.raw` reaches the dispatcher with // its body unread, so a domain that forwards the request (the // `/approvals/act` form POST) still has it. See `readDispatchBody`. - let body: any = undefined; if (method === 'POST' || method === 'PUT' || method === 'PATCH') { - body = await readDispatchBody(c); + read = await readDispatchBody(c); } // [#23214] A repeated name stays an array (`readDispatchQuery`). const queryParams = readDispatchQuery(c); - const result = await dispatcher.dispatch(method, subPath, body, queryParams, { request: c.req.raw }, prefix); + const result = await dispatcher.dispatch(method, subPath, read?.value, queryParams, { request: c.req.raw }, prefix); + // [#23914] A body this adapter could not parse, READ by the domain: the + // refusal is the answer, whatever the domain answered (see `readDispatchBody`). + const refusal = read?.refusalRead(); + if (refusal) return thrownJson(c, refusal); return toResponse(c, result); } catch (err: any) { // [#23365] A declared ADR-0112 envelope that left `dispatch()` (its foot // catch re-raises everything but a denial) is answered as declared. - return thrownJson(c, err); + // [#23914] …and a refused body that was read is answered as that refusal. + return thrownJson(c, read?.refusalRead() ?? err); } }); diff --git a/packages/cloud-connection/src/cloud-connection-plugin.ts b/packages/cloud-connection/src/cloud-connection-plugin.ts index b327197182..41360dce04 100644 --- a/packages/cloud-connection/src/cloud-connection-plugin.ts +++ b/packages/cloud-connection/src/cloud-connection-plugin.ts @@ -84,6 +84,7 @@ import { evaluateAuthGate, type AuthGate } from '@objectstack/core'; import { inProcessSessionReadInput } from '@objectstack/types'; import { ConnectionCredentialStore } from './connection-credential-store.js'; import { CLOUD_CONNECTION_UI_BUNDLE } from './cloud-connection-ui.js'; +import { readJsonRequestBody } from './request-json-body.js'; const CLOUD_CONNECTION_PREFIX = '/api/v1/cloud-connection'; @@ -315,8 +316,11 @@ export class CloudConnectionPlugin implements Plugin { // code, returns them to the Setup UI, and the operator approves in // the cloud console. rawApp.post(`${CLOUD_CONNECTION_PREFIX}/bind/start`, async (c: any) => { - let body: any = {}; - try { body = await c.req.json(); } catch { body = {}; } + // [#23914] A declared JSON body that does not parse is refused 400, + // never read as no body (`./request-json-body.ts`). + const read = await readJsonRequestBody(c); + if (!read.ok) return c.json({ success: false, error: { code: 'INVALID_REQUEST', message: read.message } }, 400); + const body: any = read.body; // Self-hosted (single-env) runtimes need NO environment id — // registration happens cloud-side at approval (ADR // runtime-identity-binding). An explicit environment_id in the @@ -375,8 +379,11 @@ export class CloudConnectionPlugin implements Plugin { // the operator token for a persisted sys_cloud_connection via the // control plane's /bind. rawApp.post(`${CLOUD_CONNECTION_PREFIX}/bind/poll`, async (c: any) => { - let body: any = {}; - try { body = await c.req.json(); } catch { body = {}; } + // [#23914] A declared JSON body that does not parse is refused 400, + // never read as no body (`./request-json-body.ts`). + const read = await readJsonRequestBody(c); + if (!read.ok) return c.json({ success: false, error: { code: 'INVALID_REQUEST', message: read.message } }, 400); + const body: any = read.body; let environmentId = await resolveEnvironmentId(c); if (!environmentId && this.cfg.singleEnvironment) { environmentId = String(body?.environment_id ?? body?.environmentId ?? '').trim() || undefined; @@ -542,8 +549,11 @@ export class CloudConnectionPlugin implements Plugin { return c.json({ success: false, error: { code: 'CLOUD_UNCONFIGURED', message: 'This runtime is not connected to a cloud account; install is unavailable.' } }, 503); } - let body: any = {}; - try { body = await c.req.json(); } catch { body = {}; } + // [#23914] A declared JSON body that does not parse is refused 400, + // never read as no body (`./request-json-body.ts`). + const read = await readJsonRequestBody(c); + if (!read.ok) return c.json({ success: false, error: { code: 'INVALID_REQUEST', message: read.message } }, 400); + const body: any = read.body; const packageId = String(body?.package_id ?? body?.packageId ?? '').trim(); if (!packageId) return c.json({ success: false, error: { code: 'INVALID_REQUEST', message: 'package_id is required' } }, 400); const seedSampleData = body?.seed_sample_data === true || body?.seedSampleData === true; diff --git a/packages/cloud-connection/src/error-envelope.conformance.test.ts b/packages/cloud-connection/src/error-envelope.conformance.test.ts index 66c58a439f..9751221909 100644 --- a/packages/cloud-connection/src/error-envelope.conformance.test.ts +++ b/packages/cloud-connection/src/error-envelope.conformance.test.ts @@ -95,9 +95,18 @@ function makeC(opts: { body?: unknown; params?: Record; headers?: Record; + /** + * [#23914] A body as the client SENT it, served the way Hono 4.13's + * `HonoRequest` serves one: `json()` is the cached `text()` then + * `JSON.parse`, so a body that does not parse rejects `json()` and still + * reads back from `text()`. Takes the place of `body`. + */ + raw?: { contentType: string; text: string }; } ): { c: any; captured: Captured } { const captured: Captured = { status: 0, body: undefined }; const h = new Headers(opts.headers ?? {}); + if (opts.raw) h.set('content-type', opts.raw.contentType); + const sent = opts.raw ? Promise.resolve(opts.raw.text) : undefined; const c: any = { req: { url: opts.url, @@ -105,7 +114,8 @@ function makeC(opts: { path: new URL(opts.url).pathname, raw: new Request(opts.url, { headers: h }), header: (n: string) => h.get(n) ?? undefined, - json: async () => opts.body ?? {}, + json: sent ? () => sent.then((t) => JSON.parse(t)) : async () => opts.body ?? {}, + text: sent ? () => sent : async () => JSON.stringify(opts.body ?? {}), param: (n: string) => opts.params?.[n], query: () => undefined, }, @@ -209,12 +219,12 @@ async function mountCloudConnection(opts: { async function driveCloudConnection( routes: Map, key: string, - opts: { body?: unknown } = {}, + opts: { body?: unknown; raw?: { contentType: string; text: string } } = {}, ): Promise { const handler = routes.get(key); if (!handler) throw new Error(`no handler for ${key} (have: ${[...routes.keys()].join(', ')})`); const [method, path] = key.split(' '); - const { c, captured } = makeC({ url: `${HOST}${path}`, method, body: opts.body }); + const { c, captured } = makeC({ url: `${HOST}${path}`, method, body: opts.body, raw: opts.raw }); await handler(c); return captured; } @@ -401,6 +411,55 @@ describe('plugin-route door — MarketplaceProxyPlugin error exits (#9267)', () }); }); +// ── [#23914] An unparseable body is refused, never read as no body ─────────── + +/** + * Each of these routes read its body as `try { body = await c.req.json() } + * catch { body = {} }`, so a truncated body was answered as if the client had + * sent none — `/bind/poll` with `400 "device_code is required"` for a body that + * carried `device_code` and lacked a brace. Now a body sent as + * `application/json` that does not parse is refused with its own `400 + * INVALID_REQUEST`, whose message names the parse failure and echoes nothing. + * CONTROL: an empty JSON body is still no body, so the required-field answer + * it always gave is unchanged. + */ +describe('plugin-route door — CloudConnectionPlugin refuses an unparseable body (#23914)', () => { + const json = (text: string) => ({ contentType: 'application/json', text }); + + function expectParseRefusal(captured: Captured, sent: string) { + expectDeclaredErrorEnvelope(captured, { status: 400, code: 'INVALID_REQUEST' }); + expect(captured.body.error.message).toMatch(/parsed as JSON/); + expect(captured.body.error.message).not.toContain(sent.trim()); + } + + it('POST /bind/start with a truncated body → 400, before anything else is read', async () => { + const routes = await mountCloudConnection({ resolvesEnvironment: true }); + const sent = '{"environment_id": "env-123"'; + expectParseRefusal(await driveCloudConnection(routes, `POST ${CC}/bind/start`, { raw: json(sent) }), sent); + }); + + it('POST /bind/poll with a truncated body carrying device_code → the parse refusal, not "device_code is required"', async () => { + const routes = await mountCloudConnection({ resolvesEnvironment: true, userId: 'usr_1' }); + const sent = '{"device_code": "dc_1"'; + expectParseRefusal(await driveCloudConnection(routes, `POST ${CC}/bind/poll`, { raw: json(sent) }), sent); + }); + + it('POST /install with a truncated body carrying package_id → the parse refusal, not "package_id is required"', async () => { + const routes = await mountCloudConnection({ + resolvesEnvironment: true, userId: 'usr_1', controlPlaneApiKey: 'svc-key', + }); + const sent = '{"package_id": "pkg_1"'; + expectParseRefusal(await driveCloudConnection(routes, `POST ${CC}/install`, { raw: json(sent) }), sent); + }); + + it('CONTROL — an empty JSON body is still no body: /bind/poll answers its required-field 400', async () => { + const routes = await mountCloudConnection({ resolvesEnvironment: true, userId: 'usr_1' }); + const captured = await driveCloudConnection(routes, `POST ${CC}/bind/poll`, { raw: json('') }); + expectDeclaredErrorEnvelope(captured, { status: 400, code: 'INVALID_REQUEST' }); + expect(captured.body.error.message).toMatch(/device_code/); + }); +}); + // ── MarketplaceInstallLocalPlugin — /api/v1/marketplace/install-local ──────── const IL = '/api/v1/marketplace/install-local'; diff --git a/packages/cloud-connection/src/marketplace-install-local-install-modes.test.ts b/packages/cloud-connection/src/marketplace-install-local-install-modes.test.ts index 1152c26b10..5946a8e5ab 100644 --- a/packages/cloud-connection/src/marketplace-install-local-install-modes.test.ts +++ b/packages/cloud-connection/src/marketplace-install-local-install-modes.test.ts @@ -42,13 +42,17 @@ const INSTALLER = 'usr_installer'; type Handler = (c: any) => Promise; -function makeC(body: any) { +function makeC(body: any, sent?: { contentType: string; text: string }) { const json = vi.fn((payload: any, status?: number) => ({ payload, status: status ?? 200 })); + // [#23914] `sent`: a body as the client sent it, served the way Hono 4.13's + // `HonoRequest` serves one (`json()` is the cached `text()` then `JSON.parse`). + const text = sent ? Promise.resolve(sent.text) : undefined; return { req: { url: `http://localhost:3000${ROUTE}`, raw: new Request(`http://localhost:3000${ROUTE}`), - json: async () => body, + json: text ? () => text.then((t) => JSON.parse(t)) : async () => body, + ...(sent ? { text: () => text, header: (n: string) => (n.toLowerCase() === 'content-type' ? sent.contentType : undefined) } : {}), param: () => undefined, }, json, @@ -118,6 +122,7 @@ async function mount(dir: string, opts: { register.mockClear(); return { install: (body: unknown) => routes.get(`POST ${ROUTE}`)!(makeC(body)), + installSent: (contentType: string, text: string) => routes.get(`POST ${ROUTE}`)!(makeC(undefined, { contentType, text })), register, syncSchemas, importUnmanagedPackage, @@ -439,3 +444,29 @@ describe('install-local — a managed install never takes over an unmanaged copy expect(h.register).toHaveBeenCalledTimes(1); }); }); + +describe('[#23914] a truncated install body is refused, and nothing is written', () => { + it('a declared JSON body that does not parse → 400 INVALID_REQUEST naming the parse failure, zero writes', async () => { + const m = await mount(dir); + const sent = '{"packageId": "com.example.crm", "installMode": "unmanaged"'; + const res = await m.installSent('application/json', sent); + expect(res.status).toBe(400); + expect(res.payload).toEqual({ + success: false, + error: { code: 'INVALID_REQUEST', message: expect.stringMatching(/parsed as JSON/) }, + }); + expect(res.payload.error.message).not.toContain(sent); + expect(m.register).not.toHaveBeenCalled(); + expect(m.syncSchemas).not.toHaveBeenCalled(); + expect(m.importUnmanagedPackage).not.toHaveBeenCalled(); + expect(m.ledgerHas('com.example.crm')).toBe(false); + }); + + it('CONTROL — an empty JSON body is still no body: the door answers what it answered for {}', async () => { + const m = await mount(dir); + const empty = await m.installSent('application/json', ''); + const none = await m.install({}); + expect(empty.status).toBe(none.status); + expect(empty.payload).toEqual(none.payload); + }); +}); diff --git a/packages/cloud-connection/src/marketplace-install-local-plugin.ts b/packages/cloud-connection/src/marketplace-install-local-plugin.ts index 7770ed0fd5..68e146257e 100644 --- a/packages/cloud-connection/src/marketplace-install-local-plugin.ts +++ b/packages/cloud-connection/src/marketplace-install-local-plugin.ts @@ -181,6 +181,7 @@ import { type SkippedManifestEntry, } from './local-manifest-source.js'; import { ConnectionCredentialStore } from './connection-credential-store.js'; +import { readJsonRequestBody } from './request-json-body.js'; import { MARKETPLACE_INSTALLED_UI_BUNDLE } from './marketplace-ui.js'; import { matchSeedRows, purgeSeedRows, type PurgeSeedDataset } from './marketplace-install-local-purge.js'; import type { IAuthService, IHttpServer, IMetadataService, IObjectQLEngine } from '@objectstack/spec/contracts'; @@ -1124,8 +1125,11 @@ export class MarketplaceInstallLocalPlugin implements Plugin { if (!admission.ok) return admission.response; const userId = admission.userId; - let body: any = {}; - try { body = await c.req.json(); } catch { /* empty body */ } + // [#23914] A declared JSON body that does not parse is refused 400, + // never read as no body (`./request-json-body.ts`). + const read = await readJsonRequestBody(c); + if (!read.ok) return c.json({ success: false, error: { code: 'INVALID_REQUEST', message: read.message } }, 400); + const body: any = read.body; // 0. [ADR-0131 D6] ⭐ THE REQUESTED INSTALL MODE, parsed against the // manifest key's own enum (`InstallModeSchema`, by reference — never diff --git a/packages/cloud-connection/src/request-json-body.ts b/packages/cloud-connection/src/request-json-body.ts new file mode 100644 index 0000000000..61561f4f3e --- /dev/null +++ b/packages/cloud-connection/src/request-json-body.ts @@ -0,0 +1,67 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The JSON body of a request to one of this package's own Hono routes — + * parsed, `{}` when there is none, or the refusal message when it was declared + * as JSON and does not parse (#23914). Module-private: not exported from this + * package's entry. + * + * It answers a MESSAGE, never a response: each route builds the `400` body + * itself, in its own module, where `scripts/check-route-envelope.mjs` counts + * every body a plugin-route module builds. + * + * ## The defect + * + * Each route read its body as `try { body = await c.req.json() } catch { body = {} }`, + * so a truncated or garbled body was handled exactly like no body: the route + * then answered for a request the client never sent, such as + * `400 "package_id is required"` for a body that carried `package_id` and a + * missing brace. The HTTP transports (`@objectstack/plugin-hono-server`, + * `@objectstack/hono`) had the same swallow and refuse it the same way now, + * with the same `code` and the same message words. + * + * ## What a reading answers + * + * - A body that parses: that value, as before. `c.req.json()` is asked first, + * exactly as before, so every input it accepted is accepted unchanged. + * - A body that does not parse, sent as `application/json` (matched + * case-insensitively) and not blank: `400 INVALID_REQUEST` (a code on + * `ERROR_CODE_LEDGER`) in the declared envelope. The message names the parse + * failure and never echoes the body. + * - No body, a blank one, or one that does not declare JSON: `{}`, as before. + * These routes are JSON doors and nothing sends them another media type; + * narrowing that is not this change. + */ + +/** + * `ok: false` is answered `400` with the registered code `INVALID_REQUEST` + * (`ERROR_CODE_LEDGER`) and this `message`, in the declared envelope. + */ +export type JsonBodyReading = + | { readonly ok: true; readonly body: any } + | { readonly ok: false; readonly message: string }; + +const MALFORMED_JSON_BODY_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.'; + +const UNREADABLE_BODY_MESSAGE = 'The request body could not be read. It was refused rather than read as an empty body.'; + +export async function readJsonRequestBody(c: any): Promise { + try { + return { ok: true, body: await c.req.json() }; + } catch { + const declaresJson = String(c.req.header('content-type') ?? '').toLowerCase().includes('application/json'); + if (!declaresJson) return { ok: true, body: {} }; + // Hono's `json()` is `text()` then `JSON.parse`, and caches the text + // it read, so this is the same bytes, not a second read of the stream. + let text: string; + try { + text = await c.req.text(); + } catch { + return { ok: false, message: UNREADABLE_BODY_MESSAGE }; + } + if (text.trim() === '') return { ok: true, body: {} }; + return { ok: false, message: MALFORMED_JSON_BODY_MESSAGE }; + } +} diff --git a/packages/plugins/plugin-hono-server/src/adapter.ts b/packages/plugins/plugin-hono-server/src/adapter.ts index bf7eb8d2e4..a331a0f6ed 100644 --- a/packages/plugins/plugin-hono-server/src/adapter.ts +++ b/packages/plugins/plugin-hono-server/src/adapter.ts @@ -33,6 +33,7 @@ import { serveStatic } from '@hono/node-server/serve-static'; // the same drain, one different server factory. See {@link HttpsListenerMaterial}. import { createServer as createHttpsServer } from 'node:https'; import { matchesRoutePattern } from './route-pattern'; +import { readRequestBody, type RequestBodyReading, type RequestBodyRefusal } from './request-body'; // The ADR-0112 wire vocabulary, read as DATA rather than restated: `ErrorCode` // is the closed union (`StandardErrorCode` ∪ `ERROR_CODE_LEDGER`) a registered // code must be a member of, and `HttpStatusErrorCodeMap` IS the set of statuses @@ -688,35 +689,17 @@ export class HonoHttpServer implements IHttpServer { renderDeclaredEnvelope?: boolean; } = {}, ): Promise<{ response: Response | null; failed: boolean }> { - let body: any = {}; - // Ambient per-request timing collector — present only when the // Server-Timing / perf-tuning middleware established one for this // request. All marks below are no-ops otherwise (zero overhead). const _perf = currentPerfTiming(); const _endParse = _perf?.start('parse', 'Body parse'); - const contentType = c.req.header('content-type') ?? ''; - const isOctetStream = contentType.includes('application/octet-stream'); - - // Try to parse JSON body first if content-type is JSON - if (contentType.includes('application/json')) { - try { - body = await c.req.json(); - } catch(e) { - // If JSON parsing fails, try parseBody - try { - body = await c.req.parseBody(); - } catch(e2) {} - } - } else if (!isOctetStream) { - // For non-JSON / non-binary content types, use parseBody - // (Skipping for octet-stream so the raw stream stays consumable - // via `req.rawBody()` for binary uploads.) - try { - body = await c.req.parseBody(); - } catch(e) {} - } + // [#23914] Parsed by the declared content type, or a refusal, never an + // invented `{}`. `readRequestBody` (`./request-body.ts`) says what each + // media type answers and why the refusal waits for `req.body` to be read. + let bodyReading: RequestBodyReading = await readRequestBody(c); + let refusedBodyRead: RequestBodyRefusal | undefined; _endParse?.(); @@ -735,7 +718,23 @@ export class HonoHttpServer implements IHttpServer { const req = { params: readRouteParams(c), query: readQuery(c), - body, + /** + * [#23914] An accessor rather than a value: reading a body this + * adapter could not parse throws the refusal, which stops the + * handler before it acts on the request, and is answered below + * whatever the handler made of the throw. A route that reads + * `rawBody()` instead never reaches it. Writable as before. + */ + get body(): any { + if (bodyReading.refusal) { + refusedBodyRead = bodyReading.refusal; + throw bodyReading.refusal; + } + return bodyReading.body; + }, + set body(value: any) { + bodyReading = { body: value }; + }, headers: rawHeaders, method: c.req.method, path: c.req.path, @@ -873,6 +872,19 @@ export class HonoHttpServer implements IHttpServer { }); const outcome = await streamPromise; + + // [#23914] The handler read a body this adapter refused. Its answer, + // whatever it is, was built on a body the request never had: a catch + // arm's 500, a classification's own dialect, or nothing. The refusal + // is the answer, and it is not this handler's failure to report. + if (refusedBodyRead) { + const envelope = declaredEnvelopeForThrow(refusedBodyRead); + if (envelope) { + closeStream(); + return { response: c.json(envelope.body, envelope.status), failed: false }; + } + } + // A handler that WROTE and then threw keeps what it wrote — unchanged, // and the reason the render decision is taken here rather than in the // `catch`: `capturedResponse` is not visible from inside the executor's diff --git a/packages/plugins/plugin-hono-server/src/request-body-refusal.test.ts b/packages/plugins/plugin-hono-server/src/request-body-refusal.test.ts new file mode 100644 index 0000000000..6bca397df6 --- /dev/null +++ b/packages/plugins/plugin-hono-server/src/request-body-refusal.test.ts @@ -0,0 +1,283 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23914] A request body this adapter cannot parse is refused `400 + * INVALID_REQUEST` when a handler reads `req.body`, and is never handed on as + * `{}`. + * + * Driven through the real Hono app (`getRawApp().fetch`) and, for the framing + * half, a real socket. The routes stand in for the REST data doors the card + * measured: each reads `req.body` the way those handlers do, inside a `try` + * whose catch answers its OWN dialect, so a pin that passes proves the + * adapter's answer won over the handler's. + * + * In-process `Request`s carry no `Content-Length` of their own, so the cases + * whose media type is not read here declare one, as the wire always does; the + * socket cases at the foot send it the way a client does. + * + * Pinned: the card's reproduction bodies (truncated query, `{ not json`, a + * JSON body sent as `text/plain`, a truncated `PATCH`, a truncated create), + * each refused with the declared envelope, and the handler never acting on + * the body. CONTROL: a valid body, an empty one, a form, and the byte doors + * (`rawBody()` of a non-JSON file sent as `application/json`, of `text/plain` + * and of `application/octet-stream`) answer as they did. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { ApiErrorSchema, envelopeViolations } from '@objectstack/spec/api'; + +import { HonoHttpServer } from './adapter'; + +/** What each stand-in door saw and did. */ +interface Seen { received: unknown[]; writes: number } + +function dataDoors(): { server: HonoHttpServer; seen: Seen } { + const server = new HonoHttpServer(0); + const seen: Seen = { received: [], writes: 0 }; + + // `POST /data/:object/query`: reads the body inside a try whose catch + // answers its own 500 — the adapter's refusal must still be the answer. + server.post('/api/v1/data/:object/query', (req, res) => { + try { + const query = req.body; + seen.received.push(query); + res.status(200); + res.json({ object: req.params.object, query }); + } catch { + res.status(500); + res.json({ success: false, error: { code: 'INTERNAL_ERROR', message: 'handler fault' } }); + } + }); + + // `PATCH /data/:object/:id`: a write that happens only after the body is read. + server.patch('/api/v1/data/:object/:id', (req, res) => { + const data = req.body; + seen.writes += 1; + seen.received.push(data); + res.status(200); + res.json({ id: req.params.id, record: data }); + }); + + // `POST /data/:object`: a create whose required-field check would answer a + // misleading `400 VALIDATION_FAILED` for a body read as `{}` (step 6). + server.post('/api/v1/data/:object', (req, res) => { + const data = (req.body ?? {}) as Record; + if (!data.name) { + res.status(400); + res.json({ error: 'Account Name is required', code: 'VALIDATION_FAILED' }); + return; + } + seen.writes += 1; + res.status(201); + res.json({ record: data }); + }); + + // A byte door, as the storage raw PUT reads it: `rawBody()` first, and + // `req.body` never touched. + server.put('/api/v1/storage/_local/raw/:token', async (req, res) => { + const bytes = await req.rawBody!(); + res.status(200); + res.json({ size: bytes.byteLength, text: bytes.toString('utf8') }); + }); + + server.installNotFoundSeam(); + return { server, seen }; +} + +/** + * `bytes` sends the body as a `Uint8Array`: undici stamps + * `text/plain;charset=UTF-8` on any STRING body that declares no type, so a + * request with no `Content-Type` at all can only be built from bytes. + */ +const send = ( + server: HonoHttpServer, + method: string, + path: string, + init: { contentType?: string; body?: string; length?: boolean; bytes?: boolean } = {}, +) => { + const headers: Record = {}; + if (init.contentType) headers['content-type'] = init.contentType; + if (init.length && init.body !== undefined) headers['content-length'] = String(Buffer.byteLength(init.body)); + const body = init.bytes && init.body !== undefined ? new TextEncoder().encode(init.body) : init.body; + return server.getRawApp().fetch(new Request(`http://localhost${path}`, { method, headers, body })); +}; + +/** The declared refusal, read whole: status, envelope, code, and a message that echoes nothing. */ +async function expectRefused(res: Response, body: string, names: RegExp) { + const json = await res.json() as any; + const shown = JSON.stringify(json); + expect(res.status, shown).toBe(400); + expect(envelopeViolations(json), shown).toEqual([]); + expect(ApiErrorSchema.safeParse(json.error).success, shown).toBe(true); + expect(json.success).toBe(false); + expect(json.error.code).toBe('INVALID_REQUEST'); + expect(json.error.message).toMatch(names); + // Never echoes the body — V8's own parse error quotes it (`"{ not json" is + // not valid JSON`), which is why the message is the adapter's, not the + // parser's. Neither the body nor any distinctive word of it is in it. + expect(json.error.message).not.toContain(body.trim()); + for (const word of body.split(/[^A-Za-z]+/).filter((w) => w.length >= 5)) { + expect(json.error.message, `echoes "${word}"`).not.toContain(word); + } +} + +const QUERY = '/api/v1/data/showcase_account/query'; +const VALID = '{"where": {"status": "churned"}}'; + +describe('[#23914] the card\'s bodies are refused 400 at the door, and the handler never acts on them', () => { + it('step 2 — a truncated query body', async () => { + const { server, seen } = dataDoors(); + const body = '{"where": {"status": "churned"}'; + await expectRefused(await send(server, 'POST', QUERY, { contentType: 'application/json', body }), body, /parsed as JSON/); + expect(seen.received).toEqual([]); + }); + + it('step 3 — `{ not json`', async () => { + const { server, seen } = dataDoors(); + const body = '{ not json'; + await expectRefused(await send(server, 'POST', QUERY, { contentType: 'application/json', body }), body, /parsed as JSON/); + expect(seen.received).toEqual([]); + }); + + it('step 4 — a valid query body sent as text/plain', async () => { + const { server, seen } = dataDoors(); + await expectRefused( + await send(server, 'POST', QUERY, { contentType: 'text/plain;charset=UTF-8', body: VALID, length: true }), + VALID, + /sent as text\/plain/, + ); + expect(seen.received).toEqual([]); + }); + + it('step 5 — a truncated PATCH writes nothing', async () => { + const { server, seen } = dataDoors(); + const body = '{"name": "HACKED"'; + await expectRefused( + await send(server, 'PATCH', '/api/v1/data/showcase_account/rec_1', { contentType: 'application/json', body }), + body, + /parsed as JSON/, + ); + expect(seen.writes).toBe(0); + }); + + it('step 6 — a truncated create answers the parse refusal, not the required-field one', async () => { + const { server, seen } = dataDoors(); + const body = '{"name": "Door Truncated Create"'; + const res = await send(server, 'POST', '/api/v1/data/showcase_account', { contentType: 'application/json', body }); + await expectRefused(res, body, /parsed as JSON/); + expect(seen.writes).toBe(0); + }); + + it('a declared JSON media type is matched case-insensitively', async () => { + const { server } = dataDoors(); + const body = '{ not json'; + await expectRefused(await send(server, 'POST', QUERY, { contentType: 'Application/JSON', body }), body, /parsed as JSON/); + }); + + it('a body with no Content-Type at all is refused, not read as empty', async () => { + const { server } = dataDoors(); + await expectRefused(await send(server, 'POST', QUERY, { body: VALID, length: true, bytes: true }), VALID, /no Content-Type/); + }); + + it('a form body that does not parse is refused', async () => { + const { server } = dataDoors(); + const body = '--nope\r\nnot a part'; + await expectRefused( + await send(server, 'POST', QUERY, { contentType: 'multipart/form-data; boundary=elsewhere', body }), + body, + /form data/, + ); + }); +}); + +describe('[#23914] CONTROL — what this adapter can read answers as before', () => { + it('a valid JSON body reaches the handler parsed', async () => { + const { server, seen } = dataDoors(); + const res = await send(server, 'POST', QUERY, { contentType: 'application/json', body: VALID }); + expect(res.status).toBe(200); + expect(seen.received).toEqual([{ where: { status: 'churned' } }]); + }); + + it('an empty or whitespace-only JSON body is `{}` — the documented empty query', async () => { + const { server, seen } = dataDoors(); + expect((await send(server, 'POST', QUERY, { contentType: 'application/json', body: '' })).status).toBe(200); + expect((await send(server, 'POST', QUERY, { contentType: 'application/json', body: ' \n ' })).status).toBe(200); + expect(seen.received).toEqual([{}, {}]); + }); + + it('a request with no body is `{}`, whatever it declares', async () => { + const { server, seen } = dataDoors(); + expect((await send(server, 'POST', QUERY)).status).toBe(200); + expect((await send(server, 'POST', QUERY, { contentType: 'text/plain', body: '', length: true })).status).toBe(200); + expect(seen.received).toEqual([{}, {}]); + }); + + it('a urlencoded form body reaches the handler parsed', async () => { + const { server, seen } = dataDoors(); + const res = await send(server, 'POST', QUERY, { contentType: 'application/x-www-form-urlencoded', body: 'a=1&b=two' }); + expect(res.status).toBe(200); + expect(seen.received).toEqual([{ a: '1', b: 'two' }]); + }); + + it('a valid PATCH writes', async () => { + const { server, seen } = dataDoors(); + const res = await send(server, 'PATCH', '/api/v1/data/showcase_account/rec_1', { contentType: 'application/json', body: '{"name":"Door"}' }); + expect(res.status).toBe(200); + expect(seen.writes).toBe(1); + }); + + it.each([ + ['a JSON-with-comments file sent as application/json', 'application/json', '{\n // a comment\n "a": 1,\n}\n'], + ['a text file sent as text/plain', 'text/plain', 'plain text bytes\n'], + ['bytes sent as application/octet-stream', 'application/octet-stream', 'raw bytes'], + ])('byte door — %s is stored byte for byte', async (_label, contentType, body) => { + const { server } = dataDoors(); + const res = await send(server, 'PUT', '/api/v1/storage/_local/raw/tok', { contentType, body, length: true }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ size: Buffer.byteLength(body), text: body }); + }); +}); + +describe('[#23914] the fallback seam', () => { + it('a fallback that declines without reading the body leaves the 404', async () => { + const { server } = dataDoors(); + server.setFallbackHandler(() => { /* not mine */ }); + const res = await send(server, 'POST', '/api/v1/nowhere', { contentType: 'application/json', body: '{ not json' }); + expect(res.status).toBe(404); + }); + + it('a fallback that reads the body is refused like a route', async () => { + const { server } = dataDoors(); + let ran = false; + server.setFallbackHandler((req, res) => { ran = true; res.status(200); res.json({ body: req.body }); }); + const body = '{ not json'; + await expectRefused(await send(server, 'POST', '/api/v1/apps/x/y', { contentType: 'application/json', body }), body, /parsed as JSON/); + expect(ran).toBe(true); + }); +}); + +describe('[#23914] on a real socket, where the client frames the body', () => { + const opened: HonoHttpServer[] = []; + afterEach(async () => { while (opened.length > 0) await opened.pop()!.close(); }); + + async function base(): Promise<{ url: string; seen: Seen }> { + const { server, seen } = dataDoors(); + opened.push(server); + await server.listen(0); + return { url: `http://127.0.0.1:${server.getPort()}`, seen }; + } + + it('step 4 over the wire: a JSON body sent as text/plain is refused', async () => { + const { url, seen } = await base(); + const res = await fetch(`${url}${QUERY}`, { method: 'POST', headers: { 'content-type': 'text/plain' }, body: VALID }); + await expectRefused(res, VALID, /sent as text\/plain/); + expect(seen.received).toEqual([]); + }); + + it('CONTROL over the wire: a POST with no body is `{}`', async () => { + const { url, seen } = await base(); + const res = await fetch(`${url}${QUERY}`, { method: 'POST' }); + expect(res.status).toBe(200); + expect(seen.received).toEqual([{}]); + }); +}); diff --git a/packages/plugins/plugin-hono-server/src/request-body.ts b/packages/plugins/plugin-hono-server/src/request-body.ts new file mode 100644 index 0000000000..492b830826 --- /dev/null +++ b/packages/plugins/plugin-hono-server/src/request-body.ts @@ -0,0 +1,167 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The body a route handler reads as `req.body`: parsed by its declared + * content type, or REFUSED, never invented (#23914). + * + * Module-private on purpose: nothing here is re-exported from this package's + * entry. `@objectstack/hono`'s `createHonoApp` answers the same refusal with + * the same `code` and `message`, and a test there drives both doors with one + * body and asserts the two answers are equal. + * + * ## The defect + * + * The adapter used to build `body` as `{}` and overwrite it only on success: + * a declared `application/json` body that did not parse fell through + * `c.req.json()` and then `c.req.parseBody()` (which answers `{}` for that + * media type) with both failures swallowed, and every other media type went + * to `parseBody()`, which answers `{}` without reading anything. So the + * handler received `{}`, a value indistinguishable from a real empty object. + * Measured on a fresh `objectstack dev` showcase boot, `main` `2054a0888c`: + * + * POST /api/v1/data/showcase_account/query + * {"where": {"status": "churned"}} 200, 2 rows + * the same, closing brace missing 200, 14 rows (every row) + * { not json 200, 14 rows + * the valid body sent as text/plain 200, 14 rows + * PATCH /api/v1/data/showcase_account/ID + * {"name": "HACKED" (truncated) 200, no field changed, updated_at bumped + * POST /api/v1/data/showcase_account + * {"name": "…" (truncated) 400 VALIDATION_FAILED "Account Name is required" + * + * The query door is the silent-WIDENING class the api-backend checklist names + * (`api-backend.query-contract-matrix`): "a malformed query body must never + * degrade into an unfiltered 200 full read". + * + * ## What a reading answers + * + * - **Declared JSON** (`application/json`, matched case-insensitively): the + * body is read as text and parsed. Empty or whitespace-only is `{}`, as + * before; the REST data doors document that an empty body is an empty query. + * Anything else that does not parse is a refusal. + * - **A form** (`multipart/form-data`, `application/x-www-form-urlencoded`): + * parsed as before. A parse failure is a refusal. + * - **Any other media type, or none** (`text/plain`, + * `application/octet-stream`, …): nothing is read here, as before. When the + * request carries a body by its framing headers, reading `req.body` is a + * refusal; when it carries none, `req.body` is `{}`, as before. + * + * ## Why the refusal is raised when `req.body` is READ, not before the handler + * + * One transport serves two kinds of route, and only a route knows which kind + * it is. A JSON door reads `req.body`. A byte door reads `req.rawBody()` and + * never touches `req.body`, and it takes whatever media type the file has. + * Measured on the same boot: the storage presign answers + * `headers: { "content-type": "application/json" }` for a `.json` file, so + * the raw PUT of a JSON-with-comments file (a `tsconfig.json`) arrives as + * declared JSON that does not parse, and it is stored, `200`; a `.txt` file + * arrives as `text/plain`, `200`. A refusal before the handler would turn both + * uploads into `400`s. Raised on read, the refusal reaches exactly the + * handlers that would otherwise have acted on an invented `{}`. + * + * The thrown value stops the handler at the read. The adapter then answers + * with this refusal, whatever the handler made of the throw (see `runHandler` + * in `adapter.ts`): the request's body was never readable, so no other answer + * describes it. + * + * ## Why a body is judged present by its framing headers + * + * RFC 9112 §6.3: a request carries a body when it declares `Content-Length` + * or `Transfer-Encoding`. Reading the bytes to find out would buffer an upload + * before the storage routes' declared-length check refuses it (#22283), and + * `Request.body` cannot answer either: `@hono/node-server` hands every + * non-GET/HEAD request a stream, an empty one included. An in-process + * `Request` built without those headers is therefore read as having no body. + * + * The message names the failure and never echoes the body. + */ + +import type { Context } from 'hono'; + +/** + * The refusal, as a DECLARED ADR-0112 throw: `status` and a registered `code` + * (`INVALID_REQUEST` is on `ERROR_CODE_LEDGER`), so `declaredEnvelopeForThrow` + * renders it, and so does any handler's own classification it passes through. + */ +export class RequestBodyRefusal extends Error { + readonly status = 400; + readonly code = 'INVALID_REQUEST'; + + constructor(message: string) { + super(message); + this.name = 'RequestBodyRefusal'; + } +} + +export type RequestBodyReading = + | { readonly body: unknown; readonly refusal?: undefined } + | { readonly refusal: RequestBodyRefusal }; + +/** The refusal tail every message ends with: what the transport did NOT do. */ +const NOT_EMPTY = 'It was refused rather than read as an empty body'; + +export const MALFORMED_JSON_BODY_MESSAGE = + `The request body could not be parsed as JSON (it was sent as application/json). ${NOT_EMPTY}: ` + + 'send a well-formed JSON document, or no body at all.'; + +export const UNREADABLE_BODY_MESSAGE = `The request body could not be read. ${NOT_EMPTY}.`; + +/** A media type as the client spelled it, echoed only when it is a plain token pair. */ +function namedMediaType(mediaType: string): string { + if (mediaType === '') return 'with no Content-Type'; + return /^[a-z0-9][a-z0-9!#$&^_.+-]{0,63}\/[a-z0-9][a-z0-9!#$&^_.+-]{0,63}$/.test(mediaType) + ? `as ${mediaType}` + : 'with an unrecognised Content-Type'; +} + +function malformedFormMessage(mediaType: string): string { + return `The request body could not be parsed as form data (it was sent ${namedMediaType(mediaType)}). ${NOT_EMPTY}.`; +} + +function unreadMediaTypeMessage(mediaType: string): string { + return `The request body was sent ${namedMediaType(mediaType)}, which this server does not read into a ` + + `request body. ${NOT_EMPTY}: send it as application/json.`; +} + +/** RFC 9112 §6.3 — see the module note. A `Content-Length` that is not `0` counts as a body. */ +function declaresBody(c: Context): boolean { + const length = c.req.header('content-length'); + if (length !== undefined && length.trim() !== '') return Number(length) !== 0; + return c.req.header('transfer-encoding') !== undefined; +} + +export async function readRequestBody(c: Context): Promise { + const lowered = (c.req.header('content-type') ?? '').toLowerCase(); + + if (lowered.includes('application/json')) { + // `text()` fills the same Hono body cache `json()` did (`json()` is + // `text()` then `JSON.parse`), so `rawBody()` still reads the bytes. + let text: string; + try { + text = await c.req.text(); + } catch { + return { refusal: new RequestBodyRefusal(UNREADABLE_BODY_MESSAGE) }; + } + if (text.trim() === '') return { body: {} }; + try { + return { body: JSON.parse(text) }; + } catch { + return { refusal: new RequestBodyRefusal(MALFORMED_JSON_BODY_MESSAGE) }; + } + } + + const mediaType = lowered.split(';')[0]!.trim(); + if (mediaType === 'multipart/form-data' || mediaType === 'application/x-www-form-urlencoded') { + try { + return { body: await c.req.parseBody() }; + } catch { + return { refusal: new RequestBodyRefusal(malformedFormMessage(mediaType)) }; + } + } + + // Not read here: `application/octet-stream` stays consumable through + // `req.rawBody()`, and so does every other media type. + return declaresBody(c) + ? { refusal: new RequestBodyRefusal(unreadMediaTypeMessage(mediaType)) } + : { body: {} }; +} diff --git a/packages/qa/http-conformance/src/hono-body-refusal.conformance.test.ts b/packages/qa/http-conformance/src/hono-body-refusal.conformance.test.ts new file mode 100644 index 0000000000..fd22a7fe9a --- /dev/null +++ b/packages/qa/http-conformance/src/hono-body-refusal.conformance.test.ts @@ -0,0 +1,184 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#23914] Through a REAL `createHonoApp` host over the REAL dispatcher and + * data domain: a declared JSON body that does not parse is refused `400 + * INVALID_REQUEST` by its READER, after the dispatcher's gates, with the same + * answer `@objectstack/plugin-hono-server` gives — and never handed to the + * data domain as `{}`. + * + * ## What this pins + * + * 1. The card's bodies at the data door — a truncated query, `{ not json`, a + * truncated `PATCH`, a truncated create — each answer the adapter's + * refusal; the store is read back after the writes and is unchanged. + * 2. Parity: the same body to a `HonoHttpServer` route that reads `req.body` + * answers the same status and the same bytes. + * 3. Order: the dispatcher's gates run before the refusal. With the ADR-0069 + * gate active and its session read failing, a truncated query answers the + * gate's `503`, not the body's `400` — the body is refused by its reader, + * and the gate stops the request before any reader runs. + * 4. CONTROL: a valid query filters, and an empty body is the empty query. + * + * A domain that forwards the request and reads its own bytes is NOT refused + * by the adapter: `hono-webhooks-redeliver.conformance.test.ts` holds the + * hosted door's `/webhooks/redeliver` answer to a body that is not JSON byte + * for byte equal to the self-hosted mount's (the member's own refusal). + * + * `@objectstack/hono` resolves to this checkout's adapter SOURCE (the + * subject); `@objectstack/runtime` and `@objectstack/plugin-hono-server` + * through their `dist/`, as in the sibling `hono-*.conformance.test.ts` files. + * WHO the caller is comes from the dispatcher's identity step, replaced on the + * prototype with one member, as in `hono-declared-envelope.conformance.test.ts`. + */ + +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { LiteKernel } from '@objectstack/core'; +import { ObjectQLPlugin } from '@objectstack/objectql'; +import type { ObjectQL } from '@objectstack/objectql'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; +import { HttpDispatcher } from '@objectstack/runtime'; +import { HonoHttpServer } from '@objectstack/plugin-hono-server'; +import { createHonoApp } from '@objectstack/hono'; + +const PREFIX = '/api/v1'; +const MEMBER = { userId: 'u_member', isSystem: false, systemPermissions: [] as string[] }; +const TASK = { + name: 'task', + label: 'Task', + fields: { + name: { name: 'name', type: 'text' as const, label: 'Name' }, + status: { name: 'status', type: 'text' as const, label: 'Status' }, + }, +}; +const REFUSAL = /^The request body could not be parsed as JSON/; + +const liveKernels: LiteKernel[] = []; +afterEach(async () => { + vi.restoreAllMocks(); + while (liveKernels.length) { + const kernel = liveKernels.pop(); + await Promise.race([ + kernel?.shutdown(), + new Promise((resolve) => setTimeout(resolve, 10_000)), + ]); + } +}); + +/** The real store, the real protocol over it, and the REAL `createHonoApp` over both. */ +async function boot(auth?: Record) { + const driver = new SqliteWasmDriver({ filename: ':memory:' }); + await driver.connect(); + const kernel = new LiteKernel(); + liveKernels.push(kernel); + kernel.use({ + name: 'test-body-refusal-services', + version: '1.0.0', + init: (c: any) => { + c.registerService('driver.memory', driver); + if (auth) c.registerService('auth', auth); + }, + } as any); + kernel.use(new ObjectQLPlugin()); + await kernel.bootstrap(); + + const ql = kernel.getService('objectql'); + ql.registerObject(TASK as any); + await ql.syncObjectSchema(TASK.name); + + vi.spyOn(HttpDispatcher.prototype as any, 'timedResolveExecutionContext') + .mockImplementation(async () => ({ ...MEMBER })); + + const app = createHonoApp({ kernel: kernel as any, prefix: PREFIX, cors: false }); + /** A request whose body is sent exactly as written, declared as JSON. */ + const send = async (method: string, path: string, body?: string) => { + const res = await app.request(`http://localhost${PREFIX}${path}`, { + method, + headers: body === undefined ? {} : { 'content-type': 'application/json' }, + ...(body === undefined ? {} : { body }), + }); + return { status: res.status, text: await res.text() }; + }; + const seed = async (name: string, status: string): Promise => String(((await ql.insert('task', { name, status })) as any).id); + const rows = async (): Promise => (await ql.find('task', {})) ?? []; + return { send, seed, rows }; +} + +/** The records a data answer carries, wherever its envelope puts them. */ +function recordsOf(text: string): unknown[] { + const body = JSON.parse(text); + const data = body?.data ?? body; + const records = data?.records ?? data?.value ?? data; + return Array.isArray(records) ? records : []; +} + +function expectRefused(answer: { status: number; text: string }, sent: string) { + expect(answer.status, answer.text).toBe(400); + expect(JSON.parse(answer.text)).toEqual({ + success: false, + error: { code: 'INVALID_REQUEST', message: expect.stringMatching(REFUSAL) }, + }); + expect(answer.text).not.toContain(sent.trim()); +} + +describe('[#23914] a real createHonoApp host: the data domain\'s read of an unparseable body is refused', () => { + it('step 2 / step 3 — a truncated query and `{ not json` answer the refusal, not every row', async () => { + const host = await boot(); + await host.seed('a', 'churned'); + await host.seed('b', 'active'); + for (const sent of ['{"where": {"status": "churned"}', '{ not json']) { + expectRefused(await host.send('POST', '/data/task/query', sent), sent); + } + }); + + it('step 5 — a truncated PATCH answers the refusal, and the stored row is unchanged', async () => { + const host = await boot(); + const id = await host.seed('kept', 'active'); + const before = await host.rows(); + const sent = '{"name": "HACKED"'; + expectRefused(await host.send('PATCH', `/data/task/${id}`, sent), sent); + expect(await host.rows()).toEqual(before); + }); + + it('step 6 — a truncated create answers the refusal, not a validation error, and writes nothing', async () => { + const host = await boot(); + const sent = '{"name": "Door Truncated Create"'; + expectRefused(await host.send('POST', '/data/task', sent), sent); + expect(await host.rows()).toEqual([]); + }); + + it('parity — a HonoHttpServer route that reads req.body answers the same status and the same bytes', async () => { + const host = await boot(); + const server = new HonoHttpServer(0); + server.post(`${PREFIX}/data/:object/query`, (req, res) => { res.json({ query: req.body }); }); + for (const sent of ['{"where": {"status": "churned"}', '{ not json']) { + const hosted = await host.send('POST', '/data/task/query', sent); + const direct = await server.getRawApp().fetch(new Request(`http://direct.test${PREFIX}/data/task/query`, { + method: 'POST', headers: { 'content-type': 'application/json' }, body: sent, + })); + expect({ status: direct.status, text: await direct.text() }).toEqual(hosted); + } + }); + + it('order — the dispatcher\'s gates run before the refusal: an active ADR-0069 gate whose session read fails answers 503', async () => { + const host = await boot({ + isAuthGateActive: () => true, + api: { getSession: async () => { throw new Error('session store down'); } }, + }); + const answer = await host.send('POST', '/data/task/query', '{"where": {"status": "churned"}'); + expect(answer.status, answer.text).toBe(503); + expect(JSON.parse(answer.text)).toMatchObject({ success: false, error: { code: 'SERVICE_UNAVAILABLE' } }); + }); + + it('CONTROL — a valid query filters, and an empty body is the empty query', async () => { + const host = await boot(); + await host.seed('a', 'churned'); + await host.seed('b', 'active'); + const filtered = await host.send('POST', '/data/task/query', '{"where": {"status": "churned"}}'); + expect(filtered.status, filtered.text).toBe(200); + expect(recordsOf(filtered.text)).toHaveLength(1); + const empty = await host.send('POST', '/data/task/query', ''); + expect(empty.status, empty.text).toBe(200); + expect(recordsOf(empty.text)).toHaveLength(2); + }); +});