Skip to content

Commit 9fcea20

Browse files
fix(runtime): callData's addressed-object exposure refusal carries the data door's code on the /data and /mcp doors (#23773)
Fixes #23528 Clause-②: no ## What changed **`packages/runtime/src/action-execution.ts`**, the producer. - `refuseUnexposedDataAction` is the exposure gate every `callData` asks first. The runtime dispatcher's `/data` domain also asks it before its request gates. It threw `{ statusCode: gate.status ?? 403, message }` with no `code`. - It now throws an `Error` carrying: - `code` and `status`: `OBJECT_API_DISABLED` / 404, or `OBJECT_API_METHOD_NOT_ALLOWED` / 405; - the addressed object as `object`; - the effective operation set as `allowed` on a 405. - The 404/405 to code mapping is now one function, `exposureRefusalFields`. Both exposure refusals in the file stamp from it: this one, and the nested-relation target refusal (`unservedRelationTargetError`), which used to spell the table inline. Each keeps its own message text. - The status and the message are unchanged. **What a caller reads now**, for the same declaration: | door | `apiEnabled: false` | verb outside `apiMethods` | |:--|:--|:--| | dispatcher `/data` (and declared endpoints), before | `404 RESOURCE_NOT_FOUND` | `405 METHOD_NOT_ALLOWED` | | dispatcher `/data` (and declared endpoints), after | `404 OBJECT_API_DISABLED` | `405 OBJECT_API_METHOD_NOT_ALLOWED` | | `/mcp` data tools, before | the message alone | the message alone | | `/mcp` data tools, after | `{ error: { code: OBJECT_API_DISABLED, message, status: 404 } }` | `{ error: { code: OBJECT_API_METHOD_NOT_ALLOWED, message, status: 405 } }` | | `RestServer`, MCP stdio | unchanged: `OBJECT_API_DISABLED` | unchanged: `OBJECT_API_METHOD_NOT_ALLOWED` | Changeset: `.changeset/23528-exposure-refusal-code.md`, `@objectstack/runtime` patch. It names the FROM and TO codes for a caller that branched on the derived ones. ## Mechanism readings (the dispatch's hypotheses) 1. **The throw** at `534ca13eb` was `action-execution.ts:311`, `throw { statusCode: gate.status ?? 403, message: gate.reason ?? 'API access denied' }`. Confirmed. 2. **The `?? 403` arm.** `checkApiExposure` (`api-exposure.ts:113`) denies with exactly two statuses: `404` when `apiEnabled === false` (`:124`) and `405` for a whitelist miss (`:139`). Every denial also sets `reason`. So the `?? 403` arm and the `'API access denied'` fallback were unreachable, and the shared mapping has no third arm. The message fallback stays only because `reason` is typed optional. 3. **The dispatcher `/data` door** answered `METHOD_NOT_ALLOWED` / `RESOURCE_NOT_FOUND`, as PR #23617 measured. On its rig (a real store, `RestServer`'s registered handler beside `HttpDispatcher.dispatch()`), both doors now answer the same `status` AND `code` for every body (pins below). Statuses did not move. 4. **Pin sweep.** I grepped both codes, the derived codes, `statusCode: 40x`, the gate's messages and the prose that names the gate's shape, repo-wide. Every pin that held the old semantics moved in this PR (table below). 5. **`ERROR_CODE_LEDGER['@objectstack/runtime']`** already lists both codes. No ledger row is added and `check:error-code-provenance` has nothing new to hold. Its comment names only the nested-relation walk as the runtime's emitter (see Acceptance notes). ## Pins | file | before | after | |:--|:--|:--| | `runtime/src/action-execution-addressed-exposure-refusal.test.ts` (new, 16 cases) | none | The card's pin, on fixture objects `ax_hidden` (`apiEnabled: false`) and `ax_getonly` (`apiMethods: ['get']`). Every `callData` action (`create`, `get`, `update`, `delete`, `query`, `find`, `aggregate`) on `ax_hidden` rejects with an `Error` carrying `code` `OBJECT_API_DISABLED`, `status` 404, `object`, and the gate's message, and asks neither the protocol nor the engine. `create` on `ax_getonly` gives 405 `OBJECT_API_METHOD_NOT_ALLOWED` with `allowed: ['get']`. Every `/mcp` bridge data member (`buildMcpBridge`: `query`, `get`, `aggregate`, `create`, `update`, `remove`) hands its tool the same coded refusal. Two controls: a granted verb is served, and a system context is not asked. | | `runtime/src/domains/data-write-request-gate.test.ts` (PR #23617's rig) | per door: status only; across doors: statuses equal, "the two doors' exposure envelopes carry different codes" | per door: `{ status, code }` equal to `405 OBJECT_API_METHOD_NOT_ALLOWED` / `404 OBJECT_API_DISABLED` for every body; across doors: each body's `{ status, code }` equal | | `runtime/src/domains/data-path-object.test.ts` | the throw `toMatchObject({ statusCode: 404 })`; the wire `error.code` `RESOURCE_NOT_FOUND` | the throw carries `code` `OBJECT_API_DISABLED`, `status` 404 and the path object; the dispatcher's real `errorFromThrown` answers a conformant envelope with `error.code` `OBJECT_API_DISABLED` | | `runtime/src/endpoint-executor.test.ts` | the `statusCode` row named `callData`'s exposure gate as its producer | That row now names the producers that still throw `statusCode`, with the same assertions. A new row drives the REAL gate's throw through the endpoint failure mapping: `404`, `error.code` `OBJECT_API_DISABLED`. | | `mcp/src/mcp-coded-refusal.test.ts` (PR #23526) | "the HTTP bridge exposure gate's `{ statusCode, message }` (no `code`) answers its bare message" | Moved to the coded rendering. 404: `{ error: { code: 'OBJECT_API_DISABLED', message, status: 404 } }`. Plus a 405 row. The generic code-less rows (`new Error('engine exploded')`) still hold "nothing is invented". | | `qa/http-conformance/src/hono-declared-envelope.conformance.test.ts` | header comment: a `statusCode`-only throw is "the shape `callData`'s exposure gate throws" | Comment only: "the shape `callData` throws when no data service resolves" (`{ statusCode: 503 }`, which its control row already cites). | ## Ablation The fix was committed first. The mutation went through `node scripts/ablation-replace.mjs`, with the anchor required to hit once, a restore trap, and the restore proven by blob hash plus an empty `git diff HEAD`. The subject is imported from source by relative path in every file run, so no `dist/` leg applies. - **Mutation.** The gate's new throw was replaced with the old `throw { statusCode: gate.status ?? 403, message: … }`. Anchor count 1 to 0; blob `270d6e439622` to `25ab1fa94db0`. - **Leg 1, at `b6b23e102`, six files.** 29 failed, 152 passed (181): - the 14 new non-control pins; - both `data-path-object` rows; - 12 dispatcher rows in `data-write-request-gate`: the catch-all door's 6, and the 6 cross-door rows; - the new `endpoint-executor` row. - **What stayed green in leg 1.** `RestServer`'s 6 exposure rows, the controls, and the relation-target suite (`action-execution-calldata-relation-target-exposure.test.ts`, which pins the refactored `unservedRelationTargetError`). - **Leg 2, at `44a7517f5`, after the engine fake was trimmed.** Re-run on the new file: 14 failed, 2 passed (the two controls). - **Restore, both legs.** Blob back to `270d6e439622`, which is HEAD's; `git diff HEAD` empty. ## Verification - **Dependency closure.** `pnpm --filter '@objectstack/runtime^...' build`: `VERDICT command-exit 0`. After the merge of `main`: `turbo run build --filter=@objectstack/runtime... --filter=@objectstack/mcp...`, 32/32 tasks, exit 0. Before the final gate run: a full `turbo run build --filter=!@objectstack/docs`, 75/75 tasks, exit 0. - **`@objectstack/runtime` test, the `local` project**, at `e95c13308`, run as six `--shard` slices. Each slice was one foreground run under the verify lock, because the whole project does not fit the container's foreground cap. Each slice: `VERDICT command-exit 0`, 66 files passed. - Totals: 396 files, 5,935 tests passed, 19 skipped. - The `repo` project (`test:repo`) is CI's. - **`@objectstack/runtime` typecheck**: exit 0. `tsc --noEmit` passes, and `check:test-typecheck: OK` (27 files and 190 errors held in the shrink-only debt ledger, none new). - **`@objectstack/mcp`.** Test: 40 files, 479 passed, `VERDICT command-exit 0`. Typecheck: exit 0, `check:test-typecheck: OK`. - **Every touched test file is compiled** by its package's `tsconfig.test.json` program, counted with `--listFiles`: all 5. - **After the merge of `origin/main` (`140238a47`)**, at `b2666435d`: the six targeted runtime files, 181 passed; `mcp-coded-refusal.test.ts`, 43 passed; `pnpm --filter @objectstack/spec check:generated`, all 14 artifacts up to date. - **Gates.** `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 65 families from this change set. All 65 ran on `b2666435d`, each exit code captured before any pipe. - `--ran` reads: "65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN". - Verdict lines include: `check-dispatcher-error-vocabulary: OK`; `✓ every derivable runtime status is documented, and every documented status is reachable` (`check:error-status-conformance`); `check:route-envelope` ✓; `check-engine-double-contract: OK`; `check-nul-bytes: OK`; `✓ check:dual-build-cjs-loads — 110 published require entry point(s) across 68 package(s) load`. - `check:engine-double-contract` went red once, on this PR's first head. It pinned the new test's engine fake: its `update`/`delete` were looser than ObjectQL's. With the protocol present that rig never reaches them, so the fake now carries only `find` / `aggregate` (`44a7517f5`). - The first run of `check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET). It was re-run after the full build. - **CI's runs.** The repo-wide scans, with `pnpm lint` first, and the rest of the farm are CI's. ## Acceptance notes - **Ledger prose.** `ERROR_CODE_LEDGER['@objectstack/runtime']`'s comment (`packages/spec/src/api/error-code-ledger.zod.ts`) names `callData`'s nested-relation walk as the runtime's emitter of the two codes. It also says "`@objectstack/rest` keeps its own rows for the addressed object". After this PR the runtime stamps them for the addressed object too. The rows are right and the provenance gate is green; only the prose is narrower than the emitter set. It was left alone because `packages/spec` is outside this surface. - **Message wording.** The addressed refusal's message is `checkApiExposure`'s `reason`: "object is not exposed via the API" or "API operation 'OP' is not allowed for this object". It does not name the object, while `RestServer`'s does ("Object 'NAME' is not exposed via the API"). Kept as is: the card asks for the code and the status. - **Context fields on the dispatcher wire.** The thrown refusal carries `object` and, on a 405, `allowed`. The dispatcher's `errorFromThrown` relays neither, because its `details` takes only `issues`, `fields` and a non-string `code`. `RestServer`'s 405 body carries both. The relation-target refusal was already in the same position. Observed from source only, not measured at a door. - **Docblock left as is.** `checkApiExposure`'s docblock still says "The only caller is `callData`". PR #23617 already noted that as untrue, and it is outside this surface. --- _Generated by [Claude Code](https://claude.ai/code/session_01HjR8RUwTPQ3TgbfKihmxp9)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8776119 commit 9fcea20

8 files changed

Lines changed: 329 additions & 54 deletions
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/runtime': patch
3+
---
4+
5+
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)
6+
7+
Clause-②: no
8+
9+
**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:
10+
11+
- the dispatcher's `/data` routes derived a code from the status: `404 RESOURCE_NOT_FOUND` / `405 METHOD_NOT_ALLOWED`;
12+
- the `/mcp` data tools rendered the message alone;
13+
- a declared endpoint whose target is an object answered the derived code too.
14+
15+
`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.
16+
17+
**What a caller reads now.** Every one of these doors answers the data door's code with the same status as before:
18+
19+
| door | declaration | FROM | TO |
20+
|:--|:--|:--|:--|
21+
| dispatcher `/data` (and declared endpoints) | `apiEnabled: false` | `404`, `error.code` `RESOURCE_NOT_FOUND` | `404`, `error.code` `OBJECT_API_DISABLED` |
22+
| dispatcher `/data` (and declared endpoints) | verb outside `apiMethods` | `405`, `error.code` `METHOD_NOT_ALLOWED` | `405`, `error.code` `OBJECT_API_METHOD_NOT_ALLOWED` |
23+
| `/mcp` data tools | either | `isError: true`; the text is the message | `isError: true`; the text is `{ "error": { "code", "message", "status" } }` |
24+
25+
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`.

‎packages/mcp/src/mcp-coded-refusal.test.ts‎

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,12 @@
2424
* `{ error: { code, message, status } }` with `isError`. The table is held
2525
* equal to the registered tool list, so a tool added later needs a row.
2626
* - The same catches render a code-less throw as its bare message,
27-
* byte-identical to `errorResult(messageOf(err))`. That includes the value
28-
* the HTTP bridge's exposure gate throws today, `{ statusCode, message }`
29-
* with no `code`. Nothing is invented for it.
27+
* byte-identical to `errorResult(messageOf(err))`. Nothing is invented for it.
28+
* - [#23528] The HTTP bridge's exposure gate (`@objectstack/runtime`'s
29+
* `refuseUnexposedDataAction`, which `callData` asks first) used to be such
30+
* a throw, `{ statusCode, message }` with no `code`. It now throws the data
31+
* door's coded refusal, and renders as one. The producer side is pinned in
32+
* runtime's `action-execution-addressed-exposure-refusal.test.ts`.
3033
* - The data tools and `list_actions` WITHHOLD `details`. Measured on `/mcp`,
3134
* a `PERMISSION_DENIED` carries the caller's `positions` and
3235
* `permissionSets` there, which the runtime dispatcher drops from the wire.
@@ -195,11 +198,36 @@ describe('MCP tools over HTTP /mcp: a coded refusal carries its code (#23509)',
195198
// One site is enough below: every catch renders through the one reader.
196199
const query = SITES.find((s) => s.tool === 'query_records')!;
197200

198-
it('the HTTP bridge exposure gate\'s `{ statusCode, message }` (no `code`) answers its bare message: nothing is invented', async () => {
199-
const thrown = { statusCode: 404, message: "Object 'task' is not exposed via the API" };
200-
expect(await callTool(query, thrown)).toEqual({
201-
content: [{ type: 'text', text: "Object 'task' is not exposed via the API" }],
202-
isError: true,
201+
// [#23528] Moved to the coded rendering. This row pinned the gate's old
202+
// `{ statusCode: 404, message }` as its bare message. The gate now throws
203+
// an `Error` carrying the data door's `code` and `status`, the addressed
204+
// object as `object`, and `allowed` on a 405; the thrown values below are
205+
// that shape, with the gate's own messages.
206+
it('the HTTP bridge exposure gate\'s 404 answers OBJECT_API_DISABLED with its status', async () => {
207+
const thrown = Object.assign(new Error('object is not exposed via the API'), {
208+
code: 'OBJECT_API_DISABLED',
209+
status: 404,
210+
object: 'task',
211+
});
212+
const result = await callTool(query, thrown);
213+
expect(result.isError).toBe(true);
214+
expect(result.content).toHaveLength(1);
215+
expect(JSON.parse(result.content[0].text)).toEqual({
216+
error: { code: 'OBJECT_API_DISABLED', message: 'object is not exposed via the API', status: 404 },
217+
});
218+
});
219+
220+
it('the HTTP bridge exposure gate\'s 405 answers OBJECT_API_METHOD_NOT_ALLOWED with its status', async () => {
221+
const thrown = Object.assign(new Error("API operation 'list' is not allowed for this object"), {
222+
code: 'OBJECT_API_METHOD_NOT_ALLOWED',
223+
status: 405,
224+
object: 'task',
225+
allowed: ['get'],
226+
});
227+
const result = await callTool(query, thrown);
228+
expect(result.isError).toBe(true);
229+
expect(JSON.parse(result.content[0].text)).toEqual({
230+
error: { code: 'OBJECT_API_METHOD_NOT_ALLOWED', message: "API operation 'list' is not allowed for this object", status: 405 },
203231
});
204232
});
205233

‎packages/qa/http-conformance/src/hono-declared-envelope.conformance.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@
4242
* A throw that is NOT a declared envelope keeps the arm's answer: a bare
4343
* `Error` is still `500 INTERNAL_ERROR` carrying `code` and `message` only, with
4444
* no `cause` in the body; a throw that declares `statusCode` and no registered
45-
* code (the shape `callData`'s exposure gate throws) keeps the status it
46-
* declared. The declared 503 also carries no `cause`: the session read's own
45+
* code (the shape `callData` throws when no data service resolves) keeps the
46+
* status it declared. The declared 503 also carries no `cause`: the session read's own
4747
* failure stays off the wire.
4848
*
4949
* ## The auth-mount arm
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#23528] `callData`'s ADDRESSED-object exposure refusal
5+
* (`refuseUnexposedDataAction`) is the data door's: an `Error` carrying `code`
6+
* and `status`, the addressed object as `object`, and the effective operation
7+
* set as `allowed` on a 405. Its nested-relation target refusal already
8+
* answered that way (`action-execution-calldata-relation-target-exposure.test.ts`),
9+
* and so do `RestServer` and the MCP stdio face for the same declaration.
10+
*
11+
* ## What was open
12+
*
13+
* The gate threw `{ statusCode, message }` with no `code`. Each door then
14+
* answered it without the data door's code:
15+
*
16+
* - the runtime dispatcher's `/data` door derived `404 RESOURCE_NOT_FOUND` /
17+
* `405 METHOD_NOT_ALLOWED` from the status
18+
* (`domains/data-write-request-gate.test.ts` pins both doors on a real store);
19+
* - the `/mcp` tools render a thrown `code` since #23509, and rendered this
20+
* refusal as its message alone (`buildMcpBridge` calls `callData` on every
21+
* data verb, pinned below).
22+
*
23+
* The status and the message are unchanged. Every row asserts `code` and
24+
* `status` and that neither the protocol nor the engine was asked.
25+
*/
26+
27+
import { describe, expect, it, vi } from 'vitest';
28+
import { callData, type ActionExecutionDeps } from './action-execution.js';
29+
import { buildMcpBridge } from './domains/mcp.js';
30+
import type { HttpProtocolContext } from './http-dispatcher.js';
31+
32+
const MEMBER = { userId: 'ax_member', isSystem: false, positions: [], permissions: [] } as any;
33+
const REQ = { request: {}, executionContext: MEMBER } as HttpProtocolContext;
34+
35+
const object = (name: string, enable?: Record<string, unknown>) => ({
36+
name,
37+
label: name,
38+
...(enable ? { enable } : {}),
39+
fields: {
40+
id: { name: 'id', label: 'ID', type: 'text' },
41+
name: { name: 'name', label: 'Name', type: 'text' },
42+
},
43+
});
44+
45+
/** The fixture: `ax_hidden` is not exposed at all, `ax_getonly` exposes `get` alone. */
46+
const SCHEMAS: Record<string, unknown> = {
47+
ax_hidden: object('ax_hidden', { apiEnabled: false }),
48+
ax_getonly: object('ax_getonly', { apiMethods: ['get'] }),
49+
};
50+
51+
const HIDDEN = { code: 'OBJECT_API_DISABLED', status: 404, object: 'ax_hidden', allowed: undefined };
52+
const HIDDEN_MESSAGE = 'object is not exposed via the API';
53+
54+
/**
55+
* The protocol and engine `callData` would reach, each verb a spy, so a refusal
56+
* is shown to ask neither. With the protocol present it serves every verb but
57+
* `aggregate`, which takes the engine; so the engine carries only the reads.
58+
*/
59+
function makeHarness() {
60+
const protocol = {
61+
createData: vi.fn(async (req: any) => ({ object: req.object, id: 'n1', record: req.data })),
62+
getData: vi.fn(async (req: any) => ({ object: req.object, id: req.id, record: { id: req.id } })),
63+
updateData: vi.fn(async (req: any) => ({ object: req.object, id: req.id, record: req.data })),
64+
deleteData: vi.fn(async (req: any) => ({ object: req.object, id: req.id, success: true })),
65+
findData: vi.fn(async (req: any) => ({ object: req.object, records: [], total: 0, hasMore: false })),
66+
};
67+
const engine = {
68+
registry: { getObject: (n: string) => SCHEMAS[n] },
69+
find: vi.fn(async () => []),
70+
aggregate: vi.fn(async () => []),
71+
};
72+
const services: Record<string, unknown> = {
73+
metadata: { getObject: async (n: string) => SCHEMAS[n] },
74+
protocol,
75+
objectql: engine,
76+
};
77+
const deps: ActionExecutionDeps = {
78+
resolveService: (async (_ctx: HttpProtocolContext, name: string) => services[name]) as any,
79+
getObjectQL: async () => engine as any,
80+
};
81+
const asked = () => [...Object.values(protocol), engine.find, engine.aggregate]
82+
.reduce((n, spy) => n + spy.mock.calls.length, 0);
83+
return { deps, protocol, asked };
84+
}
85+
86+
/** What a door reads off the refusal: the envelope pair and the two context fields. */
87+
const refusal = (e: any) => ({ code: e?.code, status: e?.status, object: e?.object, allowed: e?.allowed });
88+
89+
/** Every action `callData` serves, with params naming `object`. */
90+
const VERBS: ReadonlyArray<readonly [string, (object: string) => Record<string, unknown>]> = [
91+
['create', (o) => ({ object: o, data: { name: 'x' } })],
92+
['get', (o) => ({ object: o, id: 'r1' })],
93+
['update', (o) => ({ object: o, id: 'r1', data: { name: 'x' } })],
94+
['delete', (o) => ({ object: o, id: 'r1' })],
95+
['query', (o) => ({ object: o, query: {} })],
96+
['find', (o) => ({ object: o, query: {} })],
97+
['aggregate', (o) => ({ object: o, aggregations: [{ function: 'count', alias: 'n' }] })],
98+
];
99+
100+
describe('[#23528] callData refuses an addressed object its declaration does not expose with the data door\'s code', () => {
101+
for (const [action, params] of VERBS) {
102+
it(`${action} on an \`apiEnabled: false\` object: 404 OBJECT_API_DISABLED, nothing asked`, async () => {
103+
const h = makeHarness();
104+
const err: any = await callData(h.deps, REQ, action, params('ax_hidden'), undefined, undefined, MEMBER).catch((e) => e);
105+
expect(err).toBeInstanceOf(Error);
106+
expect(refusal(err)).toEqual(HIDDEN);
107+
expect(err.message).toBe(HIDDEN_MESSAGE);
108+
expect(h.asked()).toBe(0);
109+
});
110+
}
111+
112+
it('create on an object exposing `get` alone: 405 OBJECT_API_METHOD_NOT_ALLOWED naming the effective set, nothing asked', async () => {
113+
const h = makeHarness();
114+
const err: any = await callData(h.deps, REQ, 'create', { object: 'ax_getonly', data: { name: 'x' } }, undefined, undefined, MEMBER)
115+
.catch((e) => e);
116+
expect(err).toBeInstanceOf(Error);
117+
expect(refusal(err)).toEqual({ code: 'OBJECT_API_METHOD_NOT_ALLOWED', status: 405, object: 'ax_getonly', allowed: ['get'] });
118+
expect(err.message).toBe("API operation 'create' is not allowed for this object");
119+
expect(h.asked()).toBe(0);
120+
});
121+
122+
it('CONTROL: the verb the whitelist grants is served', async () => {
123+
const h = makeHarness();
124+
await callData(h.deps, REQ, 'get', { object: 'ax_getonly', id: 'r1' }, undefined, undefined, MEMBER);
125+
expect(h.protocol.getData).toHaveBeenCalledTimes(1);
126+
});
127+
128+
it('CONTROL: a system context is not asked, so the hidden object is served', async () => {
129+
const h = makeHarness();
130+
await callData(h.deps, REQ, 'query', { object: 'ax_hidden', query: {} }, undefined, undefined, { isSystem: true } as any);
131+
expect(h.protocol.findData).toHaveBeenCalledTimes(1);
132+
});
133+
});
134+
135+
/**
136+
* The `/mcp` face: `buildMcpBridge` is the bridge `handleMcp` hands the MCP
137+
* tools, and each data member reaches `callData`. The tools render a thrown
138+
* string `code` and numeric `status` as `{ error: { code, message, status } }`
139+
* (`@objectstack/mcp`'s `thrownRefusalEnvelope`, pinned in its
140+
* `mcp-coded-refusal.test.ts`), so the bridge must hand them the coded refusal.
141+
*/
142+
describe('[#23528] the /mcp bridge hands its tools the coded refusal on every data member', () => {
143+
const MEMBERS: ReadonlyArray<readonly [string, (bridge: any) => Promise<unknown>]> = [
144+
['query', (b) => b.query('ax_hidden', {})],
145+
['get', (b) => b.get('ax_hidden', 'r1')],
146+
['aggregate', (b) => b.aggregate('ax_hidden', { aggregations: [{ function: 'count', alias: 'n' }] })],
147+
['create', (b) => b.create('ax_hidden', { name: 'x' })],
148+
['update', (b) => b.update('ax_hidden', 'r1', { name: 'x' })],
149+
['remove', (b) => b.remove('ax_hidden', 'r1')],
150+
];
151+
152+
for (const [member, call] of MEMBERS) {
153+
it(`${member}: 404 OBJECT_API_DISABLED, nothing asked`, async () => {
154+
const h = makeHarness();
155+
const bridge = buildMcpBridge(h.deps as any, REQ);
156+
const err: any = await call(bridge).catch((e) => e);
157+
expect(err).toBeInstanceOf(Error);
158+
expect(refusal(err)).toEqual(HIDDEN);
159+
expect(err.message).toBe(HIDDEN_MESSAGE);
160+
expect(h.asked()).toBe(0);
161+
});
162+
}
163+
});

‎packages/runtime/src/action-execution.ts‎

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -239,31 +239,42 @@ async function refuseUnservedRelationTargets(
239239
}
240240
}
241241

242+
/**
243+
* [#23528] The data door's two exposure codes, by the gate's verdict: `404
244+
* OBJECT_API_DISABLED` for an object whose `apiEnabled` is `false`, and `405
245+
* OBJECT_API_METHOD_NOT_ALLOWED` with the effective operation set as `allowed`
246+
* for a verb its `apiMethods` leaves out. That is the pairing `RestServer`
247+
* answers for the same declaration (`apiAccessDenialFromEnable`).
248+
*
249+
* The ONE table both exposure refusals in this file stamp: the addressed
250+
* object's ({@link refuseUnexposedDataAction}) and a nested-relation
251+
* condition's target's ({@link unservedRelationTargetError}). One table, so one
252+
* `callData` never answers the same verdict with two codes. A denial from
253+
* {@link checkApiExposure} carries exactly one of these two statuses.
254+
*/
255+
function exposureRefusalFields(gate: ApiExposureDecision):
256+
| { code: 'OBJECT_API_DISABLED'; status: 404 }
257+
| { code: 'OBJECT_API_METHOD_NOT_ALLOWED'; status: 405; allowed: string[] } {
258+
return gate.status === 404
259+
? { code: 'OBJECT_API_DISABLED', status: 404 }
260+
: { code: 'OBJECT_API_METHOD_NOT_ALLOWED', status: 405, allowed: gate.allowedOperations ?? [] };
261+
}
262+
242263
/**
243264
* [#22823] The data door's refusal of an unserved nested-relation condition
244265
* target, field for field (`refuseUnservedRelationTarget`): `code` and `status`
245-
* by the gate's verdict, the TARGET as `object` (the route's object is not the
246-
* one switched off), the condition's position as `param`, and the effective
247-
* operation set as `allowed` on a 405.
266+
* by the gate's verdict ({@link exposureRefusalFields}), the TARGET as `object`
267+
* (the route's object is not the one switched off), the condition's position
268+
* as `param`, and the effective operation set as `allowed` on a 405.
248269
*/
249270
function unservedRelationTargetError(target: string, param: string, gate: ApiExposureDecision): Error {
250271
const consequence = `, so the condition on it at '${param}' was not evaluated and the query was not run. `
251272
+ 'Filter on the lookup\'s stored id instead, or remove the condition.';
252-
const disabled = gate.status === 404;
253-
const err: any = new Error(disabled
273+
const refusal = exposureRefusalFields(gate);
274+
const message = refusal.status === 404
254275
? `Object '${target}' is not exposed via the API${consequence}`
255-
: `API operation '${RELATION_CONDITION_TARGET_OPERATION}' is not allowed on object '${target}'${consequence}`);
256-
if (disabled) {
257-
err.code = 'OBJECT_API_DISABLED';
258-
err.status = 404;
259-
} else {
260-
err.code = 'OBJECT_API_METHOD_NOT_ALLOWED';
261-
err.status = 405;
262-
err.allowed = gate.allowedOperations ?? [];
263-
}
264-
err.object = target;
265-
err.param = param;
266-
return err;
276+
: `API operation '${RELATION_CONDITION_TARGET_OPERATION}' is not allowed on object '${target}'${consequence}`;
277+
return Object.assign(new Error(message), refusal, { object: target, param });
267278
}
268279

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

0 commit comments

Comments
 (0)