Skip to content

Commit 26aa999

Browse files
fix(runtime,hono): an exact /approvals/act dispatcher domain for kernels with no raw app, and a catch-all that leaves the raw request readable (#22693)
Fixes #22576 Clause-②: no Segment 2 of ruling A on #22438 (maintainer ruling recorded verbatim as 「A + 扫类」). A hosted tenant kernel owns no socket, so it has no raw Hono app for `plugin-approvals` to mount the ADR-0043 action page on: on that shape `GET`/`POST /api/v1/approvals/act` answered `404 ROUTE_NOT_FOUND`, and every approve / reject link in every approval message was dead. This PR gives the dispatcher that route and makes the Hono catch-all stop eating the body the route needs. ## What changed **`@objectstack/runtime`** - New dispatcher domain `packages/runtime/src/domains/approvals.ts`, registered in `HttpDispatcher` the way `/auth` is (ADR-0076 D11: the dispatcher owns registration, one route bridges to one service slot). It claims exactly `/approvals/act` (`match: 'exact'`) for `GET`, `HEAD` and `POST`, resolves the request kernel's `approvals` slot, and forwards `context.request` unread to `IApprovalService.handleActionPage`, returning that `Response` untouched. - `HEAD`: forwarded to the member as a `GET` and answered with that page's status and headers and no body, which is what the self-hosted mount answers (measured, below). It renders; it never decides. - Absent slot, or a service without `handleActionPage`: `501 NOT_IMPLEMENTED` (an existing standard code), with a message naming which. Never `ROUTE_NOT_FOUND`. - A member that throws: sanitised `500 INTERNAL_ERROR`, original error logged (the `/auth` discipline). A `POST` whose body a transport already consumed: also `500`, logged, and the member is not asked; otherwise the member would show the approver an "invalid link" page for a live link. - `enforceProjectMembership` skips the exact act path (unscoped or `/environments/:id`-scoped), and only it. See decision 3. - `route-ledger.ts`: two `public` rows (`GET`/`POST /approvals/act`), census 82 to 84. **`@objectstack/hono`** - The `${prefix}/*` catch-all and the `/auth/*` legacy fallback parse the JSON body from `c.req.raw.clone()` instead of `c.req.json()`, so `context.request` reaches the dispatcher with its body unread, for every content type, JSON included. The parsed `body` domains get is unchanged for every input (same `text()` then `JSON.parse`, same `{}` on any failure). A body something upstream already read through `c.req` falls back to `c.req.json()` (Hono's cache), as before. **Pins and bookkeeping (no published surface)** - `scripts/check-route-envelope.mjs`: declares `approvals.ts` with `handBuilt: 0`. - `packages/qa/http-conformance`: devDependency on `@objectstack/plugin-approvals` (aliased to source in its vitest config) so one suite can boot the real plugin on both doors; `pnpm-lock.yaml` gains the 3-line importer link. - `packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts`: the two new rows are pinned as unobservable on the self-hosted showcase boot, where `plugin-approvals` serves the path from `rawApp.get`/`rawApp.post`, never through the `IHttpServer` port (the same reason the `/auth/**` row is pinned). No new error code, no new package-entry export (the domain module is not exported from `@objectstack/runtime`'s index), no spec, `packages/core` or `plugin-approvals` source change. ## The claim's four decisions, as measured 1. **Hono, option A.** Premise "the parsed body domains see is unchanged" is pinned per input against a reference Hono app running the old line: spaced JSON object, array, `null`, number, string, BOM-prefixed JSON, JSON sent as `text/plain`, invalid JSON, empty body, form body, plain text, no content type. All unchanged; every one now also reads back byte for byte from the request at dispatch. Nothing measured broke. 2. **HEAD.** Measured first on the real composition (real `HonoServerPlugin` socket + real `ApprovalsServicePlugin` raw-app mount): `HEAD /api/v1/approvals/act` answers `200`, the `GET` page's headers minus `content-length`, empty body, for a live and a dead token. The hosted door (real `createHonoApp`, real dispatcher, real member) answers identically. One conformance pin holds both doors equal. 3. **Membership gate.** Premise measured TRUE before any skip existed: through `dispatch()` on a multi-tenant host, a signed-in caller who is not a member of the resolved environment got `403 PROJECT_MEMBERSHIP_REQUIRED` and the member never ran, while an anonymous caller with the same link reached it. So the exact act path joined the skip; pins cover GET, POST, the scoped spelling, an anonymous control, and five neighbours that are still checked (`/approvals/act/x`, `/approvals/actx`, `/approvals`, `/approvals/requests`, `/data/task`). No other auth change. 4. **Cross-segment ablation.** On the real `ApprovalService`, `handleActionPage` removed on purpose: the hosted door answers `501 NOT_IMPLEMENTED` on GET, HEAD and POST; with no `approvals` slot: `501 NOT_IMPLEMENTED`, while the control path `/approvals/actx` on the same door is the route miss `404 ROUTE_NOT_FOUND`. ## Pins - `packages/runtime/src/domains/approvals-act.test.ts` (12): real `HttpDispatcher` on a kernel with no `http.server` and a stub member that reads its own token. - `packages/adapters/hono/src/hono-catch-all-raw-request.test.ts` (17): the catch-all's preservation and raw-body halves, PUT/PATCH, GET/DELETE unchanged, the upstream-consumed fallback, the `/auth/*` fallback. - `packages/qa/http-conformance/src/hono-approvals-act.conformance.test.ts` (4): the real plugin on both doors; a form POST through the hosted door redeems a live token (the decision lands in the store), HEAD parity, the cross-segment 501s. ## Ablations (one-off, committed state, restore proven by blob hash equal to HEAD and empty `git diff HEAD`) - Hono fix reverted to `c.req.json()` in the catch-all: 14 of 17 hono pins red on `bodyUsed` (preservation assertions held), 3 unrelated green. - Domain registration removed, runtime `dist/` rebuilt, `ablation-dist-preflight --absent` green: all 4 hosted conformance pins red with `404`; the runtime suite on source shows the body: `ROUTE_NOT_FOUND`, "Route Not Found: /approvals/act" (11 of 12 red, the neighbour control green). Restore leg: rebuilt, marker present in 2 dist files, mutation marker absent, 4 of 4 green. - Dogfood pin key renamed: the parity gate reports `GET /approvals/act — LEDGERED BUT NOT MOUNTED` plus the stale pin (2 of 8 red), so the pin is load-bearing. ## Verification Measured on ``427772e14``, origin/main `ee3ae0360` merged in. - `@objectstack/runtime`: `local` project in three chunks, 143 + 106 + 97 files, 2569 + 1281 + 1071 tests passed (19 skipped); `repo` project 3 files, 751 passed; `typecheck` (src + test layer) 0. - `@objectstack/hono`: `test` 6 files, 139 passed; `typecheck` 0. - `@objectstack/http-conformance`: 10 files, 110 passed; `typecheck` 0. - `@objectstack/dogfood`: `route-ledger-live-mount-parity.dogfood.test.ts` 8 of 8; `typecheck` 0. The rest of that suite is CI's. - Gates: all 100 families `dispatch-gates --commands` derives for this diff (the dispatch list's 49 plus 51 the diff adds) exit 0 at `427772e14`; `dispatch-gates --ran` with each exit recorded: 100 derived, 100 run, 0 NOT-MEASURED, 0 UNRUN. Two needed a prerequisite on the way, now met: `check-plugin-teardown-shape --self-test` needed its pinned fixture commit `621a48760` fetched into this shallow checkout, and `check:dual-build-cjs-loads` needed the whole workspace built (107 entries across 66 packages load). Two were red on this diff and fixed in it: `check:route-envelope` (the new domain file declared) and `check:route-ledger-census` (82 to 84). - Lint, narrowed and proven: the population is `eslint.config.mjs`'s own `files` globs minus `NEVER_LINTED`; `eslint --no-inline-config --format json` over the 10 touched lintable files reports 10 files, 0 errors, 0 warnings (none ignored). Invariance: the config enables no type-aware linting (no `parserOptions.project`, no typed rules, stated in the config itself), and the diff touches none of the config's own inputs, so it cannot move the verdict of an untouched file. The full `pnpm lint` is CI's. ## Acceptance notes - The question on AGENTS.md route rule 5 (closed query-parameter set). Measured on both doors: `?token=LIVE&utm_source=mail&zzz=1` renders the same confirm page as `?token=LIVE`, `200`. The self-hosted mount reads `token` and ignores the rest, and so does the member; this domain reads no query parameter at all. Rule 5's subject is a REST handler that reads `req.query` and can silently drop a filter. Here the reader is the member, and a mail link may legitimately carry parameters a gateway or tracker appended. So no closed set is invented here; whether the member should declare one is the approvals contract's call. - Methods other than GET/HEAD/POST on the act path: the hosted door answers the dispatcher's `404 ROUTE_NOT_FOUND`, the self-hosted door plugin-hono-server's `404 ENDPOINT_NOT_FOUND`. Both are 404 with different codes. Pre-existing on the self-hosted side, out of this card's scope. - The scoped spelling `/api/v1/environments/:id/approvals/act` reaches the domain through the dispatcher's generic scope strip; the self-hosted mount has no such spelling. The membership skip and the ADR-0069 allow-list (#22577's) both treat it like the unscoped path. - Cost of option A: a body-carrying request through the catch-all is buffered twice for the life of the request (the tee), and on `@hono/node-server` the direct-read fast path is no longer taken for POST/PUT/PATCH. These are API bodies the old path already buffered whole. - The log window noted by segment 4 (#22578): the plugin's hosted log line says the pages "are served through the HTTP dispatcher's /approvals/act domain". That sentence becomes true when this lands. - The branch was not re-merged after `ee3ae0360`; the commits since touch none of these files. CI's merge ref covers the rest. --- _Generated by [Claude Code](https://claude.ai/code/session_01HGa2XMKBRUqPrZFxQavBwH)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 61bea24 commit 26aa999

17 files changed

Lines changed: 1120 additions & 24 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/runtime': patch
3+
'@objectstack/hono': patch
4+
---
5+
6+
fix(runtime,hono): approval e-mail links work on a kernel with no raw HTTP app — the dispatcher serves `/approvals/act`, and the Hono catch-all no longer consumes request bodies
7+
8+
Clause-②: no
9+
10+
On a self-hosted server, `@objectstack/plugin-approvals` serves the ADR-0043 action page (the approve / reject link in an approval e-mail or IM message) by mounting `GET` and `POST /api/v1/approvals/act` on the raw Hono app. A hosted tenant kernel has no raw app, so on that shape the path answered `404 ROUTE_NOT_FOUND` and every action link was dead.
11+
12+
**`@objectstack/runtime`.** `HttpDispatcher` now serves exactly `/approvals/act` (no sub-paths) for `GET`, `HEAD` and `POST`. It forwards the request, unread, to the request kernel's `approvals` service (`IApprovalService.handleActionPage`) and returns that `Response` as it is.
13+
14+
- `HEAD` answers as the self-hosted mount does: the `GET` page's status and headers, with no body. It renders and never decides.
15+
- No `approvals` service registered, or one without `handleActionPage`: `501 NOT_IMPLEMENTED`, naming which. Never `ROUTE_NOT_FOUND`.
16+
- A member that throws: a sanitised `500 INTERNAL_ERROR`, with the original error in the server log. The same answer, logged, when a transport hands over a `POST` whose body was already read, instead of an "invalid link" page for a live link.
17+
- The token is the only credential, as on the self-hosted mount. The project-membership gate now skips the exact act path (unscoped or `/environments/:id`-scoped), so a signed-in caller who is not a member of the environment reaches the page like an anonymous one instead of getting `403 PROJECT_MEMBERSHIP_REQUIRED`. Every other path, `/approvals/act/...` and `/approvals/requests/...` included, is checked as before.
18+
19+
**`@objectstack/hono`.** The `createHonoApp` catch-all (and the `/auth/*` fallback) read the JSON body with `c.req.json()`, which consumed the raw request for every `Content-Type`. A domain that forwarded the request got it empty. The body is now parsed from a clone, so `context.request` reaches the dispatcher unread, byte for byte. The parsed `body` the domains receive is unchanged for every input: JSON values as parsed, and `{}` for an empty, form, text or invalid body. The cost is that a request body is buffered twice for the life of the request.
20+
21+
Nothing changes on a self-hosted server: the plugin's raw-app mount still serves the path there and never reaches the dispatcher.
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#22576] The `${prefix}/*` catch-all hands `dispatch()` the raw request with
5+
* its body UNREAD, and the parsed `body` argument it always handed.
6+
*
7+
* ## The defect
8+
*
9+
* The catch-all read the body with `await c.req.json().catch(() => ({}))` for
10+
* every `POST` / `PUT` / `PATCH`, whatever its `Content-Type`, and passed
11+
* `{ request: c.req.raw }` beside it. `c.req.json()` CONSUMES `c.req.raw`, so a
12+
* domain that forwards the request got one with nothing left in it: measured
13+
* through `createHonoApp`, `context.request.bodyUsed` was `true` at dispatch,
14+
* `request.clone().text()` threw, and a spaced JSON body was only available
15+
* re-serialised from the parsed value. The `/approvals/act` form `POST` (its
16+
* token rides in the body) and the HMAC-verified inbound hooks (they need the
17+
* sender's exact bytes) both need what was lost.
18+
*
19+
* ## What is pinned, both halves
20+
*
21+
* 1. PRESERVATION — the `body` argument is the value the old line produced, for
22+
* every input: each case is checked against a reference Hono app that runs
23+
* that exact line, and against the literal value written out.
24+
* 2. THE FIX — at dispatch the request is unread (`bodyUsed === false`) and
25+
* reads back the exact bytes the client sent, JSON included.
26+
*
27+
* The dispatcher is a fixture here (this package's config aliases
28+
* `@objectstack/runtime` to a stub). The real boot — the real dispatcher, the
29+
* real `/approvals/act` domain, the real approvals plugin — is
30+
* `packages/qa/http-conformance/src/hono-approvals-act.conformance.test.ts`.
31+
*/
32+
33+
import { describe, it, expect, vi, beforeEach } from 'vitest';
34+
import { Hono } from 'hono';
35+
36+
interface Seen { body: unknown; bodyUsed: boolean; text: string }
37+
38+
const seen: Seen[] = [];
39+
40+
/**
41+
* Record what dispatch() was handed, reading the request as a forwarding
42+
* domain would. The bytes are decoded with `ignoreBOM` so a byte-order mark the
43+
* client sent survives into the comparison — `Request.text()` would strip it
44+
* and make "every byte" a claim about all bytes but one.
45+
*/
46+
async function record(body: unknown, request: Request): Promise<void> {
47+
const bodyUsed = request.bodyUsed;
48+
let text = '<unreadable>';
49+
try {
50+
text = new TextDecoder('utf-8', { ignoreBOM: true }).decode(await request.arrayBuffer());
51+
} catch { /* recorded as unreadable */ }
52+
seen.push({ body, bodyUsed, text });
53+
}
54+
55+
const mockDispatcher = {
56+
getDiscoveryInfo: vi.fn().mockReturnValue({ version: '1.0', routes: {} }),
57+
handleAuth: vi.fn(async (_path: string, _method: string, body: unknown, ctx: { request: Request }) => {
58+
await record(body, ctx.request);
59+
return { handled: true, response: { status: 200, body: { ok: true } } };
60+
}),
61+
dispatch: vi.fn(async (_method: string, _path: string, body: unknown, _query: unknown, ctx: { request: Request }) => {
62+
await record(body, ctx.request);
63+
return { handled: true, response: { status: 200, body: { ok: true } } };
64+
}),
65+
};
66+
67+
vi.mock('@objectstack/runtime', () => ({
68+
HttpDispatcher: function HttpDispatcher() { return mockDispatcher; },
69+
}));
70+
71+
import { createHonoApp } from './index';
72+
73+
const PREFIX = '/api/v1';
74+
const PATH = `${PREFIX}/approvals/act`;
75+
const kernel = { name: 'test-kernel' } as any;
76+
77+
/** The catch-all's body line as it was before #22576 — the reference for "unchanged". */
78+
async function referenceBody(method: string, contentType: string | undefined, text: string): Promise<unknown> {
79+
let out: unknown;
80+
const ref = new Hono();
81+
ref.all('*', async (c) => {
82+
out = await c.req.json().catch(() => ({}));
83+
return c.body(null, 204);
84+
});
85+
await ref.request('http://ref.test/x', { method, headers: contentType ? { 'Content-Type': contentType } : {}, body: text });
86+
return out;
87+
}
88+
89+
/** [label, Content-Type, body text, the parsed value the domains are handed] */
90+
const CASES: ReadonlyArray<readonly [string, string | undefined, string, unknown]> = [
91+
['spaced JSON object', 'application/json', '{ "a" : 1 , "b": [ 1, 2 ] }', { a: 1, b: [1, 2] }],
92+
['JSON array', 'application/json', '[1, "two", null]', [1, 'two', null]],
93+
['JSON null', 'application/json', 'null', null],
94+
['JSON number', 'application/json', '42', 42],
95+
['JSON string', 'application/json', '"hi"', 'hi'],
96+
['JSON behind a byte-order mark', 'application/json', '\uFEFF{"bom":true}', { bom: true }],
97+
['JSON sent as text/plain (the Content-Type was never read)', 'text/plain', '{"a":1}', { a: 1 }],
98+
['invalid JSON', 'application/json', '{ not json', {}],
99+
['empty body', 'application/json', '', {}],
100+
['a form body', 'application/x-www-form-urlencoded', 'token=abc&token=def&x=1', {}],
101+
['plain text', 'text/plain', 'hello', {}],
102+
['no Content-Type at all', undefined, 'token=abc', {}],
103+
];
104+
105+
beforeEach(() => {
106+
seen.length = 0;
107+
mockDispatcher.dispatch.mockClear();
108+
mockDispatcher.handleAuth.mockClear();
109+
});
110+
111+
describe('[#22576] the catch-all: the parsed body is unchanged, and the raw request arrives unread', () => {
112+
for (const [label, contentType, text, expected] of CASES) {
113+
it(`${label}: the same parsed body as before, and every byte still readable on the request`, async () => {
114+
const app = createHonoApp({ kernel, prefix: PREFIX, cors: false });
115+
const res = await app.request(`http://hosted.test${PATH}`, {
116+
method: 'POST',
117+
headers: contentType ? { 'Content-Type': contentType } : {},
118+
body: text,
119+
});
120+
expect(res.status).toBe(200);
121+
expect(mockDispatcher.dispatch).toHaveBeenCalledTimes(1);
122+
123+
const [{ body, bodyUsed, text: forwarded }] = seen;
124+
// 1. preservation — against the old line itself, and against the value written out
125+
expect(body).toEqual(await referenceBody('POST', contentType, text));
126+
expect(body).toEqual(expected);
127+
// 2. the fix — unread at dispatch, and byte-for-byte what the client sent
128+
expect(bodyUsed).toBe(false);
129+
expect(forwarded).toBe(text);
130+
});
131+
}
132+
133+
for (const method of ['PUT', 'PATCH'] as const) {
134+
it(`${method} is read the same way`, async () => {
135+
const app = createHonoApp({ kernel, prefix: PREFIX, cors: false });
136+
const text = '{ "spaced" : true }';
137+
await app.request(`http://hosted.test${PREFIX}/data/task/1`, {
138+
method, headers: { 'Content-Type': 'application/json' }, body: text,
139+
});
140+
expect(seen).toEqual([{ body: { spaced: true }, bodyUsed: false, text }]);
141+
});
142+
}
143+
144+
it('GET and DELETE are handed no body, as before, and their requests are untouched', async () => {
145+
const app = createHonoApp({ kernel, prefix: PREFIX, cors: false });
146+
await app.request(`http://hosted.test${PATH}?token=abc`);
147+
await app.request(`http://hosted.test${PREFIX}/data/task/1`, {
148+
method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: '{"a":1}',
149+
});
150+
expect(seen).toEqual([
151+
{ body: undefined, bodyUsed: false, text: '' },
152+
{ body: undefined, bodyUsed: false, text: '{"a":1}' },
153+
]);
154+
});
155+
156+
it('a body something upstream already read through c.req: the parsed body still arrives, from Hono\'s cache', async () => {
157+
// The one input a clone cannot be taken from — the raw request is already
158+
// consumed, and Hono's body cache holds the only copy. The catch-all asks
159+
// `c.req.json()` exactly as it used to, so nothing a host composed in front
160+
// of it loses its body.
161+
const parent = new Hono();
162+
parent.use('*', async (c, next) => { await c.req.json().catch(() => undefined); await next(); });
163+
parent.route('/', createHonoApp({ kernel, prefix: PREFIX, cors: false }));
164+
165+
await parent.request(`http://hosted.test${PATH}`, {
166+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{ "upstream": 1 }',
167+
});
168+
expect(seen).toHaveLength(1);
169+
expect(seen[0]!.body).toEqual({ upstream: 1 });
170+
expect(seen[0]!.bodyUsed).toBe(true);
171+
});
172+
});
173+
174+
describe('[#22576] the /auth/* legacy fallback reads its body the same way', () => {
175+
it('with no auth service, handleAuth gets the parsed body and an unread request', async () => {
176+
const app = createHonoApp({ kernel, prefix: PREFIX, cors: false });
177+
const text = '{ "email" : "a@b.c" }';
178+
await app.request(`http://hosted.test${PREFIX}/auth/sign-in/email`, {
179+
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: text,
180+
});
181+
expect(mockDispatcher.handleAuth).toHaveBeenCalledTimes(1);
182+
expect(seen).toEqual([{ body: { email: 'a@b.c' }, bodyUsed: false, text }]);
183+
});
184+
});

‎packages/adapters/hono/src/index.ts‎

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import { Hono } from 'hono';
4+
import type { Context } from 'hono';
45
import { cors } from 'hono/cors';
56
import {
67
type ObjectKernel,
@@ -279,6 +280,54 @@ export function objectStackMiddleware(kernel: ObjectKernel) {
279280
};
280281
}
281282

283+
/**
284+
* The JSON body this adapter hands the dispatcher, read WITHOUT consuming the
285+
* request (#22576).
286+
*
287+
* ## Why the raw request must stay readable
288+
*
289+
* The dispatcher receives two things for a body-carrying request: the parsed
290+
* `body` argument, which the domains read, and `{ request: c.req.raw }`, which
291+
* a domain that forwards the request reads. Both used to come out of one
292+
* `c.req.json()` — and that call CONSUMES `c.req.raw`, whatever the
293+
* `Content-Type`. Measured through `createHonoApp`: at dispatch,
294+
* `context.request.bodyUsed` was `true` and `request.clone().text()` threw, so
295+
* a forwarding domain received a request with nothing in it. Two bridges need
296+
* exactly that body — the ADR-0043 action page, whose `token` rides in a FORM
297+
* body (the `/approvals/act` domain), and the inbound automation hooks, which
298+
* verify an HMAC over the sender's exact JSON bytes — and a spaced JSON body
299+
* re-serialised from the parsed value is not those bytes.
300+
*
301+
* So the parse reads a CLONE: `clone()` tees the body, `c.req.raw` keeps the
302+
* other branch unread, and a domain that forwards the request gets every byte
303+
* the client sent, for every `Content-Type`, JSON included.
304+
*
305+
* ## What the domains see is unchanged, deliberately
306+
*
307+
* Hono's `c.req.json()` is `text()` then `JSON.parse`, with any failure mapped
308+
* to `{}` by the `.catch` this adapter put on it — so an empty body, a form
309+
* body and invalid JSON all became `{}`, and any JSON value (an array, `null`,
310+
* a number) came through as parsed. The clone is parsed by the same two steps
311+
* and every failure is the same `{}`, so the `body` argument is the value it
312+
* was for every input. One case cannot be read from a clone: a body something
313+
* upstream already consumed through `c.req` — Hono's body cache then holds the
314+
* only copy, and `c.req.json()` is asked exactly as before.
315+
*
316+
* ⚠️ The cost is the tee: until the request is released, the unread branch
317+
* holds the body's bytes, so a body is buffered twice instead of once for the
318+
* life of the request. These are API bodies the old path already buffered
319+
* whole; nothing here streams.
320+
*/
321+
async function readDispatchBody(c: Context): Promise<any> {
322+
const raw = c.req.raw;
323+
if (raw.bodyUsed) return c.req.json().catch(() => ({}));
324+
try {
325+
return JSON.parse(await raw.clone().text());
326+
} catch {
327+
return {};
328+
}
329+
}
330+
282331
/**
283332
* Creates a full-featured Hono app with all ObjectStack route dispatchers.
284333
*
@@ -685,7 +734,7 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono {
685734
// Fallback to legacy dispatcher
686735
const body = method === 'GET' || method === 'HEAD'
687736
? {}
688-
: await c.req.json().catch(() => ({}));
737+
: await readDispatchBody(c);
689738
const result = await dispatcher.handleAuth(path, method, body, { request: c.req.raw });
690739
// `handled: false` is the dispatcher saying no auth domain claimed it —
691740
// an explicit ownership signal, better than inferring one from a status.
@@ -727,9 +776,12 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono {
727776
const subPath = c.req.path.substring(prefix.length);
728777
const method = c.req.method;
729778

779+
// [#22576] Parsed from a clone: `c.req.raw` reaches the dispatcher with
780+
// its body unread, so a domain that forwards the request (the
781+
// `/approvals/act` form POST) still has it. See `readDispatchBody`.
730782
let body: any = undefined;
731783
if (method === 'POST' || method === 'PUT' || method === 'PATCH') {
732-
body = await c.req.json().catch(() => ({}));
784+
body = await readDispatchBody(c);
733785
}
734786

735787
const queryParams: Record<string, any> = {};

0 commit comments

Comments
 (0)