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
25 changes: 25 additions & 0 deletions .changeset/23528-exposure-refusal-code.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@objectstack/runtime': patch
---

fix(runtime): `callData`'s exposure refusal for the object a request addresses carries the data door's code, `OBJECT_API_DISABLED` (404) or `OBJECT_API_METHOD_NOT_ALLOWED` (405), on the dispatcher's `/data` routes, the `/mcp` data tools and declared endpoints (#23528)

Clause-②: no

**What was wrong.** An object can declare `apiEnabled: false` or an `apiMethods` whitelist. The runtime dispatcher's exposure check for the object a request addresses (`refuseUnexposedDataAction`, which every `callData` asks first) refused with a status and a message but no `code`. So:

- the dispatcher's `/data` routes derived a code from the status: `404 RESOURCE_NOT_FOUND` / `405 METHOD_NOT_ALLOWED`;
- the `/mcp` data tools rendered the message alone;
- a declared endpoint whose target is an object answered the derived code too.

`RestServer` and the MCP stdio tools answer the same declaration `OBJECT_API_DISABLED` / `OBJECT_API_METHOD_NOT_ALLOWED`, and so did the dispatcher's own refusal of a nested-relation filter on an unexposed object.

**What a caller reads now.** Every one of these doors answers the data door's code with the same status as before:

| door | declaration | FROM | TO |
|:--|:--|:--|:--|
| dispatcher `/data` (and declared endpoints) | `apiEnabled: false` | `404`, `error.code` `RESOURCE_NOT_FOUND` | `404`, `error.code` `OBJECT_API_DISABLED` |
| dispatcher `/data` (and declared endpoints) | verb outside `apiMethods` | `405`, `error.code` `METHOD_NOT_ALLOWED` | `405`, `error.code` `OBJECT_API_METHOD_NOT_ALLOWED` |
| `/mcp` data tools | either | `isError: true`; the text is the message | `isError: true`; the text is `{ "error": { "code", "message", "status" } }` |

The status and the message do not change. The thrown value is now an `Error` carrying `code`, `status`, the addressed object as `object`, and the effective operation set as `allowed` on a 405. A caller that branched on `RESOURCE_NOT_FOUND` / `METHOD_NOT_ALLOWED` for an exposure refusal on these doors should branch on the two `OBJECT_API_*` codes, as it already does for `RestServer`.
44 changes: 36 additions & 8 deletions packages/mcp/src/mcp-coded-refusal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,12 @@
* `{ error: { code, message, status } }` with `isError`. The table is held
* equal to the registered tool list, so a tool added later needs a row.
* - The same catches render a code-less throw as its bare message,
* byte-identical to `errorResult(messageOf(err))`. That includes the value
* the HTTP bridge's exposure gate throws today, `{ statusCode, message }`
* with no `code`. Nothing is invented for it.
* byte-identical to `errorResult(messageOf(err))`. Nothing is invented for it.
* - [#23528] The HTTP bridge's exposure gate (`@objectstack/runtime`'s
* `refuseUnexposedDataAction`, which `callData` asks first) used to be such
* a throw, `{ statusCode, message }` with no `code`. It now throws the data
* door's coded refusal, and renders as one. The producer side is pinned in
* runtime's `action-execution-addressed-exposure-refusal.test.ts`.
* - The data tools and `list_actions` WITHHOLD `details`. Measured on `/mcp`,
* a `PERMISSION_DENIED` carries the caller's `positions` and
* `permissionSets` there, which the runtime dispatcher drops from the wire.
Expand Down Expand Up @@ -195,11 +198,36 @@ describe('MCP tools over HTTP /mcp: a coded refusal carries its code (#23509)',
// One site is enough below: every catch renders through the one reader.
const query = SITES.find((s) => s.tool === 'query_records')!;

it('the HTTP bridge exposure gate\'s `{ statusCode, message }` (no `code`) answers its bare message: nothing is invented', async () => {
const thrown = { statusCode: 404, message: "Object 'task' is not exposed via the API" };
expect(await callTool(query, thrown)).toEqual({
content: [{ type: 'text', text: "Object 'task' is not exposed via the API" }],
isError: true,
// [#23528] Moved to the coded rendering. This row pinned the gate's old
// `{ statusCode: 404, message }` as its bare message. The gate now throws
// an `Error` carrying the data door's `code` and `status`, the addressed
// object as `object`, and `allowed` on a 405; the thrown values below are
// that shape, with the gate's own messages.
it('the HTTP bridge exposure gate\'s 404 answers OBJECT_API_DISABLED with its status', async () => {
const thrown = Object.assign(new Error('object is not exposed via the API'), {
code: 'OBJECT_API_DISABLED',
status: 404,
object: 'task',
});
const result = await callTool(query, thrown);
expect(result.isError).toBe(true);
expect(result.content).toHaveLength(1);
expect(JSON.parse(result.content[0].text)).toEqual({
error: { code: 'OBJECT_API_DISABLED', message: 'object is not exposed via the API', status: 404 },
});
});

it('the HTTP bridge exposure gate\'s 405 answers OBJECT_API_METHOD_NOT_ALLOWED with its status', async () => {
const thrown = Object.assign(new Error("API operation 'list' is not allowed for this object"), {
code: 'OBJECT_API_METHOD_NOT_ALLOWED',
status: 405,
object: 'task',
allowed: ['get'],
});
const result = await callTool(query, thrown);
expect(result.isError).toBe(true);
expect(JSON.parse(result.content[0].text)).toEqual({
error: { code: 'OBJECT_API_METHOD_NOT_ALLOWED', message: "API operation 'list' is not allowed for this object", status: 405 },
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@
* A throw that is NOT a declared envelope keeps the arm's answer: a bare
* `Error` is still `500 INTERNAL_ERROR` carrying `code` and `message` only, with
* no `cause` in the body; a throw that declares `statusCode` and no registered
* code (the shape `callData`'s exposure gate throws) keeps the status it
* declared. The declared 503 also carries no `cause`: the session read's own
* code (the shape `callData` throws when no data service resolves) keeps the
* status it declared. The declared 503 also carries no `cause`: the session read's own
* failure stays off the wire.
*
* ## The auth-mount arm
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* [#23528] `callData`'s ADDRESSED-object exposure refusal
* (`refuseUnexposedDataAction`) is the data door's: an `Error` carrying `code`
* and `status`, the addressed object as `object`, and the effective operation
* set as `allowed` on a 405. Its nested-relation target refusal already
* answered that way (`action-execution-calldata-relation-target-exposure.test.ts`),
* and so do `RestServer` and the MCP stdio face for the same declaration.
*
* ## What was open
*
* The gate threw `{ statusCode, message }` with no `code`. Each door then
* answered it without the data door's code:
*
* - the runtime dispatcher's `/data` door derived `404 RESOURCE_NOT_FOUND` /
* `405 METHOD_NOT_ALLOWED` from the status
* (`domains/data-write-request-gate.test.ts` pins both doors on a real store);
* - the `/mcp` tools render a thrown `code` since #23509, and rendered this
* refusal as its message alone (`buildMcpBridge` calls `callData` on every
* data verb, pinned below).
*
* The status and the message are unchanged. Every row asserts `code` and
* `status` and that neither the protocol nor the engine was asked.
*/

import { describe, expect, it, vi } from 'vitest';
import { callData, type ActionExecutionDeps } from './action-execution.js';
import { buildMcpBridge } from './domains/mcp.js';
import type { HttpProtocolContext } from './http-dispatcher.js';

const MEMBER = { userId: 'ax_member', isSystem: false, positions: [], permissions: [] } as any;
const REQ = { request: {}, executionContext: MEMBER } as HttpProtocolContext;

const object = (name: string, enable?: Record<string, unknown>) => ({
name,
label: name,
...(enable ? { enable } : {}),
fields: {
id: { name: 'id', label: 'ID', type: 'text' },
name: { name: 'name', label: 'Name', type: 'text' },
},
});

/** The fixture: `ax_hidden` is not exposed at all, `ax_getonly` exposes `get` alone. */
const SCHEMAS: Record<string, unknown> = {
ax_hidden: object('ax_hidden', { apiEnabled: false }),
ax_getonly: object('ax_getonly', { apiMethods: ['get'] }),
};

const HIDDEN = { code: 'OBJECT_API_DISABLED', status: 404, object: 'ax_hidden', allowed: undefined };
const HIDDEN_MESSAGE = 'object is not exposed via the API';

/**
* The protocol and engine `callData` would reach, each verb a spy, so a refusal
* is shown to ask neither. With the protocol present it serves every verb but
* `aggregate`, which takes the engine; so the engine carries only the reads.
*/
function makeHarness() {
const protocol = {
createData: vi.fn(async (req: any) => ({ object: req.object, id: 'n1', record: req.data })),
getData: vi.fn(async (req: any) => ({ object: req.object, id: req.id, record: { id: req.id } })),
updateData: vi.fn(async (req: any) => ({ object: req.object, id: req.id, record: req.data })),
deleteData: vi.fn(async (req: any) => ({ object: req.object, id: req.id, success: true })),
findData: vi.fn(async (req: any) => ({ object: req.object, records: [], total: 0, hasMore: false })),
};
const engine = {
registry: { getObject: (n: string) => SCHEMAS[n] },
find: vi.fn(async () => []),
aggregate: vi.fn(async () => []),
};
const services: Record<string, unknown> = {
metadata: { getObject: async (n: string) => SCHEMAS[n] },
protocol,
objectql: engine,
};
const deps: ActionExecutionDeps = {
resolveService: (async (_ctx: HttpProtocolContext, name: string) => services[name]) as any,
getObjectQL: async () => engine as any,
};
const asked = () => [...Object.values(protocol), engine.find, engine.aggregate]
.reduce((n, spy) => n + spy.mock.calls.length, 0);
return { deps, protocol, asked };
}

/** What a door reads off the refusal: the envelope pair and the two context fields. */
const refusal = (e: any) => ({ code: e?.code, status: e?.status, object: e?.object, allowed: e?.allowed });

/** Every action `callData` serves, with params naming `object`. */
const VERBS: ReadonlyArray<readonly [string, (object: string) => Record<string, unknown>]> = [
['create', (o) => ({ object: o, data: { name: 'x' } })],
['get', (o) => ({ object: o, id: 'r1' })],
['update', (o) => ({ object: o, id: 'r1', data: { name: 'x' } })],
['delete', (o) => ({ object: o, id: 'r1' })],
['query', (o) => ({ object: o, query: {} })],
['find', (o) => ({ object: o, query: {} })],
['aggregate', (o) => ({ object: o, aggregations: [{ function: 'count', alias: 'n' }] })],
];

describe('[#23528] callData refuses an addressed object its declaration does not expose with the data door\'s code', () => {
for (const [action, params] of VERBS) {
it(`${action} on an \`apiEnabled: false\` object: 404 OBJECT_API_DISABLED, nothing asked`, async () => {
const h = makeHarness();
const err: any = await callData(h.deps, REQ, action, params('ax_hidden'), undefined, undefined, MEMBER).catch((e) => e);
expect(err).toBeInstanceOf(Error);
expect(refusal(err)).toEqual(HIDDEN);
expect(err.message).toBe(HIDDEN_MESSAGE);
expect(h.asked()).toBe(0);
});
}

it('create on an object exposing `get` alone: 405 OBJECT_API_METHOD_NOT_ALLOWED naming the effective set, nothing asked', async () => {
const h = makeHarness();
const err: any = await callData(h.deps, REQ, 'create', { object: 'ax_getonly', data: { name: 'x' } }, undefined, undefined, MEMBER)
.catch((e) => e);
expect(err).toBeInstanceOf(Error);
expect(refusal(err)).toEqual({ code: 'OBJECT_API_METHOD_NOT_ALLOWED', status: 405, object: 'ax_getonly', allowed: ['get'] });
expect(err.message).toBe("API operation 'create' is not allowed for this object");
expect(h.asked()).toBe(0);
});

it('CONTROL: the verb the whitelist grants is served', async () => {
const h = makeHarness();
await callData(h.deps, REQ, 'get', { object: 'ax_getonly', id: 'r1' }, undefined, undefined, MEMBER);
expect(h.protocol.getData).toHaveBeenCalledTimes(1);
});

it('CONTROL: a system context is not asked, so the hidden object is served', async () => {
const h = makeHarness();
await callData(h.deps, REQ, 'query', { object: 'ax_hidden', query: {} }, undefined, undefined, { isSystem: true } as any);
expect(h.protocol.findData).toHaveBeenCalledTimes(1);
});
});

/**
* The `/mcp` face: `buildMcpBridge` is the bridge `handleMcp` hands the MCP
* tools, and each data member reaches `callData`. The tools render a thrown
* string `code` and numeric `status` as `{ error: { code, message, status } }`
* (`@objectstack/mcp`'s `thrownRefusalEnvelope`, pinned in its
* `mcp-coded-refusal.test.ts`), so the bridge must hand them the coded refusal.
*/
describe('[#23528] the /mcp bridge hands its tools the coded refusal on every data member', () => {
const MEMBERS: ReadonlyArray<readonly [string, (bridge: any) => Promise<unknown>]> = [
['query', (b) => b.query('ax_hidden', {})],
['get', (b) => b.get('ax_hidden', 'r1')],
['aggregate', (b) => b.aggregate('ax_hidden', { aggregations: [{ function: 'count', alias: 'n' }] })],
['create', (b) => b.create('ax_hidden', { name: 'x' })],
['update', (b) => b.update('ax_hidden', 'r1', { name: 'x' })],
['remove', (b) => b.remove('ax_hidden', 'r1')],
];

for (const [member, call] of MEMBERS) {
it(`${member}: 404 OBJECT_API_DISABLED, nothing asked`, async () => {
const h = makeHarness();
const bridge = buildMcpBridge(h.deps as any, REQ);
const err: any = await call(bridge).catch((e) => e);
expect(err).toBeInstanceOf(Error);
expect(refusal(err)).toEqual(HIDDEN);
expect(err.message).toBe(HIDDEN_MESSAGE);
expect(h.asked()).toBe(0);
});
}
});
57 changes: 39 additions & 18 deletions packages/runtime/src/action-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,31 +239,42 @@ async function refuseUnservedRelationTargets(
}
}

/**
* [#23528] The data door's two exposure codes, by the gate's verdict: `404
* OBJECT_API_DISABLED` for an object whose `apiEnabled` is `false`, and `405
* OBJECT_API_METHOD_NOT_ALLOWED` with the effective operation set as `allowed`
* for a verb its `apiMethods` leaves out. That is the pairing `RestServer`
* answers for the same declaration (`apiAccessDenialFromEnable`).
*
* The ONE table both exposure refusals in this file stamp: the addressed
* object's ({@link refuseUnexposedDataAction}) and a nested-relation
* condition's target's ({@link unservedRelationTargetError}). One table, so one
* `callData` never answers the same verdict with two codes. A denial from
* {@link checkApiExposure} carries exactly one of these two statuses.
*/
function exposureRefusalFields(gate: ApiExposureDecision):
| { code: 'OBJECT_API_DISABLED'; status: 404 }
| { code: 'OBJECT_API_METHOD_NOT_ALLOWED'; status: 405; allowed: string[] } {
return gate.status === 404
? { code: 'OBJECT_API_DISABLED', status: 404 }
: { code: 'OBJECT_API_METHOD_NOT_ALLOWED', status: 405, allowed: gate.allowedOperations ?? [] };
}

/**
* [#22823] The data door's refusal of an unserved nested-relation condition
* target, field for field (`refuseUnservedRelationTarget`): `code` and `status`
* by the gate's verdict, the TARGET as `object` (the route's object is not the
* one switched off), the condition's position as `param`, and the effective
* operation set as `allowed` on a 405.
* by the gate's verdict ({@link exposureRefusalFields}), the TARGET as `object`
* (the route's object is not the one switched off), the condition's position
* as `param`, and the effective operation set as `allowed` on a 405.
*/
function unservedRelationTargetError(target: string, param: string, gate: ApiExposureDecision): Error {
const consequence = `, so the condition on it at '${param}' was not evaluated and the query was not run. `
+ 'Filter on the lookup\'s stored id instead, or remove the condition.';
const disabled = gate.status === 404;
const err: any = new Error(disabled
const refusal = exposureRefusalFields(gate);
const message = refusal.status === 404
? `Object '${target}' is not exposed via the API${consequence}`
: `API operation '${RELATION_CONDITION_TARGET_OPERATION}' is not allowed on object '${target}'${consequence}`);
if (disabled) {
err.code = 'OBJECT_API_DISABLED';
err.status = 404;
} else {
err.code = 'OBJECT_API_METHOD_NOT_ALLOWED';
err.status = 405;
err.allowed = gate.allowedOperations ?? [];
}
err.object = target;
err.param = param;
return err;
: `API operation '${RELATION_CONDITION_TARGET_OPERATION}' is not allowed on object '${target}'${consequence}`;
return Object.assign(new Error(message), refusal, { object: target, param });
}

/** The engine for a schema lookup, or `undefined` — a lookup failure is judged by the caller's fail-closed branch. */
Expand All @@ -290,6 +301,16 @@ async function resolveEngineQuietly(deps: ActionExecutionDeps, requestContext: H
* about the body it sent. `RestServer` asks `enforceApiAccess` before its
* twin gate for the same reason. Moved, not copied: this is the one place the
* dispatcher's exposure decision is resolved.
*
* [#23528] The refusal is the data door's, as the nested-relation target's
* already was: an `Error` carrying `code` and `status` from
* {@link exposureRefusalFields}, the addressed object as `object`, and
* `allowed` on a 405. It used to throw `{ statusCode, message }` with no code,
* so the dispatcher's `/data` door answered `404 RESOURCE_NOT_FOUND` / `405
* METHOD_NOT_ALLOWED` (codes derived from the status) and the `/mcp` tools
* rendered the message alone, where `RestServer` and the MCP stdio face answer
* the same declaration `OBJECT_API_DISABLED` / `OBJECT_API_METHOD_NOT_ALLOWED`.
* The statuses do not move, and neither does the message.
*/
export async function refuseUnexposedDataAction(deps: ActionExecutionDeps,
requestContext: HttpProtocolContext,
Expand All @@ -308,7 +329,7 @@ export async function refuseUnexposedDataAction(deps: ActionExecutionDeps,
}
const gate = checkApiExposure(def, action);
if (!gate.allowed) {
throw { statusCode: gate.status ?? 403, message: gate.reason ?? 'API access denied' };
throw Object.assign(new Error(gate.reason ?? 'API access denied'), exposureRefusalFields(gate), { object });
}
}

Expand Down
Loading
Loading