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
21 changes: 21 additions & 0 deletions .changeset/22576-approvals-act-domain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
'@objectstack/runtime': patch
'@objectstack/hono': patch
---

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

Clause-②: no

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.

**`@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.

- `HEAD` answers as the self-hosted mount does: the `GET` page's status and headers, with no body. It renders and never decides.
- No `approvals` service registered, or one without `handleActionPage`: `501 NOT_IMPLEMENTED`, naming which. Never `ROUTE_NOT_FOUND`.
- 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.
- 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.

**`@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.

Nothing changes on a self-hosted server: the plugin's raw-app mount still serves the path there and never reaches the dispatcher.
184 changes: 184 additions & 0 deletions packages/adapters/hono/src/hono-catch-all-raw-request.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#22576] The `${prefix}/*` catch-all hands `dispatch()` the raw request with
* its body UNREAD, and the parsed `body` argument it always handed.
*
* ## The defect
*
* The catch-all read the body with `await c.req.json().catch(() => ({}))` for
* every `POST` / `PUT` / `PATCH`, whatever its `Content-Type`, and passed
* `{ request: c.req.raw }` beside it. `c.req.json()` CONSUMES `c.req.raw`, so a
* domain that forwards the request got one with nothing left in it: measured
* through `createHonoApp`, `context.request.bodyUsed` was `true` at dispatch,
* `request.clone().text()` threw, and a spaced JSON body was only available
* re-serialised from the parsed value. The `/approvals/act` form `POST` (its
* token rides in the body) and the HMAC-verified inbound hooks (they need the
* sender's exact bytes) both need what was lost.
*
* ## What is pinned, both halves
*
* 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.
* 2. THE FIX — at dispatch the request is unread (`bodyUsed === false`) and
* reads back the exact bytes the client sent, JSON included.
*
* The dispatcher is a fixture here (this package's config aliases
* `@objectstack/runtime` to a stub). The real boot — the real dispatcher, the
* real `/approvals/act` domain, the real approvals plugin — is
* `packages/qa/http-conformance/src/hono-approvals-act.conformance.test.ts`.
*/

import { describe, it, expect, vi, beforeEach } from 'vitest';
import { Hono } from 'hono';

interface Seen { body: unknown; bodyUsed: boolean; text: string }

const seen: Seen[] = [];

/**
* Record what dispatch() was handed, reading the request as a forwarding
* domain would. The bytes are decoded with `ignoreBOM` so a byte-order mark the
* client sent survives into the comparison — `Request.text()` would strip it
* and make "every byte" a claim about all bytes but one.
*/
async function record(body: unknown, request: Request): Promise<void> {
const bodyUsed = request.bodyUsed;
let text = '<unreadable>';
try {
text = new TextDecoder('utf-8', { ignoreBOM: true }).decode(await request.arrayBuffer());
} catch { /* recorded as unreadable */ }
seen.push({ body, bodyUsed, text });
}

const mockDispatcher = {
getDiscoveryInfo: vi.fn().mockReturnValue({ version: '1.0', routes: {} }),
handleAuth: vi.fn(async (_path: string, _method: string, body: unknown, ctx: { request: Request }) => {
await record(body, ctx.request);
return { handled: true, response: { status: 200, body: { ok: true } } };
}),
dispatch: vi.fn(async (_method: string, _path: string, body: unknown, _query: unknown, ctx: { request: Request }) => {
await record(body, ctx.request);
return { handled: true, response: { status: 200, body: { ok: true } } };
}),
};

vi.mock('@objectstack/runtime', () => ({
HttpDispatcher: function HttpDispatcher() { return mockDispatcher; },
}));

import { createHonoApp } from './index';

const PREFIX = '/api/v1';
const PATH = `${PREFIX}/approvals/act`;
const kernel = { name: 'test-kernel' } as any;

/** The catch-all's body line as it was before #22576 — the reference for "unchanged". */
async function referenceBody(method: string, contentType: string | undefined, text: string): Promise<unknown> {
let out: unknown;
const ref = new Hono();
ref.all('*', async (c) => {
out = await c.req.json().catch(() => ({}));
return c.body(null, 204);
});
await ref.request('http://ref.test/x', { method, headers: contentType ? { 'Content-Type': contentType } : {}, body: text });
return out;
}

/** [label, Content-Type, body text, the parsed value the domains are handed] */
const CASES: ReadonlyArray<readonly [string, string | undefined, string, unknown]> = [
['spaced JSON object', 'application/json', '{ "a" : 1 , "b": [ 1, 2 ] }', { a: 1, b: [1, 2] }],
['JSON array', 'application/json', '[1, "two", null]', [1, 'two', null]],
['JSON null', 'application/json', 'null', null],
['JSON number', 'application/json', '42', 42],
['JSON string', 'application/json', '"hi"', 'hi'],
['JSON behind a byte-order mark', 'application/json', '\uFEFF{"bom":true}', { bom: true }],
['JSON sent as text/plain (the Content-Type was never read)', 'text/plain', '{"a":1}', { a: 1 }],
['invalid JSON', 'application/json', '{ not json', {}],
['empty body', 'application/json', '', {}],
['a form body', 'application/x-www-form-urlencoded', 'token=abc&token=def&x=1', {}],
['plain text', 'text/plain', 'hello', {}],
['no Content-Type at all', undefined, 'token=abc', {}],
];

beforeEach(() => {
seen.length = 0;
mockDispatcher.dispatch.mockClear();
mockDispatcher.handleAuth.mockClear();
});

describe('[#22576] the catch-all: the parsed body is unchanged, and the raw request arrives unread', () => {
for (const [label, contentType, text, expected] of CASES) {
it(`${label}: the same parsed body as before, and every byte still readable on the request`, async () => {
const app = createHonoApp({ kernel, prefix: PREFIX, cors: false });
const res = await app.request(`http://hosted.test${PATH}`, {
method: 'POST',
headers: contentType ? { 'Content-Type': contentType } : {},
body: text,
});
expect(res.status).toBe(200);
expect(mockDispatcher.dispatch).toHaveBeenCalledTimes(1);

const [{ body, bodyUsed, text: forwarded }] = seen;
// 1. preservation — against the old line itself, and against the value written out
expect(body).toEqual(await referenceBody('POST', contentType, text));
expect(body).toEqual(expected);
// 2. the fix — unread at dispatch, and byte-for-byte what the client sent
expect(bodyUsed).toBe(false);
expect(forwarded).toBe(text);
});
}

for (const method of ['PUT', 'PATCH'] as const) {
it(`${method} is read the same way`, async () => {
const app = createHonoApp({ kernel, prefix: PREFIX, cors: false });
const text = '{ "spaced" : true }';
await app.request(`http://hosted.test${PREFIX}/data/task/1`, {
method, headers: { 'Content-Type': 'application/json' }, body: text,
});
expect(seen).toEqual([{ body: { spaced: true }, bodyUsed: false, text }]);
});
}

it('GET and DELETE are handed no body, as before, and their requests are untouched', async () => {
const app = createHonoApp({ kernel, prefix: PREFIX, cors: false });
await app.request(`http://hosted.test${PATH}?token=abc`);
await app.request(`http://hosted.test${PREFIX}/data/task/1`, {
method: 'DELETE', headers: { 'Content-Type': 'application/json' }, body: '{"a":1}',
});
expect(seen).toEqual([
{ body: undefined, bodyUsed: false, text: '' },
{ body: undefined, bodyUsed: false, text: '{"a":1}' },
]);
});

it('a body something upstream already read through c.req: the parsed body still arrives, from Hono\'s cache', async () => {
// The one input a clone cannot be taken from — the raw request is already
// consumed, and Hono's body cache holds the only copy. The catch-all asks
// `c.req.json()` exactly as it used to, so nothing a host composed in front
// of it loses its body.
const parent = new Hono();
parent.use('*', async (c, next) => { await c.req.json().catch(() => undefined); await next(); });
parent.route('/', createHonoApp({ kernel, prefix: PREFIX, cors: false }));

await parent.request(`http://hosted.test${PATH}`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{ "upstream": 1 }',
});
expect(seen).toHaveLength(1);
expect(seen[0]!.body).toEqual({ upstream: 1 });
expect(seen[0]!.bodyUsed).toBe(true);
});
});

describe('[#22576] the /auth/* legacy fallback reads its body the same way', () => {
it('with no auth service, handleAuth gets the parsed body and an unread request', async () => {
const app = createHonoApp({ kernel, prefix: PREFIX, cors: false });
const text = '{ "email" : "a@b.c" }';
await app.request(`http://hosted.test${PREFIX}/auth/sign-in/email`, {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: text,
});
expect(mockDispatcher.handleAuth).toHaveBeenCalledTimes(1);
expect(seen).toEqual([{ body: { email: 'a@b.c' }, bodyUsed: false, text }]);
});
});
56 changes: 54 additions & 2 deletions packages/adapters/hono/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

import { Hono } from 'hono';
import type { Context } from 'hono';
import { cors } from 'hono/cors';
import {
type ObjectKernel,
Expand Down Expand Up @@ -279,6 +280,54 @@ export function objectStackMiddleware(kernel: ObjectKernel) {
};
}

/**
* The JSON body this adapter hands the dispatcher, read WITHOUT consuming the
* request (#22576).
*
* ## Why the raw request must stay readable
*
* The dispatcher receives two things for a body-carrying request: the parsed
* `body` argument, which the domains read, and `{ request: c.req.raw }`, which
* a domain that forwards the request reads. Both used to come out of one
* `c.req.json()` — and that call CONSUMES `c.req.raw`, whatever the
* `Content-Type`. Measured through `createHonoApp`: at dispatch,
* `context.request.bodyUsed` was `true` and `request.clone().text()` threw, so
* a forwarding domain received a request with nothing in it. Two bridges need
* exactly that body — the ADR-0043 action page, whose `token` rides in a FORM
* body (the `/approvals/act` domain), and the inbound automation hooks, which
* verify an HMAC over the sender's exact JSON bytes — and a spaced JSON body
* re-serialised from the parsed value is not those bytes.
*
* So the parse reads a CLONE: `clone()` tees the body, `c.req.raw` keeps the
* other branch unread, and a domain that forwards the request gets every byte
* the client sent, for every `Content-Type`, JSON included.
*
* ## What the domains see is unchanged, deliberately
*
* Hono's `c.req.json()` is `text()` then `JSON.parse`, with any failure mapped
* to `{}` by the `.catch` this adapter put on it — so an empty body, a form
* body and invalid JSON all became `{}`, and any JSON value (an array, `null`,
* a number) came through as parsed. The clone is parsed by the same two steps
* and every failure is the same `{}`, so the `body` argument is the value it
* was for every input. One case cannot be read from a clone: a body something
* upstream already consumed through `c.req` — Hono's body cache then holds the
* only copy, and `c.req.json()` is asked exactly as before.
*
* ⚠️ The cost is the tee: until the request is released, the unread branch
* 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.
*/
async function readDispatchBody(c: Context): Promise<any> {
const raw = c.req.raw;
if (raw.bodyUsed) return c.req.json().catch(() => ({}));
try {
return JSON.parse(await raw.clone().text());
} catch {
return {};
}
}

/**
* Creates a full-featured Hono app with all ObjectStack route dispatchers.
*
Expand Down Expand Up @@ -685,7 +734,7 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono {
// Fallback to legacy dispatcher
const body = method === 'GET' || method === 'HEAD'
? {}
: await c.req.json().catch(() => ({}));
: await readDispatchBody(c);
const result = await dispatcher.handleAuth(path, method, body, { request: c.req.raw });
// `handled: false` is the dispatcher saying no auth domain claimed it —
// an explicit ownership signal, better than inferring one from a status.
Expand Down Expand Up @@ -727,9 +776,12 @@ export function createHonoApp(options: ObjectStackHonoOptions): Hono {
const subPath = c.req.path.substring(prefix.length);
const method = c.req.method;

// [#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 c.req.json().catch(() => ({}));
body = await readDispatchBody(c);
}

const queryParams: Record<string, any> = {};
Expand Down
Loading
Loading