From bde3873ed67167aabf1962ae4658917ec2dadb86 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 03:18:24 +0000 Subject: [PATCH 1/2] fix(runtime): standalone mounts hand dispatch() the body unread, so a domain reads it after its anonymous decision WIP: helper, mount sites, domain reads, pins. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb --- .../runtime/src/deferred-request-body.test.ts | 104 +++++++++ packages/runtime/src/deferred-request-body.ts | 101 ++++++++ ...r-anonymous-floor.hono.integration.test.ts | 219 ++++++++++++++++++ packages/runtime/src/dispatcher-plugin.ts | 59 +++-- packages/runtime/src/domains/actions.ts | 4 + packages/runtime/src/domains/ai.ts | 12 +- packages/runtime/src/domains/analytics.ts | 4 + packages/runtime/src/domains/automation.ts | 6 + packages/runtime/src/domains/keys.ts | 4 + packages/runtime/src/domains/mcp.ts | 4 + packages/runtime/src/domains/notifications.ts | 4 + packages/runtime/src/domains/packages.ts | 4 + packages/runtime/src/domains/security.ts | 4 + 13 files changed, 504 insertions(+), 25 deletions(-) create mode 100644 packages/runtime/src/deferred-request-body.test.ts create mode 100644 packages/runtime/src/deferred-request-body.ts create mode 100644 packages/runtime/src/dispatcher-body-after-anonymous-floor.hono.integration.test.ts diff --git a/packages/runtime/src/deferred-request-body.test.ts b/packages/runtime/src/deferred-request-body.test.ts new file mode 100644 index 00000000000..17def8d259e --- /dev/null +++ b/packages/runtime/src/deferred-request-body.test.ts @@ -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; + + /** 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); + }); +}); diff --git a/packages/runtime/src/deferred-request-body.ts b/packages/runtime/src/deferred-request-body.ts new file mode 100644 index 00000000000..7d7452c9011 --- /dev/null +++ b/packages/runtime/src/deferred-request-body.ts @@ -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(); + +/** 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; +} diff --git a/packages/runtime/src/dispatcher-body-after-anonymous-floor.hono.integration.test.ts b/packages/runtime/src/dispatcher-body-after-anonymous-floor.hono.integration.test.ts new file mode 100644 index 00000000000..c05422cf40e --- /dev/null +++ b/packages/runtime/src/dispatcher-body-after-anonymous-floor.hono.integration.test.ts @@ -0,0 +1,219 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { LiteKernel, Plugin, PluginContext } from '@objectstack/core'; +import { HonoServerPlugin } from '@objectstack/plugin-hono-server'; +import type { IHttpServer } from '@objectstack/spec/contracts'; + +import { createDispatcherPlugin } from './dispatcher-plugin.js'; + +/** + * On the standalone server, an anonymous caller's answer does not depend on + * whether its JSON body parses (#24151) — over a real Hono socket. + * + * The standalone mounts in `dispatcher-plugin.ts` used to hand `dispatch()` + * `req.body`. `@objectstack/plugin-hono-server`'s `req.body` throws its + * `400 INVALID_REQUEST` refusal when a body it could not parse is READ, and + * answers the request with that refusal; read while building `dispatch()`'s + * arguments, it fired before the domain ran, so ahead of the domain's + * anonymous decision (ADR-0138 D2: the domain's first statement). Measured on + * `cbfa5e433`: every row below answered an anonymous caller `400 + * INVALID_REQUEST` for `{"name": "probe"` and `401 UNAUTHENTICATED` for the + * same body with its closing brace. `createHonoApp` answers `401`, because it + * raises the refusal only when a domain reads the body. + * + * The mounts now hand `deferRequestBody(req)`, and each domain reads it with + * `readDispatchedBody` once its anonymous decision has admitted the caller. + * Three readings per row: + * + * 1. anonymous + unparseable → the domain's anonymous answer (`401 + * UNAUTHENTICATED`; `/mcp/skill` is public and answers its own `405`); + * 2. anonymous + the same body, well-formed → the SAME answer — the card's + * statement that the anonymous answer must not depend on the parse; + * 3. signed-in + unparseable → still the transport's refusal, read by the + * domain once it admitted the caller (the control: the body is read later, + * not dropped). `/mcp/skill` never reads a body, so it answers its `405`. + * + * Which door answered a `401` is read off the body: the dispatcher's envelope + * fills `error.httpStatus`. The refusal is told from a domain's own + * `INVALID_REQUEST` by its message, as `request-body-refusal.test.ts` in + * `plugin-hono-server` tells it. + * + * Both prefixes for the registrars `enableProjectScoping` mounts twice + * (automation, actions, security, AI, packages). `LiteKernel`, no `objectql`: + * every answer here comes before a domain touches storage. The `auth` slot + * answers a session only when the request carries `x-test-session`. A stub + * `analytics` slot makes the standalone server mount `/analytics`, and a stub + * `mcp` slot lets `/mcp` reach its anonymous decision (which follows its + * enabled and available checks). + */ + +const SESSION_HEADER = 'x-test-session'; +const SCOPED = '/api/v1/environments/env_body_floor'; +const UNSCOPED = '/api/v1'; +/** `{"name": "probe"` — the closing brace missing. */ +const UNPARSEABLE = '{"name": "probe"'; +const WELL_FORMED = '{"name": "probe"}'; + +interface Answer { status: number; code: string } +interface Site { + method: 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + /** The sub-path after the prefix. */ + path: string; + /** Mounted under the environment-scoped prefix too. */ + scoped: boolean; + /** What an anonymous caller is answered, whatever its body. */ + anonymous: Answer; + /** What a signed-in caller with an unparseable body is answered. */ + signedIn: Answer | 'refusal'; +} + +const UNAUTHENTICATED: Answer = { status: 401, code: 'UNAUTHENTICATED' }; +const SKILL_405: Answer = { status: 405, code: 'METHOD_NOT_ALLOWED' }; + +/** The card's twenty `dispatcher.dispatch(… req.body …)` mounts, every verb of the method-parameterised ones. */ +const CARD_SITES: Site[] = [ + { method: 'POST', path: '/analytics/query', scoped: false, anonymous: UNAUTHENTICATED, signedIn: 'refusal' }, + { method: 'POST', path: '/analytics/sql', scoped: false, anonymous: UNAUTHENTICATED, signedIn: 'refusal' }, + { method: 'POST', path: '/mcp', scoped: false, anonymous: UNAUTHENTICATED, signedIn: 'refusal' }, + { method: 'DELETE', path: '/mcp', scoped: false, anonymous: UNAUTHENTICATED, signedIn: 'refusal' }, + { method: 'POST', path: '/mcp/skill', scoped: false, anonymous: SKILL_405, signedIn: SKILL_405 }, + { method: 'DELETE', path: '/mcp/skill', scoped: false, anonymous: SKILL_405, signedIn: SKILL_405 }, + { method: 'POST', path: '/keys', scoped: false, anonymous: UNAUTHENTICATED, signedIn: 'refusal' }, + { method: 'PUT', path: '/keys', scoped: false, anonymous: UNAUTHENTICATED, signedIn: 'refusal' }, + { method: 'PATCH', path: '/keys', scoped: false, anonymous: UNAUTHENTICATED, signedIn: 'refusal' }, + { method: 'DELETE', path: '/keys', scoped: false, anonymous: UNAUTHENTICATED, signedIn: 'refusal' }, + { method: 'POST', path: '/notifications/read', scoped: false, anonymous: UNAUTHENTICATED, signedIn: 'refusal' }, + { method: 'POST', path: '/notifications/read/all', scoped: false, anonymous: UNAUTHENTICATED, signedIn: 'refusal' }, + { method: 'POST', path: '/automation', scoped: true, anonymous: UNAUTHENTICATED, signedIn: 'refusal' }, + { method: 'PUT', path: '/automation/flow_x', scoped: true, anonymous: UNAUTHENTICATED, signedIn: 'refusal' }, + { method: 'POST', path: '/automation/trigger/flow_x', scoped: true, anonymous: UNAUTHENTICATED, signedIn: 'refusal' }, + { method: 'POST', path: '/automation/flow_x/trigger', scoped: true, anonymous: UNAUTHENTICATED, signedIn: 'refusal' }, + { method: 'POST', path: '/automation/flow_x/toggle', scoped: true, anonymous: UNAUTHENTICATED, signedIn: 'refusal' }, + { method: 'POST', path: '/automation/flow_x/clone', scoped: true, anonymous: UNAUTHENTICATED, signedIn: 'refusal' }, + { method: 'POST', path: '/automation/flow_x/runs/run_1/resume', scoped: true, anonymous: UNAUTHENTICATED, signedIn: 'refusal' }, + { method: 'POST', path: '/automation/flow_x/runs/run_1/cancel', scoped: true, anonymous: UNAUTHENTICATED, signedIn: 'refusal' }, + { method: 'POST', path: '/automation/flow_x/runs/run_1/restore-suspension', scoped: true, anonymous: UNAUTHENTICATED, signedIn: 'refusal' }, + { method: 'POST', path: '/actions//close_all', scoped: true, anonymous: UNAUTHENTICATED, signedIn: 'refusal' }, + { method: 'POST', path: '/actions/task/close', scoped: true, anonymous: UNAUTHENTICATED, signedIn: 'refusal' }, + { method: 'POST', path: '/actions/task/close/rec_1', scoped: true, anonymous: UNAUTHENTICATED, signedIn: 'refusal' }, +]; + +/** + * The same read, in mounts the card's single-line grep did not reach: the `/ai/*` + * wildcards (`method.toUpperCase()` closes the grep's `[^)]*`), and the + * `/packages` and `/security` registrars, which spell the call over several lines. + */ +const FAMILY_SITES: Site[] = [ + { method: 'POST', path: '/ai/chat', scoped: true, anonymous: UNAUTHENTICATED, signedIn: 'refusal' }, + { method: 'PUT', path: '/ai/conversations/c1', scoped: true, anonymous: UNAUTHENTICATED, signedIn: 'refusal' }, + { method: 'PATCH', path: '/ai/conversations/c1', scoped: true, anonymous: UNAUTHENTICATED, signedIn: 'refusal' }, + { method: 'DELETE', path: '/ai/conversations/c1', scoped: true, anonymous: UNAUTHENTICATED, signedIn: 'refusal' }, + { method: 'POST', path: '/packages', scoped: true, anonymous: UNAUTHENTICATED, signedIn: 'refusal' }, + { method: 'PATCH', path: '/packages/pkg_x', scoped: true, anonymous: UNAUTHENTICATED, signedIn: 'refusal' }, + { method: 'PATCH', path: '/packages/pkg_x/enable', scoped: true, anonymous: UNAUTHENTICATED, signedIn: 'refusal' }, + { method: 'POST', path: '/packages/pkg_x/publish', scoped: true, anonymous: UNAUTHENTICATED, signedIn: 'refusal' }, + { method: 'POST', path: '/packages/pkg_x/rollback', scoped: true, anonymous: UNAUTHENTICATED, signedIn: 'refusal' }, + { method: 'DELETE', path: '/packages/pkg_x', scoped: true, anonymous: UNAUTHENTICATED, signedIn: 'refusal' }, + { method: 'POST', path: '/security/_activation/permission_set/ps_x', scoped: true, anonymous: UNAUTHENTICATED, signedIn: 'refusal' }, + { method: 'POST', path: '/security/permission-sets/ps_x/clone', scoped: true, anonymous: UNAUTHENTICATED, signedIn: 'refusal' }, +]; + +function fakeServicesPlugin(): Plugin { + return { + name: 'com.objectstack.test.fake-services-body-after-floor', + version: '1.0.0', + init: async (ctx: PluginContext) => { + ctx.registerService('auth', { + api: { + getSession: async ({ headers }: any) => + (headers?.get?.(SESSION_HEADER) ? { user: { id: 'u_body_after_floor' } } : undefined), + }, + }); + // Occupants only: no row reaches either past the body read. + ctx.registerService('analytics', { + query: async () => ({ rows: [] }), + getMeta: async () => [], + }); + ctx.registerService('mcp', { + handleHttpRequest: async () => + new Response('{}', { status: 200, headers: { 'content-type': 'application/json' } }), + renderSkill: () => '# skill', + }); + }, + }; +} + +describe('the body is read after the anonymous decision on every body-reading standalone mount (integration, #24151)', () => { + let kernel: LiteKernel; + let baseUrl: string; + + beforeAll(async () => { + kernel = new LiteKernel(); + kernel.use(fakeServicesPlugin()); + // port 0 → an OS-assigned free port, read back through getPort(). + kernel.use(new HonoServerPlugin({ port: 0, cors: false })); + kernel.use(createDispatcherPlugin({ + prefix: UNSCOPED, + securityHeaders: false, + scoping: { enableProjectScoping: true, projectResolution: 'auto' }, + })); + await kernel.bootstrap(); + baseUrl = `http://127.0.0.1:${kernel.getService('http.server').getPort!()}`; + }, 30_000); + + afterAll(async () => { + if (kernel) { + await Promise.race([ + kernel.shutdown(), + new Promise((resolve) => setTimeout(resolve, 10_000)), + ]); + } + }, 30_000); + + async function call(method: string, url: string, body: string, signedIn: boolean): Promise<{ status: number; json: Record }> { + const res = await fetch(`${baseUrl}${url}`, { + method, + headers: { + 'content-type': 'application/json', + ...(signedIn ? { [SESSION_HEADER]: '1' } : {}), + }, + body, + }); + return { status: res.status, json: (await res.json()) as Record }; + } + + for (const site of [...CARD_SITES, ...FAMILY_SITES]) { + for (const prefix of site.scoped ? [UNSCOPED, SCOPED] : [UNSCOPED]) { + const url = `${prefix}${site.path}`; + + it(`anonymous ${site.method} ${url}, unparseable body → ${site.anonymous.status} ${site.anonymous.code}, as for a well-formed one`, async () => { + const unparseable = await call(site.method, url, UNPARSEABLE, false); + const wellFormed = await call(site.method, url, WELL_FORMED, false); + + expect(unparseable.status).toBe(site.anonymous.status); + expect(unparseable.json.success).toBe(false); + expect(unparseable.json.error.code).toBe(site.anonymous.code); + // The dispatcher's envelope, not the transport's refusal. + expect(unparseable.json.error.httpStatus).toBe(site.anonymous.status); + // The anonymous answer does not depend on whether the body parses. + expect({ status: wellFormed.status, code: wellFormed.json.error?.code }) + .toEqual({ status: unparseable.status, code: unparseable.json.error.code }); + }); + + it(`signed-in ${site.method} ${url}, unparseable body → ${site.signedIn === 'refusal' ? "the transport's 400 refusal, read by the domain" : `${site.signedIn.status} ${site.signedIn.code}`}`, async () => { + const { status, json } = await call(site.method, url, UNPARSEABLE, true); + + expect(json.success).toBe(false); + if (site.signedIn === 'refusal') { + expect(status).toBe(400); + expect(json.error.code).toBe('INVALID_REQUEST'); + expect(json.error.message).toMatch(/parsed as JSON/); + } else { + expect(status).toBe(site.signedIn.status); + expect(json.error.code).toBe(site.signedIn.code); + } + }); + } + } +}); diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index e9aa4abfe42..4f1fe1af842 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -7,6 +7,7 @@ import type { IAuthService, IMetadataService, Logger } from '@objectstack/spec/c import type { CounterStore } from '@objectstack/plugin-auth/rate-limit-storage'; import { HttpDispatcher, HttpDispatcherResult, type HttpProtocolContext } from './http-dispatcher.js'; import { isServiceServeable } from './service-serveable.js'; +import { deferRequestBody } from './deferred-request-body.js'; import { validationFailureDetails, VALIDATION_FAILED_STATUS } from './validation-failure.js'; import { buildApiError } from './error-envelope.js'; import { appEndpointMountPrefix, isAppEndpointPath, runAppEndpointStep } from './api-endpoint-step.js'; @@ -985,6 +986,16 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu sendResultBase(result, res, securityHeaders, { logger: ctx.logger }); const errorResponse = (err: any, res: any) => errorResponseBase(err, res, securityHeaders, { logger: ctx.logger }); + // [#24151] Every mount below whose domain reads a body hands + // `dispatch()` `deferRequestBody(req)`, ⛔ never `req.body`. Reading + // `req.body` here runs the transport's refuse-on-read ahead of + // `dispatch()`, so a body it cannot parse was answered `400` before + // the domain's anonymous decision (ADR-0138 D2: the domain's first + // statement) could answer `401`. Deferred, the domain reads the body + // once it has admitted the caller (`readDispatchedBody`), so the + // anonymous answer no longer depends on whether the body parses, + // and an admitted caller's unparseable body still gets the + // transport's refusal. See `./deferred-request-body.ts`. // ── Observability ────────────────────────────────────────── // Noop defaults; production hosts inject real adapters. @@ -1279,7 +1290,7 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu if (analyticsInstalled) { server.post(`${prefix}/analytics/query`, async (req: any, res: any) => { try { - const result = await dispatcher.dispatch('POST', '/analytics/query', req.body, req.query, { request: req }); + const result = await dispatcher.dispatch('POST', '/analytics/query', deferRequestBody(req), req.query, { request: req }); sendResult(result, res); } catch (err: any) { errorResponse(err, res); @@ -1297,7 +1308,7 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu server.post(`${prefix}/analytics/sql`, async (req: any, res: any) => { try { - const result = await dispatcher.dispatch('POST', '/analytics/sql', req.body, req.query, { request: req }); + const result = await dispatcher.dispatch('POST', '/analytics/sql', deferRequestBody(req), req.query, { request: req }); sendResult(result, res); } catch (err: any) { errorResponse(err, res); @@ -1323,7 +1334,7 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu const register = method === 'GET' ? server.get : method === 'DELETE' ? server.delete : server.post; register.call(server, `${prefix}/mcp`, async (req: any, res: any) => { try { - const result = await dispatcher.dispatch(method, '/mcp', req.body, req.query, { request: req }); + const result = await dispatcher.dispatch(method, '/mcp', deferRequestBody(req), req.query, { request: req }); sendResult(result, res); } catch (err: any) { errorResponse(err, res); @@ -1359,7 +1370,7 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu const register = method === 'GET' ? server.get : method === 'DELETE' ? server.delete : server.post; register.call(server, `${prefix}/mcp/skill`, async (req: any, res: any) => { try { - const result = await dispatcher.dispatch(method, '/mcp/skill', req.body, req.query, { request: req }); + const result = await dispatcher.dispatch(method, '/mcp/skill', deferRequestBody(req), req.query, { request: req }); sendResult(result, res); } catch (err: any) { errorResponse(err, res); @@ -1392,7 +1403,7 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu // Proxy above like every other mount here (#24025). server.post(`${prefix}/keys`, async (req: any, res: any) => { try { - const result = await dispatcher.dispatch('POST', '/keys', req.body, req.query, { request: req }); + const result = await dispatcher.dispatch('POST', '/keys', deferRequestBody(req), req.query, { request: req }); sendResult(result, res); } catch (err: any) { errorResponse(err, res); @@ -1406,7 +1417,7 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu : server.delete; register.call(server, `${prefix}/keys`, async (req: any, res: any) => { try { - const result = await dispatcher.dispatch(method, '/keys', req.body, req.query, { request: req }); + const result = await dispatcher.dispatch(method, '/keys', deferRequestBody(req), req.query, { request: req }); sendResult(result, res); } catch (err: any) { errorResponse(err, res); @@ -1444,7 +1455,7 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu server.post(`${prefix}/notifications/read`, async (req: any, res: any) => { try { - const result = await dispatcher.dispatch('POST', '/notifications/read', req.body, req.query, { request: req }); + const result = await dispatcher.dispatch('POST', '/notifications/read', deferRequestBody(req), req.query, { request: req }); sendResult(result, res); } catch (err: any) { errorResponse(err, res); @@ -1453,7 +1464,7 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu server.post(`${prefix}/notifications/read/all`, async (req: any, res: any) => { try { - const result = await dispatcher.dispatch('POST', '/notifications/read/all', req.body, req.query, { request: req }); + const result = await dispatcher.dispatch('POST', '/notifications/read/all', deferRequestBody(req), req.query, { request: req }); sendResult(result, res); } catch (err: any) { errorResponse(err, res); @@ -1550,7 +1561,7 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu const result = await dispatcher.dispatch( verb.toUpperCase(), `/packages${toSubPath(req)}`, - req.body, + deferRequestBody(req), req.query ?? {}, { request: req }, ); @@ -1673,7 +1684,7 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu // the list is `GET /api/v1/meta/flow`. server!.post(`${base}/automation`, async (req: any, res: any) => { try { - const result = await dispatcher.dispatch('POST', '/automation', req.body, req.query, { request: req }); + const result = await dispatcher.dispatch('POST', '/automation', deferRequestBody(req), req.query, { request: req }); sendResult(result, res); } catch (err: any) { errorResponse(err, res); @@ -1732,7 +1743,7 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu server!.put(`${base}/automation/:name`, async (req: any, res: any) => { try { - const result = await dispatcher.dispatch('PUT', `/automation/${req.params.name}`, req.body, req.query, { request: req }); + const result = await dispatcher.dispatch('PUT', `/automation/${req.params.name}`, deferRequestBody(req), req.query, { request: req }); sendResult(result, res); } catch (err: any) { errorResponse(err, res); @@ -1750,7 +1761,7 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu server!.post(`${base}/automation/trigger/:name`, async (req: any, res: any) => { try { - const result = await dispatcher.dispatch('POST', `/automation/trigger/${req.params.name}`, req.body, req.query, { request: req }); + const result = await dispatcher.dispatch('POST', `/automation/trigger/${req.params.name}`, deferRequestBody(req), req.query, { request: req }); sendResult(result, res); } catch (err: any) { errorResponse(err, res); @@ -1759,7 +1770,7 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu server!.post(`${base}/automation/:name/trigger`, async (req: any, res: any) => { try { - const result = await dispatcher.dispatch('POST', `/automation/${req.params.name}/trigger`, req.body, req.query, { request: req }); + const result = await dispatcher.dispatch('POST', `/automation/${req.params.name}/trigger`, deferRequestBody(req), req.query, { request: req }); sendResult(result, res); } catch (err: any) { errorResponse(err, res); @@ -1768,7 +1779,7 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu server!.post(`${base}/automation/:name/toggle`, async (req: any, res: any) => { try { - const result = await dispatcher.dispatch('POST', `/automation/${req.params.name}/toggle`, req.body, req.query, { request: req }); + const result = await dispatcher.dispatch('POST', `/automation/${req.params.name}/toggle`, deferRequestBody(req), req.query, { request: req }); sendResult(result, res); } catch (err: any) { errorResponse(err, res); @@ -1792,7 +1803,7 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu // dispatch path, which the domain answers `trigger` first. server!.post(`${base}/automation/:name/clone`, async (req: any, res: any) => { try { - const result = await dispatcher.dispatch('POST', `/automation/${req.params.name}/clone`, req.body, req.query, { request: req }); + const result = await dispatcher.dispatch('POST', `/automation/${req.params.name}/clone`, deferRequestBody(req), req.query, { request: req }); sendResult(result, res); } catch (err: any) { errorResponse(err, res); @@ -1821,7 +1832,7 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu // screen node's collected input, and re-fetch its pending screen. server!.post(`${base}/automation/:name/runs/:runId/resume`, async (req: any, res: any) => { try { - const result = await dispatcher.dispatch('POST', `/automation/${req.params.name}/runs/${req.params.runId}/resume`, req.body, req.query, { request: req }); + const result = await dispatcher.dispatch('POST', `/automation/${req.params.name}/runs/${req.params.runId}/resume`, deferRequestBody(req), req.query, { request: req }); sendResult(result, res); } catch (err: any) { errorResponse(err, res); @@ -1845,7 +1856,7 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu // second policy that happens to agree today. server!.post(`${base}/automation/:name/runs/:runId/cancel`, async (req: any, res: any) => { try { - const result = await dispatcher.dispatch('POST', `/automation/${req.params.name}/runs/${req.params.runId}/cancel`, req.body, req.query, { request: req }); + const result = await dispatcher.dispatch('POST', `/automation/${req.params.name}/runs/${req.params.runId}/cancel`, deferRequestBody(req), req.query, { request: req }); sendResult(result, res); } catch (err: any) { errorResponse(err, res); @@ -1854,7 +1865,7 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu server!.post(`${base}/automation/:name/runs/:runId/restore-suspension`, async (req: any, res: any) => { try { - const result = await dispatcher.dispatch('POST', `/automation/${req.params.name}/runs/${req.params.runId}/restore-suspension`, req.body, req.query, { request: req }); + const result = await dispatcher.dispatch('POST', `/automation/${req.params.name}/runs/${req.params.runId}/restore-suspension`, deferRequestBody(req), req.query, { request: req }); sendResult(result, res); } catch (err: any) { errorResponse(err, res); @@ -1906,7 +1917,7 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu const fullPath: string = req.path ?? ''; const idx = fullPath.lastIndexOf('/ai'); const aiSubPath = idx >= 0 ? fullPath.slice(idx) : '/ai'; - const result = await dispatcher.dispatch(method.toUpperCase(), aiSubPath, req.body, req.query, { request: req }); + const result = await dispatcher.dispatch(method.toUpperCase(), aiSubPath, deferRequestBody(req), req.query, { request: req }); sendResult(result, res); } catch (err: any) { errorResponse(err, res); @@ -1944,7 +1955,7 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu // the two-segment route below (verified against the router). server!.post(`${base}/actions//:action`, async (req: any, res: any) => { try { - const result = await dispatcher.dispatch('POST', `/actions//${req.params.action}`, req.body, req.query, { request: req }); + const result = await dispatcher.dispatch('POST', `/actions//${req.params.action}`, deferRequestBody(req), req.query, { request: req }); sendResult(result, res); } catch (err: any) { errorResponse(err, res); @@ -1952,7 +1963,7 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu }); server!.post(`${base}/actions/:object/:action`, async (req: any, res: any) => { try { - const result = await dispatcher.dispatch('POST', `/actions/${req.params.object}/${req.params.action}`, req.body, req.query, { request: req }); + const result = await dispatcher.dispatch('POST', `/actions/${req.params.object}/${req.params.action}`, deferRequestBody(req), req.query, { request: req }); sendResult(result, res); } catch (err: any) { errorResponse(err, res); @@ -1960,7 +1971,7 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu }); server!.post(`${base}/actions/:object/:action/:recordId`, async (req: any, res: any) => { try { - const result = await dispatcher.dispatch('POST', `/actions/${req.params.object}/${req.params.action}/${req.params.recordId}`, req.body, req.query, { request: req }); + const result = await dispatcher.dispatch('POST', `/actions/${req.params.object}/${req.params.action}/${req.params.recordId}`, deferRequestBody(req), req.query, { request: req }); sendResult(result, res); } catch (err: any) { errorResponse(err, res); @@ -2003,7 +2014,7 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu const result = await dispatcher.dispatch( 'POST', `/security/_activation/${req.params.type}/${req.params.name}`, - req.body, + deferRequestBody(req), req.query, { request: req }, ); @@ -2017,7 +2028,7 @@ export function createDispatcherPlugin(config: DispatcherPluginConfig = {}): Plu const result = await dispatcher.dispatch( 'POST', `/security/permission-sets/${encodeURIComponent(req.params.name)}/clone`, - req.body, + deferRequestBody(req), req.query, { request: req }, ); diff --git a/packages/runtime/src/domains/actions.ts b/packages/runtime/src/domains/actions.ts index e15f8b9fff1..a3eed15f335 100644 --- a/packages/runtime/src/domains/actions.ts +++ b/packages/runtime/src/domains/actions.ts @@ -83,6 +83,7 @@ import { } from './activation-gate.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; +import { readDispatchedBody } from '../deferred-request-body.js'; /** * [ADR-0126 §8 item 2] The reserved first segment of the activation door, @@ -449,6 +450,9 @@ export async function handleActionsRequest(deps: DomainHandlerDeps, path: string }; } } + // [#24151] The body is read once the baseline above has admitted the + // caller, never ahead of it (`../deferred-request-body.ts`). + body = readDispatchedBody(body); if (method.toUpperCase() !== 'POST') { // RFC 9110 §15.5.6: a 405 MUST carry an `Allow` header naming the // target resource's supported methods. Hand-rolled rather than diff --git a/packages/runtime/src/domains/ai.ts b/packages/runtime/src/domains/ai.ts index 3213f242a87..4b8854c1672 100644 --- a/packages/runtime/src/domains/ai.ts +++ b/packages/runtime/src/domains/ai.ts @@ -20,6 +20,7 @@ import { buildApiError } from '../error-envelope.js'; import type { IAIService } from '@objectstack/spec/contracts'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; +import { readDispatchedBody } from '../deferred-request-body.js'; export function createAiDomain(deps: DomainHandlerDeps): DomainRoute { return { @@ -63,6 +64,11 @@ export async function handleAIRequest(deps: DomainHandlerDeps, subPath: string, handled: true, response: deps.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE }), }); + // [#24151] The body is read once this decision has admitted the caller, + // never ahead of it (`../deferred-request-body.ts`): here for a caller it + // admits, and in the route loop below for an anonymous caller a route's + // `auth: false` admits. + if (!denyAnonymous) body = readDispatchedBody(body); let aiService: IAIService | undefined; try { @@ -180,6 +186,10 @@ export async function handleAIRequest(deps: DomainHandlerDeps, subPath: string, // itself is the same one — `denyAnonymous`, computed once above — // so the two exits cannot answer differently. if (route.auth !== false && denyAnonymous) return anonymousRefusal(); + // [#24151] An anonymous caller this route's `auth: false` admitted has + // its body read here; an admitted caller's was read at the decision + // above, and reading a body that is no longer deferred returns it as is. + const admittedBody = readDispatchedBody(body); // Resolve `req.user` from the already-resolved ExecutionContext so // AI route handlers can attribute the call to the authenticated @@ -247,7 +257,7 @@ export async function handleAIRequest(deps: DomainHandlerDeps, subPath: string, : undefined; const result = await route.handler({ - body, + body: admittedBody, params, query, headers: context.request?.headers, diff --git a/packages/runtime/src/domains/analytics.ts b/packages/runtime/src/domains/analytics.ts index 3bb9a967758..03cf5d2b8b8 100644 --- a/packages/runtime/src/domains/analytics.ts +++ b/packages/runtime/src/domains/analytics.ts @@ -22,6 +22,7 @@ import { CoreServiceName } from '@objectstack/spec/system'; import { AnalyticsQueryRequestSchema } from '@objectstack/spec/api'; import { isAnalyticsDateRangeRefusalIssue } from '@objectstack/spec/data'; import { isServiceServeable } from '../service-serveable.js'; +import { readDispatchedBody } from '../deferred-request-body.js'; import { validationFailure, fieldsFromZodIssues } from '../validation-failure.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; @@ -141,6 +142,9 @@ export async function handleAnalyticsRequest( response: deps.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE }), }; } + // [#24151] The body is read once the floor above has admitted the caller, + // never ahead of it (`../deferred-request-body.ts`). + body = readDispatchedBody(body); const analyticsService = await deps.getService(context, CoreServiceName.enum.analytics); // Empty slot — or a slot filled by a self-declared stub (#4000), which is diff --git a/packages/runtime/src/domains/automation.ts b/packages/runtime/src/domains/automation.ts index 0a859d74384..65c9cffdde7 100644 --- a/packages/runtime/src/domains/automation.ts +++ b/packages/runtime/src/domains/automation.ts @@ -68,6 +68,7 @@ import { } from '../flow-clone.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; +import { readDispatchedBody } from '../deferred-request-body.js'; // [#20552] The per-type credential redaction every metadata read exit applies, // and its write-path inverse — reused, never restated, for the definitions this // domain serves and overwrites. @@ -2513,6 +2514,11 @@ export async function handleAutomationRequest(deps: DomainHandlerDeps, path: str const hook = matchInboundHookRoute(routePath, method); if (hook) return handleInboundHookRequest(deps, context, hook); } + // [#24151] The body is read once the floor above has admitted the caller, + // never ahead of it (`../deferred-request-body.ts`) — and after the inbound + // hook is handed off, because that route verifies the raw bytes and never + // reads a parsed body. + body = readDispatchedBody(body); const m = method.toUpperCase(); const parts = path.replace(/^\/+/, '').split('/').filter(Boolean); diff --git a/packages/runtime/src/domains/keys.ts b/packages/runtime/src/domains/keys.ts index f6d9a554769..629f1f78e8b 100644 --- a/packages/runtime/src/domains/keys.ts +++ b/packages/runtime/src/domains/keys.ts @@ -51,6 +51,7 @@ import { generateApiKey } from '../security/api-key.js'; import { buildApiError } from '../error-envelope.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; +import { readDispatchedBody } from '../deferred-request-body.js'; const DAY_MS = 86_400_000; @@ -114,6 +115,9 @@ export async function handleKeysRequest( response: deps.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE }), }; } + // [#24151] The body is read once the baseline above has admitted the + // caller, never ahead of it (`../deferred-request-body.ts`). + body = readDispatchedBody(body); if (method !== 'POST') { // RFC 9110 §15.5.6: a 405 MUST carry an `Allow` header naming the diff --git a/packages/runtime/src/domains/mcp.ts b/packages/runtime/src/domains/mcp.ts index 66f4902f6f4..2f3cc854566 100644 --- a/packages/runtime/src/domains/mcp.ts +++ b/packages/runtime/src/domains/mcp.ts @@ -18,6 +18,7 @@ import * as actionExec from '../action-execution.js'; import { isSystemObjectName } from '../action-execution.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; +import { readDispatchedBody } from '../deferred-request-body.js'; // [#15705] The resume door's one refusal table, shared with // `POST /automation/:name/runs/:runId/resume` so the two doors answer one // engine result identically. @@ -90,6 +91,9 @@ export async function handleMcpRequest(deps: DomainHandlerDeps, body: any, conte } return { handled: true, response }; } + // [#24151] The body is read once the anonymous decision above has admitted + // the caller, never ahead of it (`../deferred-request-body.ts`). + body = readDispatchedBody(body); // ── OAuth scope → tool-family enforcement (fail-closed, #2698) ── // `oauthScopes` is set ONLY for OAuth-token provenance. A token that diff --git a/packages/runtime/src/domains/notifications.ts b/packages/runtime/src/domains/notifications.ts index 3e022849c27..1391e051e57 100644 --- a/packages/runtime/src/domains/notifications.ts +++ b/packages/runtime/src/domains/notifications.ts @@ -37,6 +37,7 @@ import { parseBooleanParam, parseIntegerParam, parseStringParam } from '../query import { capabilityUnavailable } from './unavailable.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; +import { readDispatchedBody } from '../deferred-request-body.js'; // --------------------------------------------------------------------------- // [#6928] GET /notifications — the query parse point, with the coercions checked @@ -134,6 +135,9 @@ export async function handleNotificationRequest( response: deps.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE }), }; } + // [#24151] The body is read once the baseline above has admitted the + // caller, never ahead of it (`../deferred-request-body.ts`). + body = readDispatchedBody(body); // [#4127] Typed against the contract instead of `as any`, so a call this // file makes that `INotificationService` does not declare is a compile diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index 3168f2e89ab..8de6149772c 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -123,6 +123,7 @@ import { parseBooleanParam } from '../query-param.js'; import { setPackageDisabled } from '../package-state-store.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; +import { readDispatchedBody } from '../deferred-request-body.js'; // [#19328] The install door's DECLARED body contract, asked whole at // `POST /api/v1/packages` — the union of the wrapped request and a bare // manifest — plus each branch by name, which the refusal re-reads to locate a @@ -1070,6 +1071,9 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin }; } } + // [#24151] The body is read once the floor above has admitted the caller, + // never ahead of it (`../deferred-request-body.ts`). + body = readDispatchedBody(body); const parts = path.replace(/^\/+/, '').split('/').filter(Boolean); diff --git a/packages/runtime/src/domains/security.ts b/packages/runtime/src/domains/security.ts index 6b34e00a868..45a96a28756 100644 --- a/packages/runtime/src/domains/security.ts +++ b/packages/runtime/src/domains/security.ts @@ -33,6 +33,7 @@ import { isCatalogActivationWrite, handleCatalogActivationWrite } from './catalo import { isPermissionSetCloneWrite, handlePermissionSetCloneWrite } from './permission-set-clone.js'; import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; +import { readDispatchedBody } from '../deferred-request-body.js'; // [#23214] The repo's one sentence for a repeated single-valued parameter. import { repeatedQueryParamMessage } from '@objectstack/rest'; @@ -90,6 +91,9 @@ export async function handleSecurityRequest( response: deps.error(ANONYMOUS_DENY_MESSAGE, ANONYMOUS_DENY_STATUS, { code: ANONYMOUS_DENY_CODE }), }; } + // [#24151] The body is read once the baseline above has admitted the + // caller, never ahead of it (`../deferred-request-body.ts`). + _body = readDispatchedBody(_body); // [ADR-0126 §3 regime C, ADR-0131 D3/D6] THE CATALOG ACTIVATION DOOR — // switch a position or a permission set on/off for this deployment, which From 8e9fd41b2b61f691703c359b87f5c504fcafecdf Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 18 Oct 2026 03:34:14 +0000 Subject: [PATCH 2/2] chore(changeset): runtime patch for the body read after the anonymous decision Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01QyoWKK9RwUNxBxmevy9Neb --- .changeset/24151-body-after-anonymous-floor.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .changeset/24151-body-after-anonymous-floor.md diff --git a/.changeset/24151-body-after-anonymous-floor.md b/.changeset/24151-body-after-anonymous-floor.md new file mode 100644 index 00000000000..3d0ccce023d --- /dev/null +++ b/.changeset/24151-body-after-anonymous-floor.md @@ -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.