diff --git a/.changeset/22739-import-reference-target-exposure.md b/.changeset/22739-import-reference-target-exposure.md index f01f64334ec..b95cc300590 100644 --- a/.changeset/22739-import-reference-target-exposure.md +++ b/.changeset/22739-import-reference-target-exposure.md @@ -24,7 +24,7 @@ Clause-②: no (narrowing) **Bindings.** -- **Every caller of `runImport` whose protocol reads object declarations.** The decision takes no user, so an administrator, a member and a system context (the connector pull) are answered the same. The target's declaration is read through the protocol's `getMetaItem`, a new optional member of `ImportProtocolLike`; a protocol without it (plugin-auth's identity import, whose reads run under the system identity) is not judged. +- **Every caller of `runImport` whose protocol reads object declarations.** The decision takes no user, so an administrator, a member and a system context (the connector pull) are answered the same. The target's declaration is read through the protocol's `getMetaItem`, a new optional member of `ImportProtocolLike`; a protocol without it is not judged (plugin-auth's identity import carries the reader since #22800). - **No new error code, no status change.** The refusal is the per-row `reference_not_found` the resolver already answers. - **Which fields count as references does not widen.** A `user` field written without `reference` still has no target on these doors (its cell reaches the write as written), although the arbiter gives it `sys_user`; adopting that target would widen what the import accepts and is not this change. diff --git a/.changeset/22823-calldata-fallback-relation-exposure.md b/.changeset/22823-calldata-fallback-relation-exposure.md new file mode 100644 index 00000000000..7c9ec3940a8 --- /dev/null +++ b/.changeset/22823-calldata-fallback-relation-exposure.md @@ -0,0 +1,38 @@ +--- +'@objectstack/runtime': minor +--- + +fix(runtime)!: `callData` asks each nested-relation condition's related object its declared exposure before the engine reads it, on the query fallback and the aggregate arm (#22823) + +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.** The runtime dispatcher's data bridge, `callData`, judged only the object a call addresses against its `enable` block, and then handed the caller's `where` to the ObjectQL engine at two positions: + +- the `query` / `find` fallback, which serves when no protocol service resolves (an assembly that composes the engine plugin with `registerProtocol: false` and no `MetadataProtocolPlugin`); +- the `aggregate` arm, which never takes the protocol service, so on every assembly (the MCP `aggregate_records` tool over HTTP reaches it). + +At both, such a condition was evaluated whatever the related object's `enable` block declared, including a related object every data door refuses when it is addressed. + +**TO.** `callData` asks each condition's related object the exposure gate it applies to an addressed object, for `list` (the operation a query of that object is judged as), before the engine is called. A related object the gate does not serve refuses the call with the data door's own exposure answer, the one the protocol path gives the same condition: + +- `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 (`object`) and the condition's position (`param`, `where.`), with the remedy. It applies at the top level and under `$and` / `$or` / `$not`. A related object with no `enable` block, or a whitelist that grants `list`, is served exactly as before. With the protocol service present the `query` arm is unchanged: the data door judges the condition, as it has since #22737. + +**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.** + +- **Refused, not withheld.** Dropping a condition would widen the result. +- **One gate, one walk.** The gate is the one `callData` already applies to an addressed object, and the conditions are found by `relationConditionSites` from `@objectstack/core`, the walk the data door and the MCP stdio bridge use. +- **A system context is not asked**, as it is not asked for the addressed object: these flags govern API exposure, not the engine's own reads. A flow's or a hook's own query calls the engine directly and never passes `callData`. +- **No new error code.** `@objectstack/runtime` now stamps the two existing exposure codes, and the error-code ledger lists them under that package too. + +**Measured producers.** Read on `origin/main` `75b6d6a048`: no shipped composition reaches the `query` fallback. Every non-test `ObjectQLPlugin` construction under `packages/` and `examples/` keeps the built-in protocol (`os serve`, the standalone stack, the dev plugin, the verify harness, the kernel factory), and `registerProtocol: false` appears only in tests. The `aggregate` arm is reached on every assembly that serves the MCP route over HTTP. Deployed compositions were NOT MEASURED. diff --git a/.changeset/22823-spec-ledger-runtime-exposure-codes.md b/.changeset/22823-spec-ledger-runtime-exposure-codes.md new file mode 100644 index 00000000000..5a5c7165489 --- /dev/null +++ b/.changeset/22823-spec-ledger-runtime-exposure-codes.md @@ -0,0 +1,7 @@ +--- +'@objectstack/spec': patch +--- + +fix(spec): the error-code ledger records `@objectstack/runtime` as an emitter of `OBJECT_API_DISABLED` and `OBJECT_API_METHOD_NOT_ALLOWED` (#22823) + +The runtime dispatcher's `callData` now answers the two exposure codes for a nested-relation filter condition whose related object's `enable` block refuses a query of it, on its `query` fallback and its `aggregate` arm, so `ERROR_CODE_LEDGER['@objectstack/runtime']` 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. No export, schema or generated artifact moves. 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 a961f473dcc..cd6fb0ba145 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 @@ -48,7 +48,14 @@ * targets are resolved inside `relationConditionSites` (`@objectstack/core`), * which is the caller classified below. The bridge asks each target the * decision for `list` (`enforceRelationTargetExposure`, #22777), held by its - * own package's pin rather than by a row here. Nor, for the same reason, a + * own package's pin rather than by a row here. The runtime's `callData` is a + * third such door: its `query` fallback (no protocol service resolved) and its + * `aggregate` arm hand a caller's `where` to the same engine lowering, and + * `refuseUnservedRelationTargets` (`runtime/src/action-execution.ts`) asks each + * target the gate the addressed object is asked, for `list`, through the same + * walk (#22823, census row 10), held by its own package's pin + * (`runtime/src/action-execution-calldata-relation-target-exposure.test.ts`) + * rather than by a row here. Nor, for the same reason, a * door that drives `runImport` with a protocol of its own: plugin-auth's * identity import takes its cells' targets from the `import-field-meta.ts` map * classified below, and its run protocol carries the declaration reader the @@ -178,9 +185,9 @@ const CALLERS: Record = { 'packages/core/src/utils/relation-condition-sites.ts': { kind: 'not-a-served-read', reason: - "finds a filter's nested-relation conditions and each one's target; reads no record. Both doors that call it ask " + - "each target the decision before the engine reads it: the data door's `refuseUnservedRelationTarget` and the MCP " + - "stdio data bridge's `enforceRelationTargetExposure`", + "finds a filter's nested-relation conditions and each one's target; reads no record. Every door that calls it asks " + + "each target the decision before the engine reads it: the data door's `refuseUnservedRelationTarget`, the MCP " + + "stdio data bridge's `enforceRelationTargetExposure` and the runtime `callData`'s `refuseUnservedRelationTargets`", }, 'packages/lint/src/object-graph.ts': { kind: 'not-a-served-read', diff --git a/packages/runtime/src/action-execution-calldata-relation-target-exposure.test.ts b/packages/runtime/src/action-execution-calldata-relation-target-exposure.test.ts new file mode 100644 index 00000000000..fe941868e29 --- /dev/null +++ b/packages/runtime/src/action-execution-calldata-relation-target-exposure.test.ts @@ -0,0 +1,155 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#22823] Census row 10 of #22661 — `callData` hands a caller's `where` to the + * ObjectQL ENGINE on two arms: the `query` fallback (no protocol service + * resolved) and the `aggregate` arm (which never takes the protocol). The engine + * lowers a NESTED-RELATION condition by reading the related object as the + * caller, so each condition's TARGET is asked the exposure gate the addressed + * object is asked, for `list`, before the engine is called, and a target the + * gate does not serve refuses the call with the data door's own 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 `find` / `aggregate`. Each refused shape is paired + * with a CONTROL the gate serves, which reaches the engine unchanged, and the + * protocol path is pinned untouched — with the protocol service present the + * query is the data door's to judge, and this door hands it over verbatim. + */ + +import { describe, expect, it, vi } from 'vitest'; +import { callData, type ActionExecutionDeps } from './action-execution.js'; +import type { HttpProtocolContext } from './http-dispatcher.js'; + +const REQ = { request: {} } as HttpProtocolContext; +const MEMBER = { userId: 'rc_member', isSystem: false, positions: [], permissions: [] } as any; + +const lookup = (name: string, reference: string) => ({ name, label: name, type: 'lookup', reference }); + +const object = (name: string, enable?: Record, extra: Record = {}) => ({ + name, + label: name, + ...(enable ? { enable } : {}), + fields: { + id: { name: 'id', label: 'ID', type: 'text' }, + name: { name: 'name', label: 'Name', type: 'text' }, + ...extra, + }, +}); + +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'] }), + rc_open: object('rc_open'), +}; + +/** 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; + +/** + * The engine `callData` falls back to, with its registry (the schema the + * engine's relation lowering reads) and spies on the two verbs a caller's + * `where` reaches. `withProtocol` adds the data door, which then serves the + * query arm. + */ +function makeHarness(opts: { withProtocol?: boolean } = {}) { + const find = vi.fn(async () => [{ id: 's1', name: 'source' }]); + const aggregate = vi.fn(async () => [{ n: 1 }]); + const findData = vi.fn(async (req: any) => ({ object: req.object, records: [], total: 0, hasMore: false })); + const engine: any = { registry: { getObject: (n: string) => SCHEMAS[n] }, find, aggregate }; + const services: Record = { + metadata: { getObject: async (n: string) => SCHEMAS[n] }, + objectql: engine, + ...(opts.withProtocol ? { protocol: { findData } } : {}), + }; + const deps: ActionExecutionDeps = { + resolveService: (async (_ctx: HttpProtocolContext, name: string) => services[name]) as any, + getObjectQL: async () => engine, + }; + return { deps, find, aggregate, findData }; +} + +const refusal = (e: any) => ({ code: e?.code, status: e?.status, object: e?.object, param: e?.param, allowed: e?.allowed }); + +describe('[#22823] callData asks each nested-relation condition\'s target its exposure before the engine reads it', () => { + for (const [rel, answer] of Object.entries(REFUSED)) { + it(`query fallback: ${rel} is refused ${answer.status} ${answer.code}, top level and under a combinator`, async () => { + const h = makeHarness(); + for (const where of [{ [rel]: { name: 'x' } }, { name: 'source', $or: [{ [rel]: { name: 'x' } }] }]) { + const err: any = await callData(h.deps, REQ, 'query', { object: 'rc_source', query: { where } }, undefined, undefined, MEMBER) + .catch((e) => e); + expect(err).toBeInstanceOf(Error); + expect(refusal(err)).toEqual({ code: answer.code, status: answer.status, object: `rc_${rel}`, param: `where.${rel}`, allowed: answer.allowed }); + } + expect(h.find).not.toHaveBeenCalled(); + }); + + it(`aggregate arm: ${rel} is refused ${answer.status} ${answer.code} before the engine aggregates`, async () => { + const h = makeHarness(); + const err: any = await callData( + h.deps, REQ, 'aggregate', + { object: 'rc_source', where: { [rel]: { name: 'x' } }, aggregations: [{ function: 'count', alias: 'n' }] }, + undefined, undefined, MEMBER, + ).catch((e) => e); + expect(err).toBeInstanceOf(Error); + expect(refusal(err)).toEqual({ code: answer.code, status: answer.status, object: `rc_${rel}`, param: `where.${rel}`, allowed: answer.allowed }); + expect(h.aggregate).not.toHaveBeenCalled(); + }); + } + + for (const rel of SERVED) { + it(`CONTROL ${rel}: served, the condition reaches the engine unchanged on both arms`, async () => { + const h = makeHarness(); + await callData(h.deps, REQ, 'query', { object: 'rc_source', query: { where: { [rel]: { name: 'x' } } } }, undefined, undefined, MEMBER); + expect(h.find).toHaveBeenCalledTimes(1); + expect((h.find.mock.calls[0] as any[])[1].where).toEqual({ [rel]: { name: 'x' } }); + await callData( + h.deps, REQ, 'aggregate', + { object: 'rc_source', where: { [rel]: { name: 'x' } }, aggregations: [{ function: 'count', alias: 'n' }] }, + undefined, undefined, MEMBER, + ); + expect(h.aggregate).toHaveBeenCalledTimes(1); + expect((h.aggregate.mock.calls[0] as any[])[1].where).toEqual({ [rel]: { name: 'x' } }); + }); + } + + it('CONTROL: a plain id comparand on the same lookups is the source\'s own column and is served', async () => { + const h = makeHarness(); + await callData(h.deps, REQ, 'query', { object: 'rc_source', query: { where: { hidden: 'h1', getonly: { $in: ['g1'] } } } }, undefined, undefined, MEMBER); + expect(h.find).toHaveBeenCalledTimes(1); + }); + + it('CONTROL: a system context is not asked, as the addressed object\'s gate is not', async () => { + const h = makeHarness(); + await callData(h.deps, REQ, 'query', { object: 'rc_source', query: { where: { hidden: { name: 'x' } } } }, undefined, undefined, { isSystem: true } as any); + expect(h.find).toHaveBeenCalledTimes(1); + }); + + it('the protocol path is untouched: the data door gets the query verbatim and the engine is never called here', async () => { + const h = makeHarness({ withProtocol: true }); + const query = { where: { hidden: { name: 'x' } } }; + await callData(h.deps, REQ, 'query', { object: 'rc_source', query }, undefined, undefined, MEMBER); + expect(h.findData).toHaveBeenCalledTimes(1); + expect((h.findData.mock.calls[0] as any[])[0].query).toEqual(query); + expect(h.find).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/runtime/src/action-execution.ts b/packages/runtime/src/action-execution.ts index 01991a5ab62..079f932376e 100644 --- a/packages/runtime/src/action-execution.ts +++ b/packages/runtime/src/action-execution.ts @@ -18,7 +18,7 @@ import { validateActionParams, type ActionSession, type ResolvedActionParam } from '@objectstack/spec/ui'; // [#15124] The facade's `find` judges its own parameter against the SAME // declaration its type names, so the refusal and the type cannot drift apart. -import { EngineQueryOptionsSchema } from '@objectstack/spec/data'; +import { EngineQueryOptionsSchema, DATA_ACTION_TO_API_OPERATION } from '@objectstack/spec/data'; import type { ExecutionContext } from '@objectstack/spec/kernel'; import type { IObjectQLEngine, ServiceSlotContract, ServiceSlotContracts } from '@objectstack/spec/contracts'; // [#15942 / #16293] The confirmation member's SPELLING is the contract, so it @@ -30,7 +30,12 @@ import { type AIActionConfirmation, type ActionConfirmationRequiredDetails, } from '@objectstack/spec/contracts'; -import { checkApiExposure } from './api-exposure.js'; +import { checkApiExposure, type ApiExposureDecision, type ObjectApiDef } from './api-exposure.js'; +// [#22823] The ONE walk every door that hands a caller's filter to the engine +// takes to find its nested-relation conditions and each one's target — the data +// door's and the MCP stdio bridge's, imported rather than re-walked so this door +// cannot disagree with them about which conditions a filter holds. +import { relationConditionSites } from '@objectstack/core'; // [#9446] The ONE #9378 status table. Imported rather than re-read here: this // door's blanket `FLOW_FAILED` was the second of three readings of one engine // result, and a second definition of the rule is what let the doors diverge. @@ -145,6 +150,11 @@ export interface ActionExecutionDeps { * engine's live registry first, then the metadata service. `undefined` when * neither answers — callers FAIL CLOSED on that (echo no record), because an * unjudged record is exactly the one that may carry a stored credential. + * + * [#22823] It is also the read {@link refuseUnservedRelationTargets} takes, for + * the filtered object's field map and each condition target's `enable` block: + * the engine's own registry first is the schema the engine's relation lowering + * reads. That caller falls OPEN on `undefined`, as the exposure gate does. */ export async function resolveWriteResponseSchema( deps: ActionExecutionDeps, @@ -168,6 +178,94 @@ export async function resolveWriteResponseSchema( } } +/** + * [#22823] The runtime action a NESTED-RELATION condition's TARGET is judged as + * by this door's exposure gate: `query`, which the spec's derivation + * (`DATA_ACTION_TO_API_OPERATION`) maps to `list`. A condition is a predicate + * over the whole target — the engine runs `find(target, { where: condition })` + * bounded by a match cap, not by ids the caller holds — which is the read the + * target's own filtered query performs. The data door judges the same target as + * `list` (its own `RELATION_CONDITION_TARGET_OPERATION`, `@objectstack/metadata-protocol`), + * and so does the MCP stdio bridge (`GATED_ACTIONS.query`). + */ +const RELATION_CONDITION_TARGET_ACTION = 'query'; +/** The canonical operation that action derives to, named in the refusal. */ +const RELATION_CONDITION_TARGET_OPERATION = DATA_ACTION_TO_API_OPERATION[RELATION_CONDITION_TARGET_ACTION]; + +/** + * [#22823] Census row 10 of #22661. `callData` hands a caller's `where` to the + * ObjectQL ENGINE on two arms: the `query`/`find` fallback that serves when no + * protocol service resolves, and the `aggregate` arm, which never takes the + * protocol. The engine lowers each NESTED-RELATION condition + * (`{ : { : … } }`) by reading the related + * object as the caller (`ObjectQL.lowerRelationConditions`) and keeping the + * rows whose related record matched, so the condition is a predicate over an + * object nobody addressed. The exposure gate at the top of `callData` judges + * only the ADDRESSED object. + * + * So each condition's TARGET is asked the same gate the addressed object is + * asked ({@link checkApiExposure}, for {@link RELATION_CONDITION_TARGET_ACTION}), + * before the engine is called, and a target the gate does not serve REFUSES the + * call. Refused, not withheld: dropping the condition would widen the result. + * The conditions are found by the one walk the data door + * (`refuseUnservedRelationTarget`, #22737) and the MCP stdio bridge + * (`enforceRelationTargetExposure`, #22777) take, `relationConditionSites` from + * `@objectstack/core`, and the refusal is the data door's: the protocol path + * this fallback stands in for answers the same condition `404 + * OBJECT_API_DISABLED` / `405 OBJECT_API_METHOD_NOT_ALLOWED`, and one + * `callData` must not give two answers depending on whether the deployment + * registered the protocol slot (the #5138 rule this file already keeps). + * + * `callData` skips it for a system context on the one decision that skips the + * addressed gate: these flags govern API exposure, not the engine's own reads. A schema + * that cannot be resolved finds no condition (the filtered object) or serves + * (a target), the gate's own fail-open. + */ +async function refuseUnservedRelationTargets( + deps: ActionExecutionDeps, + requestContext: HttpProtocolContext, + engine: unknown, + object: string, + where: unknown, + scopeId?: string, +): Promise { + if (where == null || typeof where !== 'object') return; + const schema = await resolveWriteResponseSchema(deps, requestContext, engine, object, scopeId); + const fields = (schema as { fields?: Record } | undefined)?.fields; + for (const site of relationConditionSites(fields, where)) { + const target = await resolveWriteResponseSchema(deps, requestContext, engine, site.target, scopeId); + const gate = checkApiExposure(target as ObjectApiDef | undefined, RELATION_CONDITION_TARGET_ACTION); + if (!gate.allowed) throw unservedRelationTargetError(site.target, `where.${site.field}`, gate); + } +} + +/** + * [#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. + */ +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 + ? `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; +} + /** The engine for a schema lookup, or `undefined` — a lookup failure is judged by the caller's fail-closed branch. */ async function resolveEngineQuietly(deps: ActionExecutionDeps, requestContext: HttpProtocolContext | undefined, envId?: string): Promise { try { @@ -189,7 +287,11 @@ export async function callData(deps: ActionExecutionDeps, // Honour the object's `apiEnabled` / `apiMethods` declarations for // external traffic. System/internal contexts bypass — these flags // govern API *exposure*, not internal engine self-writes. - if (!executionContext?.isSystem && params?.object) { + // [#22823] Decided ONCE, here: the same bypass covers each nested-relation + // condition's target, asked below on the two arms that hand the engine a + // caller's `where` ({@link refuseUnservedRelationTargets}). + const exposureGated = !executionContext?.isSystem; + if (exposureGated && params?.object) { let def: any; try { const meta = await deps.resolveService(requestContext, 'metadata', scopeId); @@ -390,6 +492,12 @@ export async function callData(deps: ActionExecutionDeps, `normalization this path serves only canonical QueryAST keys (${FALLBACK_QUERY_KEYS.join(', ')}).`, }; } + // [#22823] …and each nested-relation condition's TARGET, before the + // engine reads it — the protocol path asks the same of the same + // sites (`refuseUnservedRelationTarget`). + if (exposureGated) { + await refuseUnservedRelationTargets(deps, requestContext, ql, params.object, bag.where, scopeId); + } let all = await ql.find(params.object, findOpts(bag)); if (!Array.isArray(all) && all && (all as any).value) all = (all as any).value; if (!all) all = []; @@ -416,6 +524,14 @@ export async function callData(deps: ActionExecutionDeps, const engine = (await deps.getObjectQL(requestContext, scopeId)) ?? await deps.resolveService(requestContext, 'objectql', scopeId).catch(() => null); if (engine && typeof engine.aggregate === 'function') { + // [#22823] This arm takes no protocol, so it is the engine's door on + // every assembly: each nested-relation condition's TARGET in `where` + // is asked before the engine aggregates, as the query fallback asks + // it (the engine lowers relation conditions in `where` only, never + // in an aggregation's own `filter`). + if (exposureGated) { + await refuseUnservedRelationTargets(deps, requestContext, engine, params.object, params.where, scopeId); + } const rows = await engine.aggregate( params.object, { diff --git a/packages/spec/src/api/error-code-ledger.zod.ts b/packages/spec/src/api/error-code-ledger.zod.ts index 8d5ab43b2a8..cd029c8d7cf 100644 --- a/packages/spec/src/api/error-code-ledger.zod.ts +++ b/packages/spec/src/api/error-code-ledger.zod.ts @@ -459,6 +459,16 @@ export const ERROR_CODE_LEDGER = { 'FLOW_NO_START_NODE', 'INVALID_OR_EXPIRED', // share-link token 'NEEDS_PASSWORD', // share link requires a password + // [#22823] The data door's two exposure codes, stamped by `callData`'s + // nested-relation walk (`refuseUnservedRelationTargets`, + // `action-execution.ts`) when a condition's related object does not serve + // `list`: 404 for `apiEnabled: false`, 405 with `allowed` for a whitelist. + // Reached on the `query` fallback (no protocol service resolved) and on the + // `aggregate` arm. The protocol path's answer for the same condition is + // `@objectstack/metadata-protocol`'s row; `@objectstack/rest` keeps its own + // rows for the addressed object. Provenance, not identity (see above). + 'OBJECT_API_DISABLED', + 'OBJECT_API_METHOD_NOT_ALLOWED', // [#7557] `DELETE /packages/:id` on the DISPATCHER door — per-item failures // used to ride inside a 200 with a hardcoded `success: true`. Second // EMITTER of the code `@objectstack/rest` already registers for the