diff --git a/.changeset/23528-exposure-refusal-code.md b/.changeset/23528-exposure-refusal-code.md new file mode 100644 index 00000000000..5719714dea0 --- /dev/null +++ b/.changeset/23528-exposure-refusal-code.md @@ -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`. diff --git a/packages/mcp/src/mcp-coded-refusal.test.ts b/packages/mcp/src/mcp-coded-refusal.test.ts index bb3a0927f20..b44827928bb 100644 --- a/packages/mcp/src/mcp-coded-refusal.test.ts +++ b/packages/mcp/src/mcp-coded-refusal.test.ts @@ -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. @@ -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 }, }); }); diff --git a/packages/qa/http-conformance/src/hono-declared-envelope.conformance.test.ts b/packages/qa/http-conformance/src/hono-declared-envelope.conformance.test.ts index 971d2181fca..4f024dbeaad 100644 --- a/packages/qa/http-conformance/src/hono-declared-envelope.conformance.test.ts +++ b/packages/qa/http-conformance/src/hono-declared-envelope.conformance.test.ts @@ -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 diff --git a/packages/runtime/src/action-execution-addressed-exposure-refusal.test.ts b/packages/runtime/src/action-execution-addressed-exposure-refusal.test.ts new file mode 100644 index 00000000000..ded923f8047 --- /dev/null +++ b/packages/runtime/src/action-execution-addressed-exposure-refusal.test.ts @@ -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) => ({ + 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 = { + 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 = { + 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 Record]> = [ + ['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 Promise]> = [ + ['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); + }); + } +}); diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index e1777f48edd..270d6e43962 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -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. */ @@ -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, @@ -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 }); } } diff --git a/packages/runtime/src/domains/data-path-object.test.ts b/packages/runtime/src/domains/data-path-object.test.ts index ab87d39018c..ad2b91b97bb 100644 --- a/packages/runtime/src/domains/data-path-object.test.ts +++ b/packages/runtime/src/domains/data-path-object.test.ts @@ -207,9 +207,14 @@ describe('POST /data/:object/query binds to the object in the path (#3946)', () expect(findData.mock.calls[0][0].object).toBe('crm_account'); // Addressed directly, the hidden object is still refused. The gate - // THROWS a `{ statusCode }` shape; the dispatcher above turns it into - // an envelope (`errorFromThrown`), so assert the throw here. - await expect(post(deps, context, 'sys_user/query', {})).rejects.toMatchObject({ statusCode: 404 }); + // THROWS the data door's coded refusal (#23528); the dispatcher above + // turns it into an envelope (`errorFromThrown`), so assert the throw + // here, on the path object. + await expect(post(deps, context, 'sys_user/query', {})).rejects.toMatchObject({ + code: 'OBJECT_API_DISABLED', + status: 404, + object: 'sys_user', + }); expect(findData).toHaveBeenCalledTimes(1); // the refused call never read }); @@ -285,19 +290,23 @@ describe('[#6719] /data error exits answer in the ADR-0112 envelope', () => { expect(error.message).toMatch(/^Project not resolved\./); }); - it("the exposure gate's throw reaches the wire as a conformant 404", async () => { + // [#23528] Flipped. The gate threw `{ statusCode, message }` with no code, + // so this exit derived `RESOURCE_NOT_FOUND` from the status, where + // `RestServer` answers the same declaration `OBJECT_API_DISABLED`. The + // gate now throws the data door's code, and this exit carries it. + it("the exposure gate's throw reaches the wire as a conformant 404 OBJECT_API_DISABLED", async () => { const { deps, context } = setup({ sys_user: { apiEnabled: false } }); const thrown = await post(deps, context, 'sys_user/query', {}).then( () => undefined, (e: any) => e, ); - expect(thrown).toMatchObject({ statusCode: 404 }); + expect(thrown).toMatchObject({ code: 'OBJECT_API_DISABLED', status: 404 }); const response = deps.errorFromThrown(thrown); expect(response.status).toBe(404); const error = expectDataErrorEnvelope(response); - expect(error.code).toBe('RESOURCE_NOT_FOUND'); + expect(error.code).toBe('OBJECT_API_DISABLED'); }); it('a record miss keeps its OWN code — promoted into `error.code`, not derived from the status', async () => { diff --git a/packages/runtime/src/domains/data-write-request-gate.test.ts b/packages/runtime/src/domains/data-write-request-gate.test.ts index 4bf3c26a3e8..25e7b533f73 100644 --- a/packages/runtime/src/domains/data-write-request-gate.test.ts +++ b/packages/runtime/src/domains/data-write-request-gate.test.ts @@ -96,10 +96,13 @@ const TASK = { name: 'task', label: 'Task', fields: FIELDS }; * [#23574] Two objects whose declared API exposure refuses every verb below: * `locked` exposes `get` alone (`405` for `create`, `update` and `list`), and * `hidden` is not exposed at all (`404`). + * + * [#23528] With the data door's code for each: `RestServer` always answered + * these, and the dispatcher now does too. */ -const EXPOSURE_DENIED: ReadonlyArray<{ object: { name: string; label: string; fields: typeof FIELDS; enable: Record }; status: number }> = [ - { object: { name: 'locked', label: 'Locked', fields: FIELDS, enable: { apiMethods: ['get'] } }, status: 405 }, - { object: { name: 'hidden', label: 'Hidden', fields: FIELDS, enable: { apiEnabled: false } }, status: 404 }, +const EXPOSURE_DENIED: ReadonlyArray<{ object: { name: string; label: string; fields: typeof FIELDS; enable: Record }; status: number; code: string }> = [ + { object: { name: 'locked', label: 'Locked', fields: FIELDS, enable: { apiMethods: ['get'] } }, status: 405, code: 'OBJECT_API_METHOD_NOT_ALLOWED' }, + { object: { name: 'hidden', label: 'Hidden', fields: FIELDS, enable: { apiEnabled: false } }, status: 404, code: 'OBJECT_API_DISABLED' }, ]; const OBJECTS = [TASK, ...EXPOSURE_DENIED.map((d) => d.object)]; @@ -456,8 +459,12 @@ describe('[#23574] the two query doors answer each request alike', () => { * [#23574] Location 3: on an object whose API exposure denies the verb, every * body is answered the exposure refusal, a record or not: the request gate is * never what tells a caller the object refuses how its ingress reads a body. - * Statuses only across doors: the two doors' exposure envelopes carry - * different codes, which this card does not change. + * + * [#23528] Flipped from statuses only: the dispatcher's exposure refusal had no + * code of its own (`404 RESOURCE_NOT_FOUND` / `405 METHOD_NOT_ALLOWED`, derived + * from the status), where `RestServer` answered `OBJECT_API_DISABLED` / + * `OBJECT_API_METHOD_NOT_ALLOWED`. Both doors now answer the same code and + * status, asserted on each door and across them. */ const EXPOSURE_BODIES: ReadonlyArray = [ ...NOT_A_RECORD, @@ -478,14 +485,14 @@ const EXPOSURE_CASES: ReadonlyArray { - for (const { object, status } of EXPOSURE_DENIED) { + for (const { object, status, code } of EXPOSURE_DENIED) { for (const [verb, bodies, engineCalls] of EXPOSURE_CASES) { - it(`${verb} on \`${object.name}\`: ${status} for every body, the engine never asked, the row unchanged`, async () => { + it(`${verb} on \`${object.name}\`: ${status} ${code} for every body, the engine never asked, the row unchanged`, async () => { const door = await boot(); const before = await door.state(object.name); for (const [shape, body] of bodies) { const answer = await door[verb](body, object.name); - expect(answer.status, `${shape}: ${JSON.stringify(answer)}`).toBe(status); + expect({ status: answer.status, code: answer.code }, `${shape}: ${JSON.stringify(answer)}`).toEqual({ status, code }); } expect(engineCalls(door)).toBe(0); expect(await door.state(object.name)).toEqual(before); @@ -495,19 +502,22 @@ for (const [doorName, boot] of DOORS) { }); } -describe('[#23574] the two doors answer an exposure-denied object with the same status, whatever the body', () => { +describe('[#23574] [#23528] the two doors answer an exposure-denied object with the same status and code, whatever the body', () => { for (const { object } of EXPOSURE_DENIED) { for (const [verb, bodies] of EXPOSURE_CASES) { it(`${verb} on \`${object.name}\``, async () => { - const statuses: number[][] = []; + const answers: Array> = []; for (const [, boot] of DOORS) { const door = await boot(); - const seen: number[] = []; - for (const [, body] of bodies) seen.push((await door[verb](body, object.name)).status); - statuses.push(seen); + const seen: Array<{ status: number; code?: string }> = []; + for (const [, body] of bodies) { + const { status, code } = await door[verb](body, object.name); + seen.push({ status, code }); + } + answers.push(seen); } - const [rest, catchAllStatuses] = statuses; - expect(catchAllStatuses).toEqual(rest); + const [rest, catchAll] = answers; + expect(catchAll).toEqual(rest); }, 60_000); } } diff --git a/packages/runtime/src/endpoint-executor.test.ts b/packages/runtime/src/endpoint-executor.test.ts index 2d0a46fdc15..55be8dfc1a4 100644 --- a/packages/runtime/src/endpoint-executor.test.ts +++ b/packages/runtime/src/endpoint-executor.test.ts @@ -32,6 +32,7 @@ import { type EndpointExecutionRequest, type EndpointExecutorDeps, } from './endpoint-executor.js'; +import { refuseUnexposedDataAction, type ActionExecutionDeps } from './action-execution.js'; // --------------------------------------------------------------------------- // Fixtures @@ -573,12 +574,30 @@ describe('a delegated failure maps onto the EXISTING envelope, class by class', expect(expectConformantError(answer).message).toBe('Record not found'); }); - it('honours `statusCode` too — the shape callData’s exposure gate throws', async () => { - // `action-execution.callData` throws `{ statusCode, message }` for an - // object whose `apiEnabled`/`apiMethods` deny the operation (ADR-0049). - const answer = await failWith({ statusCode: 403, message: 'API access denied' }); + it('honours `statusCode` too — the spelling `metadata-protocol` and the approval hooks throw', async () => { + // [#23528] This row used to name `callData`'s exposure gate as the + // producer of `{ statusCode, message }`. That gate now throws the data + // door's coded refusal (the row below); the spelling is still thrown + // elsewhere, so the resolver still reads it. + const answer = await failWith({ statusCode: 403, message: 'Approval step is locked' }); expect(answer.status).toBe(403); - expect(expectConformantError(answer).message).toBe('API access denied'); + expect(expectConformantError(answer).message).toBe('Approval step is locked'); + }); + + it('[#23528] callData’s exposure refusal keeps its code: 404 OBJECT_API_DISABLED for an `apiEnabled: false` target', async () => { + // The REAL gate's throw, for the endpoint fixture's own target object. + const hidden = { name: 'showcase_task', enable: { apiEnabled: false } }; + const gateDeps: ActionExecutionDeps = { + resolveService: (async (_ctx: unknown, name: string) => (name === 'metadata' ? { getObject: async () => hidden } : undefined)) as any, + getObjectQL: async () => null, + }; + const thrown = await refuseUnexposedDataAction(gateDeps, { request: {} } as any, 'find', 'showcase_task', undefined, EC) + .then(() => undefined, (e: unknown) => e); + expect(thrown).toBeInstanceOf(Error); + + const answer = await failWith(thrown); + expect(answer.status).toBe(404); + expect(expectConformantError(answer).code).toBe('OBJECT_API_DISABLED'); }); it('maps a record ValidationError to 400 with fields[] (#3918)', async () => {