diff --git a/.changeset/22737-relation-condition-target-exposure.md b/.changeset/22737-relation-condition-target-exposure.md new file mode 100644 index 00000000000..09e13f9106e --- /dev/null +++ b/.changeset/22737-relation-condition-target-exposure.md @@ -0,0 +1,31 @@ +--- +'@objectstack/metadata-protocol': minor +--- + +fix(metadata-protocol)!: a nested-relation filter condition asks the related object its declared exposure, and is refused when that object does not serve `list` (#22737) + +Clause-②: no (narrowing) + + + +**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes. + +A nested-relation filter condition, `{ : { : … } }`, is evaluated on the RELATED object: the engine queries that object for the matching records and keeps the source rows that point at one. + +**FROM.** On `GET /api/v1/data/:object?filter=…` and `POST /api/v1/data/:object/query`, for an administrator and a member alike, such a condition was evaluated whatever the related object's `enable` block declared. A source row came back only when the related value matched, even for a related object every data route refuses. + +**TO.** The generic data door asks the spec's one exposure decision (`apiExposureDenialReason`) of the related object, for `list`, the operation `GET /api/v1/data/:target?filter=…` is judged as. A condition is a query of the related object, not a read of a record whose id the caller holds, so a `get`-only whitelist does not serve it. A related object the decision does not serve is refused, with the data door's own exposure answer: + +- `enable.apiEnabled: false` → `404 OBJECT_API_DISABLED`. +- an `enable.apiMethods` whitelist without `list` (for example `['get']`, `['create']` or the deny-all `[]`) → `405 OBJECT_API_METHOD_NOT_ALLOWED`, with the effective operation set in `allowed`. + +The refusal names the related object and the condition's position. It applies wherever the door walks a filter: `where`, each aggregation's `filter`, and an `expand` entry's own `where` at every level. + +**Migration.** Filter on the lookup's stored id (`{ : '' }` or `{ : { $in: [...] } }`), which is the source object's own column, or remove the condition. To keep the condition, declare `list` on the related object's `enable.apiMethods`. + +**Bindings.** + +- **Every caller.** The decision takes no user, so an administrator, a member and a system context get the same answer. +- **Refused, not withheld.** Dropping a condition would widen the result. A related object the caller may not read already answers this position with a refusal (`403`). +- **The engine's privileged callers are untouched.** A flow's or a hook's own query calls the engine directly and never passes the generic data door. +- **No new error code.** `@objectstack/metadata-protocol` now stamps the two existing exposure codes, and the error-code ledger lists them under that package too. diff --git a/.changeset/22737-spec-ledger-metadata-protocol-exposure-codes.md b/.changeset/22737-spec-ledger-metadata-protocol-exposure-codes.md new file mode 100644 index 00000000000..7019958a56a --- /dev/null +++ b/.changeset/22737-spec-ledger-metadata-protocol-exposure-codes.md @@ -0,0 +1,7 @@ +--- +'@objectstack/spec': patch +--- + +fix(spec): the error-code ledger records `@objectstack/metadata-protocol` as an emitter of `OBJECT_API_DISABLED` and `OBJECT_API_METHOD_NOT_ALLOWED` (#22737) + +The generic data door now answers the two exposure codes for a nested-relation filter condition whose related object's `enable` block refuses a query of it, so `ERROR_CODE_LEDGER['@objectstack/metadata-protocol']` lists both. That is the provenance `check:error-code-provenance` holds every emitting package to. Value-only: `REGISTERED_ERROR_CODES` is unchanged, because both codes were already registered under `@objectstack/rest`. No export, schema or generated artifact moves. diff --git a/.changeset/22737-types-exposure-arm-names-target.md b/.changeset/22737-types-exposure-arm-names-target.md new file mode 100644 index 00000000000..a85694a5dc5 --- /dev/null +++ b/.changeset/22737-types-exposure-arm-names-target.md @@ -0,0 +1,11 @@ +--- +'@objectstack/types': patch +--- + +fix(types): a thrown exposure refusal about a second object names that object on the REST body (#22737) + +The shared REST error classification gains one arm for `OBJECT_API_DISABLED` and `OBJECT_API_METHOD_NOT_ALLOWED`. Both REST error doors ask it: `mapDataError`, and `resolveErrorResponse` behind `sendThrownError`. + +When the thrown error names an object other than the one the route addressed, the body's `object` is that object, and a `405` carries the effective operation set as `allowed`. The generic data door throws this when a nested-relation condition's related object is not served by the API. The new body is the same one the REST door already sends for these codes about the addressed object. Before, the body named the route's object beside a code saying that object was switched off. + +An error naming the route's own object, or a door given no route object, answers exactly as before. No code is added. diff --git a/packages/core/src/security/second-object-read-exposure.pin.test.ts b/packages/core/src/security/second-object-read-exposure.pin.test.ts index 2f9f80e64dc..cc7223a107c 100644 --- a/packages/core/src/security/second-object-read-exposure.pin.test.ts +++ b/packages/core/src/security/second-object-read-exposure.pin.test.ts @@ -11,9 +11,10 @@ * The data routes judge the ADDRESSED object. A read that follows a lookup * reaches an object nobody addressed: the data door's `$expand` served the row * fields of a target every data route refuses, the dataset door's label - * passes rendered its display names, and the import door matched cells against - * it (#22739). Each read classified `decided` below now asks the target - * (behaviour-pinned in its own package). This + * passes rendered its display names, the import door matched cells against + * it (#22739), and the data door's nested-relation filter condition evaluated + * a predicate on it (#22737). Each read classified `decided` below now asks the + * target (behaviour-pinned in its own package). This * file is the ENUMERATION: it names the census of such reads, holds each * decided one to the decision, and goes red when a new one appears without * being classified here. @@ -137,12 +138,12 @@ const CALLERS: Record = { pin: 'packages/core/src/utils/import-runner-reference-exposure.test.ts', }, 'packages/objectql/src/relation-filter-lowering.ts': { - kind: 'open', - read: "the data door's nested-relation filter condition (`ObjectQL.lowerRelationConditions` reads the related object to lower it)", - measured: 'evaluated for an unexposed target, for an administrator and a member: the source row comes back only when the related value matches', - carrier: - 'a filter cannot be withheld, so its answer is a refusal in the data door\'s exposure codes, which needs a ' + - 'provenance row for the door and touches a filter position; carried by a follow-up of #22661', + kind: 'decided', + read: "the data door's nested-relation filter condition (`ObjectQL.lowerRelationConditions` reads the related object to lower it), refused rather than withheld", + operation: 'list', + decision: { file: 'packages/metadata-protocol/src/protocol.ts', fn: 'refuseUnservedRelationTarget' }, + wiring: { file: 'packages/metadata-protocol/src/protocol.ts', call: 'this.refuseUnservedRelationTarget(' }, + pin: 'packages/metadata-protocol/src/protocol.relation-condition-target-exposure.test.ts', }, 'packages/plugins/plugin-approvals/src/approval-service.ts': { kind: 'decided', @@ -289,6 +290,7 @@ describe('[#22661] every read that reaches a second object asks that object its (c): c is Extract => c.kind === 'decided', ); expect(decided.map((d) => d.decision.fn).sort()).toEqual([ + 'refuseUnservedRelationTarget', 'servesExpansionTarget', 'servesLabelTarget', 'servesPayloadDisplayTarget', diff --git a/packages/metadata-protocol/src/protocol.relation-condition-target-exposure.test.ts b/packages/metadata-protocol/src/protocol.relation-condition-target-exposure.test.ts new file mode 100644 index 00000000000..396d47ca2ce --- /dev/null +++ b/packages/metadata-protocol/src/protocol.relation-condition-target-exposure.test.ts @@ -0,0 +1,126 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22737] A NESTED-RELATION filter condition is evaluated on the related + * object, so the data door asks that TARGET the spec's one exposure decision + * (`apiExposureDenialReason(enable, 'list')`, ADR-0049) and refuses a target + * it does not serve, in the door's own exposure answer: `404 + * OBJECT_API_DISABLED`, or `405 OBJECT_API_METHOD_NOT_ALLOWED` with the + * effective operation set. + * + * Asserted on the envelope (`code` and `status`) and on the engine: a refused + * condition never reaches `engine.find` / `engine.aggregate`, so nothing of the + * target is read. Each refused shape is paired with a CONTROL the decision + * serves, which must reach the engine unchanged. The administrator and member + * answers on a real stack are pinned in `@objectstack/dogfood`. + */ + +import { describe, expect, it, vi } from 'vitest'; +import type { ServiceObject } from '@objectstack/spec/data'; +import { ObjectStackProtocolImplementation } from './protocol.js'; + +const lookup = (name: string, reference: string) => ({ name, label: name, type: 'lookup', reference }); + +const object = (name: string, enable?: Record, extra: Record = {}): ServiceObject => ({ + name, + label: name, + ...(enable ? { enable } : {}), + fields: { + id: { name: 'id', label: 'ID', type: 'text' }, + name: { name: 'name', label: 'Name', type: 'text' }, + ...extra, + }, +} as unknown as ServiceObject); + +const SCHEMAS: Record = { + rc_source: object('rc_source', undefined, { + hidden: lookup('hidden', 'rc_hidden'), + closed: lookup('closed', 'rc_closed'), + denyall: lookup('denyall', 'rc_denyall'), + getonly: lookup('getonly', 'rc_getonly'), + listonly: lookup('listonly', 'rc_listonly'), + open: lookup('open', 'rc_open'), + }), + // `apiEnabled: false` is judged FIRST: a whitelist granting `list` beside it changes nothing. + rc_hidden: object('rc_hidden', { apiEnabled: false, apiMethods: ['list'] }), + rc_closed: object('rc_closed', { apiMethods: ['create'] }), + rc_denyall: object('rc_denyall', { apiMethods: [] }), + rc_getonly: object('rc_getonly', { apiMethods: ['get'] }), + rc_listonly: object('rc_listonly', { apiMethods: ['list'] }), + // Exposed, with a lookup into the off-switched object: an `expand` entry's own filter reaches it. + rc_open: object('rc_open', undefined, { inner: lookup('inner', 'rc_hidden') }), +}; + +function makeProtocol() { + const find = vi.fn(async () => [{ id: 's1', name: 'source' }]); + const aggregate = vi.fn(async () => [{ name: 'source', n: 1 }]); + const count = vi.fn(async () => 1); + const engine: any = { registry: { getObject: (n: string) => SCHEMAS[n] }, find, aggregate, count }; + return { p: new ObjectStackProtocolImplementation(engine), find, aggregate }; +} + +/** Each refused target, the door's answer, and the effective set a 405 names. */ +const REFUSED = { + hidden: { status: 404, code: 'OBJECT_API_DISABLED', allowed: undefined }, + closed: { status: 405, code: 'OBJECT_API_METHOD_NOT_ALLOWED', allowed: ['create', 'import'] }, + denyall: { status: 405, code: 'OBJECT_API_METHOD_NOT_ALLOWED', allowed: [] }, + getonly: { status: 405, code: 'OBJECT_API_METHOD_NOT_ALLOWED', allowed: ['get'] }, +} as const; +const SERVED = ['listonly', 'open'] as const; + +/** A member and the most privileged caller: the decision takes no caller. */ +const CONTEXTS = [{ userId: 'rc_member' }, { isSystem: true }]; + +describe('[#22737] the data door refuses a nested-relation condition whose target the API does not serve', () => { + for (const [rel, answer] of Object.entries(REFUSED)) { + for (const context of CONTEXTS) { + it(`${rel}: refused ${answer.status} ${answer.code} on the POST where and the list filter (${JSON.stringify(context)})`, async () => { + const { p, find } = makeProtocol(); + for (const query of [ + { where: { [rel]: { name: 'x' } } }, + { $filter: JSON.stringify({ name: 'source', $or: [{ [rel]: { name: 'x' } }] }) }, + ]) { + const err: any = await p.findData({ object: 'rc_source', query, context }).catch((e) => e); + expect(err).toBeInstanceOf(Error); + expect({ code: err.code, status: err.status }).toEqual({ code: answer.code, status: answer.status }); + expect(err.object).toBe(`rc_${rel}`); + expect(err.allowed).toEqual(answer.allowed); + } + expect(find).not.toHaveBeenCalled(); + }); + } + } + + for (const rel of SERVED) { + it(`CONTROL ${rel}: served, the condition reaches the engine unchanged`, async () => { + const { p, find } = makeProtocol(); + await p.findData({ object: 'rc_source', query: { where: { [rel]: { name: 'x' } } } }); + expect(find).toHaveBeenCalledTimes(1); + expect((find.mock.calls[0] as any[])[1].where).toEqual({ [rel]: { name: 'x' } }); + }); + } + + it('the grouped read is refused before the engine aggregates', async () => { + const { p, aggregate } = makeProtocol(); + const err: any = await p + .findData({ object: 'rc_source', query: { where: { hidden: { name: 'x' } }, groupBy: ['name'], aggregations: [{ function: 'count', alias: 'n' }] } }) + .catch((e) => e); + expect({ code: err.code, status: err.status }).toEqual({ code: 'OBJECT_API_DISABLED', status: 404 }); + expect(aggregate).not.toHaveBeenCalled(); + }); + + it('an expand entry\'s own filter is judged against its level: a condition into an unexposed object is refused', async () => { + const { p, find } = makeProtocol(); + const err: any = await p + .findData({ object: 'rc_source', query: { expand: { open: { object: 'open', where: { inner: { name: 'x' } } } } } }) + .catch((e) => e); + expect({ code: err.code, status: err.status, object: err.object }).toEqual({ code: 'OBJECT_API_DISABLED', status: 404, object: 'rc_hidden' }); + expect(find).not.toHaveBeenCalled(); + }); + + it('CONTROL: a plain id comparand on the same lookup is the source\'s own column and is served', async () => { + const { p, find } = makeProtocol(); + await p.findData({ object: 'rc_source', query: { where: { hidden: 'h1', getonly: { $in: ['g1'] } } } }); + expect(find).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/metadata-protocol/src/protocol.ts b/packages/metadata-protocol/src/protocol.ts index 258aa28866a..b9d275e8fee 100644 --- a/packages/metadata-protocol/src/protocol.ts +++ b/packages/metadata-protocol/src/protocol.ts @@ -134,7 +134,7 @@ import { SEARCHABLE_TEXTUAL_TYPES, SEARCHABLE_ENUM_TYPES, SEARCH_AUTO_EXCLUDED_FIELDS, isVirtualSearchField, type SearchFieldMeta, - canServeApiOperation, + canServeApiOperation, apiExposureDenialReason, effectiveOperationsArray, resolveEffectiveApiMethods, classifyDottedFilterHead, foldQueryAliasSlots, QUERY_TRANSPORT_ALIAS_SLOTS, QUERY_TRANSPORT_DOLLAR_ALIASES, QUERY_TRANSPORT_DOLLAR_PARAMS, @@ -4463,6 +4463,17 @@ const INTERNAL_FIELD_WALK_DEPTH = 8; */ const EXPANSION_TARGET_OPERATION = 'get'; +/** + * [#22737] The operation a NESTED-RELATION condition's TARGET object is judged + * as, by the same decision: `list`. Not {@link EXPANSION_TARGET_OPERATION}'s + * `get`, because the read is not the same one: a condition is a PREDICATE over + * the target. The engine runs `find(target, { where: condition })` across the + * whole object, bounded by a match cap rather than by ids the source rows hold + * (`ObjectQL.lowerRelationConditions`), which is the read + * `GET /data/{target}?filter=…` performs, and that route is judged `list`. + */ +const RELATION_CONDITION_TARGET_OPERATION = 'list'; + /** * [#22646] The NESTED-RELATION conditions a filter holds — a key naming a * reference field of the object (`REFERENCE_VALUE_TYPES`) whose value is a @@ -12523,7 +12534,9 @@ export class ObjectStackProtocolImplementation implements * record matched, so a related `internal` field there is the same * confirmation oracle one hop away. Judged here, at the door, rather than * in the engine's relation-site walk, so the engine's privileged callers - * keep the #7823 split (none of them reaches this door). + * keep the #7823 split (none of them reaches this door). The RELATED + * object's exposure is asked first, at the same hop + * ({@link refuseUnservedRelationTarget}, #22737). * - every `expand` entry's own `where` / `orderBy`, at EVERY depth of the * expand tree, judged against that level's TARGET object: the nested * sub-read's row strip already withholds the column, but a nested filter @@ -12566,7 +12579,8 @@ export class ObjectStackProtocolImplementation implements /** * One filter's internal-field judgement: every column it reads on `object` * ({@link collectFilterReads}), then each nested-relation condition it holds, - * recursively, on the related object. `param` names the position, extended + * recursively, on the related object, once that object's exposure serves the + * condition ({@link refuseUnservedRelationTarget}). `param` names the position, extended * by the relation field at each hop (`filter.owner_id`). */ private refuseInternalInFilter(object: string, where: unknown, param: string, depth: number): void { @@ -12581,8 +12595,47 @@ export class ObjectStackProtocolImplementation implements this.refuseInternalNames(object, [...reads], internal, param); } for (const site of relationConditionSites(gate.fields, condition)) { - this.refuseInternalInFilter(site.target, site.condition, `${param}.${site.field}`, depth + 1); + const position = `${param}.${site.field}`; + // [#22737] The target's exposure before anything of it is judged. + this.refuseUnservedRelationTarget(site.target, position); + this.refuseInternalInFilter(site.target, site.condition, position, depth + 1); + } + } + + /** + * [#22737] A NESTED-RELATION condition is evaluated on its TARGET object + * (`ObjectQL.lowerRelationConditions` reads it as the caller), so the + * target is asked the spec's one exposure decision for + * {@link RELATION_CONDITION_TARGET_OPERATION}. A target it does not serve + * is REFUSED in the data door's own exposure answer: `404 + * OBJECT_API_DISABLED` for the off switch, `405 + * OBJECT_API_METHOD_NOT_ALLOWED` with the effective operation set for a + * whitelist. Refused, not withheld as {@link servedExpand} withholds: + * dropping a condition would widen the result, and a related object the + * caller may not read already answers this position with a refusal. The + * decision takes no caller. The target is named, as the `internal: true` + * refusal at this hop names it: it is the lookup's declared target. + */ + private refuseUnservedRelationTarget(target: string, param: string): void { + const enable = this.engine?.registry?.getObject?.(target)?.enable; + const reason = apiExposureDenialReason(enable, RELATION_CONDITION_TARGET_OPERATION); + if (reason === null) return; + 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 err: any = new Error(reason === 'api-disabled' + ? `Object '${target}' is not exposed via the API${consequence}` + : `API operation '${RELATION_CONDITION_TARGET_OPERATION}' is not allowed on object '${target}'${consequence}`); + if (reason === 'api-disabled') { + err.code = 'OBJECT_API_DISABLED'; + err.status = 404; + } else { + err.code = 'OBJECT_API_METHOD_NOT_ALLOWED'; + err.status = 405; + err.allowed = effectiveOperationsArray(resolveEffectiveApiMethods(enable)); } + err.object = target; + err.param = param; + throw err; } /** diff --git a/packages/qa/dogfood/test/second-object-exposure.dogfood.test.ts b/packages/qa/dogfood/test/second-object-exposure.dogfood.test.ts index 038c0d723a1..1d22fd32d50 100644 --- a/packages/qa/dogfood/test/second-object-exposure.dogfood.test.ts +++ b/packages/qa/dogfood/test/second-object-exposure.dogfood.test.ts @@ -17,6 +17,10 @@ // - The dataset door's two dimension-label passes. A target the decision does // not serve for `get` is not read: the stored id renders, and an `order` on // the dimension sorts by it. +// - [#22737] A nested-relation filter condition, on the list and the query +// route. It is a query OF the target, judged `list`, and a filter cannot be +// withheld: a target the decision does not serve is REFUSED (404 / 405), as a +// related object the member may not read already is (403, the precedent). // // ## Armed before anything is believed // @@ -191,6 +195,63 @@ describe('[#22661] a second-object read serves only a target whose declared expo } }); + // [#22737] A nested-relation filter condition is a query OF the target, + // judged `list`: a target that does not serve it is refused in the data + // door's exposure answer on the list and the query route, matched or not. + const conditionAnswers = async (rel: Rel, value: string) => { + const filter = { [rel]: { name: value } }; + return [ + await stack.apiAs(token[persona], 'GET', `/data/sox_source?$filter=${encodeURIComponent(JSON.stringify(filter))}`), + await stack.apiAs(token[persona], 'POST', '/data/sox_source/query', { where: filter }), + ]; + }; + const REFUSED_CONDITION = { hidden: [404, 'OBJECT_API_DISABLED'], closed: [405, 'OBJECT_API_METHOD_NOT_ALLOWED'], getonly: [405, 'OBJECT_API_METHOD_NOT_ALLOWED'] } as const; + + it('nested-relation condition: a target that does not serve list is refused, matched or not', async () => { + for (const [rel, [status, code]] of Object.entries(REFUSED_CONDITION) as Array<[Rel, readonly [number, string]]>) { + for (const value of [MARK[rel], 'no-such-name']) { + for (const res of await conditionAnswers(rel, value)) { + const body: any = await res.json(); + expect({ status: res.status, code: body?.code }, rel).toEqual({ status, code }); + } + } + } + }); + + // The refusal is about the TARGET, so its body names the target and + // carries the effective set exactly as the target's own route does. + it('nested-relation condition: the refusal body names the target, as the target\'s own route does', async () => { + for (const rel of Object.keys(REFUSED_CONDITION) as Rel[]) { + const own = await stack.apiAs(token[persona], 'GET', `/data/${TARGETS[rel].object}`); + const ownBody: any = await own.json(); + for (const res of await conditionAnswers(rel, MARK[rel])) { + const body: any = await res.json(); + expect({ status: res.status, code: body?.code, object: body?.object, allowed: body?.allowed }, rel) + .toEqual({ status: own.status, code: ownBody?.code, object: TARGETS[rel].object, allowed: ownBody?.allowed }); + } + } + }); + + it('CONTROL: the target\'s own route refusal names the target and is unchanged', async () => { + for (const [rel, [status, code]] of Object.entries(REFUSED_CONDITION) as Array<[Rel, readonly [number, string]]>) { + const res = await stack.apiAs(token[persona], 'GET', `/data/${TARGETS[rel].object}`); + const body: any = await res.json(); + expect({ status: res.status, code: body?.code, object: body?.object }, rel).toEqual({ status, code, object: TARGETS[rel].object }); + expect(Array.isArray(body?.allowed), rel).toBe(status === 405); + } + }); + + it('CONTROL nested-relation condition: a target that serves list is evaluated', async () => { + for (const rel of ['listonly', 'open'] as const) { + for (const [value, rows] of [[MARK[rel], 2], ['no-such-name', 0]] as const) { + for (const res of await conditionAnswers(rel, value)) { + expect(res.status, rel).toBe(200); + expect(rowsOf(await res.json()).length, rel).toBe(rows); + } + } + } + }); + it('dataset sort-key pass: an order on a withheld dimension sorts by the stored id', async () => { // By the withheld names this would read soxh_2 (A-…) first. expect((await datasetRows('hidden', { hidden: 'asc' })).map((r) => r.hidden)).toEqual(['soxh_1', 'soxh_2']); @@ -204,4 +265,9 @@ describe('[#22661] a second-object read serves only a target whose declared expo const body: any = await res.json(); expect((body.record ?? body).private).toBe(ids.private); }); + + it('PRECEDENT — a nested-relation condition on a related object the member may not read is refused, unchanged', async () => { + const res = await stack.apiAs(token.member, 'POST', '/data/sox_source/query', { where: { private: { name: MARK.private } } }); + expect({ status: res.status, code: ((await res.json()) as any)?.code }).toEqual({ status: 403, code: 'PERMISSION_DENIED' }); + }); }); diff --git a/packages/rest/src/error-response-sandbox-arm-message.test.ts b/packages/rest/src/error-response-sandbox-arm-message.test.ts index cb83d27840b..c3ddbbbb8b4 100644 --- a/packages/rest/src/error-response-sandbox-arm-message.test.ts +++ b/packages/rest/src/error-response-sandbox-arm-message.test.ts @@ -205,6 +205,21 @@ const ARMS: readonly Arm[] = [ code: 'PERMISSION_DENIED', keeps: { object: 'account' }, }, + { + // [#22737] The exposure arm fires for an object other than the route's. + arm: 'OBJECT_API_DISABLED', + declares: { code: 'OBJECT_API_DISABLED', status: 404, object: 'contact' }, + status: 404, + code: 'OBJECT_API_DISABLED', + keeps: { object: 'contact' }, + }, + { + arm: 'OBJECT_API_METHOD_NOT_ALLOWED', + declares: { code: 'OBJECT_API_METHOD_NOT_ALLOWED', status: 405, object: 'contact', allowed: ['get'] }, + status: 405, + code: 'OBJECT_API_METHOD_NOT_ALLOWED', + keeps: { object: 'contact', allowed: ['get'] }, + }, ]; describe('#14704 · the single `/data` door never ships the QuickJS wrapper out of a declared-code arm', () => { @@ -354,9 +369,10 @@ describe('#14704 · the single `/data` door never ships the QuickJS wrapper out * mechanically, so the next arm added to the shared classification is * either covered above or excused here BY NAME. * - * Measured re-derivation on this tree: fourteen declared-code literals - * sit above the unwrap door — eleven reachable by a sandboxed producer - * (the {@link ARMS} rows; `METADATA_CONFLICT` joined in #22114) and + * Measured re-derivation on this tree: sixteen declared-code literals + * sit above the unwrap door — thirteen reachable by a sandboxed producer + * (the {@link ARMS} rows; `METADATA_CONFLICT` joined in #22114, the two + * exposure codes in #22737) and * three that are not, each for a reason the source states in the arm * itself. */ diff --git a/packages/rest/src/error-response-structured-arm-door-parity.test.ts b/packages/rest/src/error-response-structured-arm-door-parity.test.ts index 472cbb440cc..d20da596a6d 100644 --- a/packages/rest/src/error-response-structured-arm-door-parity.test.ts +++ b/packages/rest/src/error-response-structured-arm-door-parity.test.ts @@ -462,6 +462,38 @@ const CASES: readonly Case[] = [ }, }, }, + { + // [#22737] The exposure arm: the error names an object other than the route's. + covers: ['OBJECT_API_DISABLED'], + what: "metadata-protocol's refuseUnservedRelationTarget (`code`/`status = 404`/`object` = the condition's target)", + error: () => Object.assign(new Error("Object 'contact' is not exposed via the API"), { + code: 'OBJECT_API_DISABLED', status: 404, object: 'contact', + }), + object: 'account', + restored: ['object'], + expect: { + status: 404, + body: { error: "Object 'contact' is not exposed via the API", code: 'OBJECT_API_DISABLED', object: 'contact' }, + }, + }, + { + covers: ['OBJECT_API_METHOD_NOT_ALLOWED'], + what: "metadata-protocol's refuseUnservedRelationTarget (`status = 405`, `object` = the target, `allowed`)", + error: () => Object.assign(new Error("API operation 'list' is not allowed on object 'contact'"), { + code: 'OBJECT_API_METHOD_NOT_ALLOWED', status: 405, object: 'contact', allowed: ['get'], + }), + object: 'account', + restored: ['object', 'allowed'], + expect: { + status: 405, + body: { + error: "API operation 'list' is not allowed on object 'contact'", + code: 'OBJECT_API_METHOD_NOT_ALLOWED', + object: 'contact', + allowed: ['get'], + }, + }, + }, ]; describe('#14541 · structured arms are consulted by BOTH doors', () => { diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index bde983d345a..bcbca89f305 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -618,6 +618,13 @@ export const ERROR_CODE_LEDGER = { 'NOT_ATTEMPTED', // data-batch row never ran — an earlier row's failure stopped the batch, to roll back (atomic, #4793) or because continueOnError was unset (#7539) 'NOT_CREATABLE', 'NOT_OVERRIDABLE', + // [#22737] The data door's two exposure codes, stamped by `findData`'s + // nested-relation walk (`refuseUnservedRelationTarget`) when a condition's + // related object does not serve `list`: 404 for `apiEnabled: false`, 405 + // with `allowed` for a whitelist. Served on the list and query routes. + // `@objectstack/rest` keeps its own rows for the addressed object. + 'OBJECT_API_DISABLED', + 'OBJECT_API_METHOD_NOT_ALLOWED', 'OBJECT_OVERLAY_PACKAGE_MISMATCH', // [ADR-0029 D9.9] object overlay row bound to a package that does not own the object 'OBJECT_PACKAGE_DISABLED', // [#7557] object is registered but its owning package is disabled — data plane refuses rather than serving rows // [#8846] the query body's convenience `object` key names a different diff --git a/packages/types/src/data-error-classification.exposure-arm.test.ts b/packages/types/src/data-error-classification.exposure-arm.test.ts new file mode 100644 index 00000000000..c1d00e9c41e --- /dev/null +++ b/packages/types/src/data-error-classification.exposure-arm.test.ts @@ -0,0 +1,67 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22737] The exposure arm of the shared classification: a thrown + * `OBJECT_API_DISABLED` / `OBJECT_API_METHOD_NOT_ALLOWED` that names an object + * OTHER than the one the route addressed (the data door's refusal of a + * nested-relation condition's target) answers with that object and the + * effective `allowed` set, the REST door's own body for these codes. Before the + * arm, the generic passthrough stamped the route's object beside the code. + * + * Controls: an error naming the route's own object, and a door given no object + * (the analytics dataset route's `classifiedRefusalAnswer`), keep the + * passthrough's body byte for byte, `allowed` absent as before. Both REST doors + * asking this arm are pinned in `@objectstack/rest`'s door-parity suite. + */ + +import { describe, it, expect } from 'vitest'; +import { mapDataError } from './data-error-classification.js'; + +function exposureRefusal(code: 'OBJECT_API_DISABLED' | 'OBJECT_API_METHOD_NOT_ALLOWED', object: string): any { + const disabled = code === 'OBJECT_API_DISABLED'; + const err: any = new Error(disabled + ? `Object '${object}' is not exposed via the API` + : `API operation 'list' is not allowed on object '${object}'`); + err.code = code; + err.status = disabled ? 404 : 405; + err.object = object; + if (!disabled) err.allowed = ['get']; + return err; +} + +describe('[#22737] the exposure arm names the refused object, not the route object', () => { + it('404: the target is the envelope object', () => { + const wire = mapDataError(exposureRefusal('OBJECT_API_DISABLED', 'xa_target'), 'xa_source'); + expect({ status: wire.status, code: wire.body.code, object: wire.body.object }) + .toEqual({ status: 404, code: 'OBJECT_API_DISABLED', object: 'xa_target' }); + expect(wire.body).not.toHaveProperty('allowed'); + }); + + it('405: the target is the envelope object, and the effective set rides as `allowed`', () => { + const wire = mapDataError(exposureRefusal('OBJECT_API_METHOD_NOT_ALLOWED', 'xa_target'), 'xa_source'); + expect({ status: wire.status, code: wire.body.code, object: wire.body.object, allowed: wire.body.allowed }) + .toEqual({ status: 405, code: 'OBJECT_API_METHOD_NOT_ALLOWED', object: 'xa_target', allowed: ['get'] }); + }); + + it('CONTROL: an error naming the route object keeps the passthrough body byte for byte', () => { + expect(mapDataError(exposureRefusal('OBJECT_API_DISABLED', 'xa_source'), 'xa_source')).toEqual({ + status: 404, + body: { error: "Object 'xa_source' is not exposed via the API", code: 'OBJECT_API_DISABLED', object: 'xa_source' }, + }); + expect(mapDataError(exposureRefusal('OBJECT_API_METHOD_NOT_ALLOWED', 'xa_source'), 'xa_source')).toEqual({ + status: 405, + body: { + error: "API operation 'list' is not allowed on object 'xa_source'", + code: 'OBJECT_API_METHOD_NOT_ALLOWED', + object: 'xa_source', + }, + }); + }); + + it('CONTROL: a door given no object keeps the passthrough body byte for byte', () => { + expect(mapDataError(exposureRefusal('OBJECT_API_METHOD_NOT_ALLOWED', 'xa_target'))).toEqual({ + status: 405, + body: { error: "API operation 'list' is not allowed on object 'xa_target'", code: 'OBJECT_API_METHOD_NOT_ALLOWED' }, + }); + }); +}); diff --git a/packages/types/src/data-error-classification.ts b/packages/types/src/data-error-classification.ts index 22d350975ce..61b0d967685 100644 --- a/packages/types/src/data-error-classification.ts +++ b/packages/types/src/data-error-classification.ts @@ -1268,6 +1268,29 @@ function structuredCodeAnswer( }, }; } + // [#22737] The data door's two exposure codes, refusing a SECOND object: the + // error names an object other than the one the route addressed (a + // nested-relation condition's target, `metadata-protocol`'s + // `refuseUnservedRelationTarget`). The generic passthrough would stamp the + // ROUTE's object beside a code saying that object is switched off, so this + // names the error's own object and relays `allowed`, the body the REST door + // sends for these codes itself (`apiAccessDenialFromEnable`). An error naming + // the route's own object, or a door given none, keeps the passthrough's + // answer byte for byte. + if ((error?.code === 'OBJECT_API_DISABLED' || error?.code === 'OBJECT_API_METHOD_NOT_ALLOWED') + && typeof error?.object === 'string' && error.object.length > 0 + && typeof object === 'string' && object.length > 0 && error.object !== object) { + const disabled = error.code === 'OBJECT_API_DISABLED'; + return { + status: disabled ? 404 : 405, + body: { + error: armSentence(error) ?? `The API does not serve object '${error.object}'`, + code: error.code, + object: error.object, + ...(!disabled && Array.isArray(error.allowed) ? { allowed: error.allowed } : {}), + }, + }; + } return undefined; }