Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .changeset/23914-cloud-connection-body-refusal.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, no stored row is rewritten or deleted, and no exported declaration is added, removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is the accept set of four of this package's own routes for one input: a body declared as JSON that does not parse, which used to be read as no body. The remedy is in the requests a client sends, never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->

**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.
27 changes: 27 additions & 0 deletions .changeset/23914-hono-body-refusal.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, no stored row is rewritten or deleted, and no exported declaration is added, removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is the accept set of the `createHonoApp` catch-all and its `/auth/*` fallback for one input: a body declared as JSON that does not parse, which used to be dispatched as `{}` and is now refused when a domain reads it. The remedy is in the requests a client sends, never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->

**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.
29 changes: 29 additions & 0 deletions .changeset/23914-hono-server-body-refusal.md
Original file line number Diff line number Diff line change
@@ -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)

<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, object definition or stored shape is added, removed, renamed or re-shaped, no stored row is rewritten or deleted, and no exported declaration is added, removed or narrowed, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is the accept set of the HTTP transport for request bodies it cannot parse: a malformed declared-JSON body, a malformed form, or a body in a media type it does not read, each of which used to reach the handler as `{}`. The remedy is in the requests a client sends, never a rewrite of a package source or a stored row. The other categories are closed on facts: the package publishes (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered); and nothing exported is removed or narrowed (not runtime-interface-only, type-surface-only or runtime-export-retired). -->

**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.
186 changes: 186 additions & 0 deletions packages/adapters/hono/src/hono-body-refusal.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>).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));
});
});
Loading
Loading