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
17 changes: 17 additions & 0 deletions .changeset/24151-body-after-anonymous-floor.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@objectstack/runtime': patch
---

fix(runtime): on the standalone server, an anonymous request whose JSON body cannot be parsed gets the domain's `401`, not the transport's `400`

Clause-②: no

On the standalone server (`os dev`, `os serve`: `HonoServerPlugin` with the dispatcher plugin), the routes the dispatcher mounts read the request body before handing the request to `dispatch()`. The transport refuses a body it cannot parse when the body is read, so the refusal answered first: an anonymous `POST /api/v1/keys` with `{"name": "probe"` (the closing brace missing) got `400 INVALID_REQUEST`, while the same request with a well-formed body got the promised `401 UNAUTHENTICATED`. The anonymous answer depended on whether the body parsed.

The mounts now hand the body on unread, and each domain reads it once its anonymous decision has admitted the caller. An anonymous caller gets the same answer whatever its body, on every mounted route that reads one, unscoped and `/environments/:environmentId`-scoped:

- `401 UNAUTHENTICATED` on `POST /analytics/query` and `/analytics/sql`; `/keys` (every verb); `POST /notifications/read` and `/notifications/read/all`; `POST /automation`, `PUT /automation/:name` and the two `trigger` doors, `toggle`, `clone`, `resume`, `cancel` and `restore-suspension`; the three `POST /actions/...` shapes; the `/ai/*` routes; the `/packages` routes; `POST /security/_activation/:type/:name` and `POST /security/permission-sets/:name/clone`.
- `/mcp` answers its own `401`, or, ahead of it, its `404` when MCP is switched off and its `501` when no MCP service is installed. None of the three reads the body, so a signed-in caller gets the `404` or `501` too.
- `/mcp/skill` is public and reads no body: a `POST` or `DELETE` gets its own `405` + `Allow: GET`, signed in or not.

Otherwise a signed-in caller with an unparseable body still gets the transport's `400 INVALID_REQUEST`: the body is read after the anonymous decision, not dropped. A `createHonoApp` host (`@objectstack/hono`) is unchanged; it already raised the refusal only when a domain read the body.
104 changes: 104 additions & 0 deletions packages/runtime/src/deferred-request-body.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import { describe, it, expect } from 'vitest';

import { deferRequestBody, readDispatchedBody, UNRESOLVED_REQUEST_BODY_READ } from './deferred-request-body.js';

/**
* The two halves of a body handed to `dispatch()` unread (#24151): the mount
* defers, the domain reads once it has admitted the caller. The socket-level
* pins are `dispatcher-body-after-anonymous-floor.hono.integration.test.ts`;
* these hold the value's own contract.
*/

/** A request whose `body` getter counts its reads and answers `value`, or throws `refusal`. */
function requestWith(value: unknown, refusal?: Error): { req: { readonly body: unknown }; reads: () => number } {
let reads = 0;
const req = {
get body(): unknown {
reads += 1;
if (refusal) throw refusal;
return value;
},
};
return { req, reads: () => reads };
}

describe('deferRequestBody / readDispatchedBody', () => {
it('defers: building, holding and passing the value along reads nothing', () => {
const { req, reads } = requestWith({ name: 'probe' });
const deferred = deferRequestBody(req);

expect(typeof deferred).toBe('object');
expect(deferred ?? 'nullish').toBe(deferred);
const carried = [deferred][0];
expect(carried).toBe(deferred);
expect(reads()).toBe(0);
});

it('reads the request body when the domain resolves it, returning that exact value', () => {
for (const value of [{ name: 'probe' }, [{ jsonrpc: '2.0' }], null, 'text', 0, false]) {
const { req, reads } = requestWith(value);
const deferred = deferRequestBody(req);

expect(readDispatchedBody(deferred)).toBe(value);
expect(reads()).toBe(1);
}
});

it("lets the transport's refusal out of the resolving read, unchanged", () => {
const refusal = Object.assign(new Error('The request body could not be parsed as JSON'), { status: 400, code: 'INVALID_REQUEST' });
const { req, reads } = requestWith(undefined, refusal);
const deferred = deferRequestBody(req);

expect(reads()).toBe(0);
let thrown: unknown;
try {
readDispatchedBody(deferred);
} catch (err) {
thrown = err;
}
// The very object the transport threw: its `status` and `code` are what it answers.
expect(thrown).toBe(refusal);
expect(reads()).toBe(1);
});

it('hands every value it did not defer back as it is, reading nothing from it', () => {
const refusedOnRead = new Proxy(Object.create(null) as object, {
get: () => { throw new Error('read'); },
has: () => { throw new Error('read'); },
ownKeys: () => { throw new Error('read'); },
getOwnPropertyDescriptor: () => { throw new Error('read'); },
getPrototypeOf: () => { throw new Error('read'); },
});
const parsed = { name: 'probe' };
for (const value of [parsed, refusedOnRead, undefined, null, 'text', 0, [1]]) {
expect(readDispatchedBody(value)).toBe(value);
}
});

it('refuses loudly when a deferred body is read without being resolved, and never reads the request', () => {
const { req, reads } = requestWith({ name: 'probe' });
const deferred = deferRequestBody(req) as Record<string, unknown>;

/** The name of what `read` threw, or `'nothing'` when it returned. */
const thrownName = (read: () => unknown): string => {
try {
read();
} catch (err) {
return (err as Error).name;
}
return 'nothing';
};
for (const read of [
() => deferred.name,
() => 'name' in deferred,
() => Object.keys(deferred),
() => ({ ...deferred }),
() => JSON.stringify(deferred),
]) {
expect(thrownName(read)).toBe(UNRESOLVED_REQUEST_BODY_READ);
}
expect(reads()).toBe(0);
});
});
101 changes: 101 additions & 0 deletions packages/runtime/src/deferred-request-body.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* A request body handed to `dispatch()` UNREAD, so the domain reads it only
* after its anonymous decision (ADR-0138 D2) — never the mount ahead of it.
*
* ## Why the mount cannot read it
*
* `IHttpRequest.body` may REFUSE when it is read: the standard transport
* (`@objectstack/plugin-hono-server`) throws its `400 INVALID_REQUEST` from the
* read of a body it could not parse, and answers the request with that refusal
* whatever the handler then does. The standalone mounts in
* `dispatcher-plugin.ts` used to read `req.body` to build `dispatch()`'s
* arguments, so the refusal fired before `dispatch()` ran — ahead of the
* domain's anonymous floor, which ADR-0138 D2 makes the domain's FIRST
* statement. An anonymous caller sending an unparseable JSON body was answered
* `400` where the same request with a readable body is answered `401`: the
* anonymous answer depended on whether the body parsed. `createHonoApp`
* (`@objectstack/hono`) never had this order, because it hands `dispatch()` a
* body whose refusal is raised only when a domain reads it.
*
* ## The two halves
*
* - {@link deferRequestBody} (the mount): the `body` argument for `dispatch()`,
* holding the request and reading nothing.
* - {@link readDispatchedBody} (the domain): called once the domain's anonymous
* decision has admitted the caller. A deferred body is read there, from the
* request, so a refusal is the transport's, unchanged; any other value (what
* `createHonoApp`, a test, or an internal caller hands `dispatch()`) is
* returned as it is.
*
* Reading the body THEN — rather than handing the domain no body at all, the
* shape the bodiless doors use — is what keeps the admitted caller's answer: a
* signed-in request with an unparseable body still gets the transport's
* refusal, and a readable one the domain's answer to what it says.
*
* ## A deferred body read without being resolved is a programming error
*
* The value {@link deferRequestBody} returns throws on every read — a
* property, `in`, its keys, a spread, `JSON.stringify` — with a message naming
* the missing {@link readDispatchedBody} call. Holding it is not a read:
* `typeof` is `'object'`, and passing it along or testing it with `??` touch
* nothing, so `dispatch()` and a domain's own gates carry it untouched. A
* domain that forgot to resolve it fails loudly on its first read instead of
* acting on an invented empty body.
*/

/** The request a deferred body is read from: the transport's `IHttpRequest` (`body` may throw on read). */
interface BodyBearingRequest {
readonly body?: unknown;
}

/** Deferred bodies → their request. Keyed by identity, so recognising one touches none of its traps. */
const DEFERRED_BODIES = new WeakMap<object, BodyBearingRequest>();

/** The `name` of the error a read of an unresolved deferred body throws: a programming error, never a request's. */
export const UNRESOLVED_REQUEST_BODY_READ = 'UnresolvedRequestBodyRead';

const UNRESOLVED_READ_MESSAGE =
'A request body handed to dispatch() unread was read before the domain resolved it. '
+ 'A domain resolves the body with readDispatchedBody(body) once its anonymous decision has admitted the caller, '
+ 'and reads only the value that call returns.';

/**
* The `body` argument a mount hands `dispatch()`: `req.body`, NOT yet read.
* The domain reads it with {@link readDispatchedBody} after its anonymous
* decision; until then nothing reads it, so a body the transport refuses is
* refused only for a caller the domain admitted.
*/
export function deferRequestBody(req: BodyBearingRequest): unknown {
const unresolved = (): never => {
const error = new Error(UNRESOLVED_READ_MESSAGE);
error.name = UNRESOLVED_REQUEST_BODY_READ;
throw error;
};
const deferred = new Proxy(Object.create(null) as object, {
get: unresolved,
has: unresolved,
ownKeys: unresolved,
getOwnPropertyDescriptor: unresolved,
getPrototypeOf: unresolved,
set: unresolved,
defineProperty: unresolved,
deleteProperty: unresolved,
});
DEFERRED_BODIES.set(deferred, req);
return deferred;
}

/**
* The body a domain acts on, read once its anonymous decision has admitted the
* caller. A body {@link deferRequestBody} deferred is read now, from its
* request — which may throw the transport's refusal, and must be left to
* propagate (`IHttpRequest.body`: a handler never carries on past it). Any
* other value is returned unchanged.
*/
export function readDispatchedBody(body: unknown): unknown {
if (typeof body !== 'object' || body === null) return body;
const req = DEFERRED_BODIES.get(body);
return req === undefined ? body : req.body;
}
Loading
Loading