Skip to content

Commit 615cba8

Browse files
fix(objectql,metadata-protocol): the data door honours a field's internal flag in its filter, sort, group-by, aggregate, $search and $expand positions (#22646) (#22702)
Fixes #22646 Clause-②: no (narrowing) ## Summary A field declared `internal: true` is withheld from every generic exit (#21197): the engine omits it from every row. The ROW position honoured that; the generic data door's EVALUATE positions did not, so a value the platform withholds from rows could still be learned through a position that answers by its stored value. Class ① product defect, `security` — class, position and function level only. Every position now judges the one flag reader (`collectInternalReadFields` in `@objectstack/objectql`, or its byte-identical `@objectstack/core` twin `collectInternalWriteResponseFields` on the door, which sits below objectql), and a position that reveals a stored value is refused with a declared ADR-0112 envelope — `INVALID_FIELD` / 400. No new error code; `Clause-②` stays `no`. ## Measurement (real stack, before) Measured on `origin/main` as a seeded administrator and an ordinary member, on the list and query data routes, over a platform object's `internal: true` credential-digest column and a non-hidden `internal` text column (the dev's report on #22646 holds the run; ⛔ no request shapes here): - **filter** (implicit, `filter=`, query `where`, nested `$or`, cross-field comparand): served — the predicate reached the driver, a confirmation oracle. - **sort** (`$orderby`): served — the order leaked the comparative stored value. - **group-by** / **aggregate operand**: refused, but as an undeclared `500 INTERNAL_ERROR` (`ObjectQL.rejectCredentialAggregation` threw a bare `Error`). - **per-aggregation filter** (`aggregations[].filter`) and **filter beside an aggregation**: served — the same oracle at a second filter position. - **$search field list**: a non-hidden `internal` text column entered the auto-default scan set and an explicit `$searchFields` naming one was accepted. - **$expand target field**: a one-level expand's own `where` / `orderBy` on the target object's `internal` field reached the expansion sub-read — the expanded record's presence is an oracle on the related row. - **nested-relation condition** (a condition on a lookup's related object, at the top level, under `$or`, and in the count total), measured in patch round 1 on fixture objects: served — the parent row came back only when the related object's `internal` value matched. The same condition inside `aggregations[].filter` was already refused (`INVALID_FILTER`), and the dotted array spelling already refused (`INVALID_FIELD`). - **dotted sort / group-by path through a lookup**: not reachable — the ingress sort gate refuses every spelling (`INVALID_SORT`) and the group-by gate refuses it as an unknown field (`INVALID_FIELD`); a dotted sort inside an `$expand` entry is admitted but changes no answer. No code for this position. - **`$expand` deeper than one level**: served — a depth-2 entry's own `where`, and a depth-1 entry whose `where` is a relation condition onto the grand target, made the expanded record's presence depend on the stored value. - **row** position (control): already withheld (omit), unchanged. ## Fix, by location - `packages/objectql/src/engine.ts` - `rejectCredentialAggregation`: the refusal now carries the ADR-0112 envelope (`INVALID_FIELD` / 400, located at object + field) instead of the bare `Error`. Covers the group-by and aggregate-operand positions for every caller; the `secret` / `password` case that shared the bare `Error` is upgraded with it. - `expandSearchOnAst`: `internal` fields are dropped from the set the search expansion resolves over (a withhold, like the auto-default's `hidden` / credential-type exclusions), so `$search` never scans one — for `find` / `findOne` / `aggregate`, and for `searchAll` through them. - `packages/metadata-protocol/src/protocol.ts` (the generic data door, `findData`) - `assertNoInternalFieldEvaluated`: refuses an `internal` field in the filter, sort, group-by, aggregate-operand and per-aggregation-filter positions, after the field-existence gates and before the engine — the shape, envelope and caller-independence of the stored-metadata body/hash family one axis over. - Every filter position runs through `refuseInternalInFilter`, which judges the filter's own reads and then each nested-relation condition (found by `relationConditionSites`) recursively against the RELATED object's own flag reader; the refusal names the object that declares the field. - `refuseInternalInExpand` walks the whole `$expand` tree: every level's `where` and `orderBy` against that level's target object. - `assertSearchFieldsAreSearchable`: an explicit `$searchFields` naming an `internal` field is refused `INVALID_FIELD` / 400 rather than silently widened back to the default set. The engine's privileged consumers (the credential verifier's lookup by its stored digest under a system context; the share-link / SCIM / approval-token lookups) call the engine directly and never pass the generic data door, so authentication is untouched — exactly as #7823 kept the engine's write results whole while the generic-data-path ingress stripped them. This is pinned by the `NEGATIVE` case in the dogfood file and by `internal-fields.test.ts`'s "keeps the field usable as a WHERE filter". ## Compile-surface checklist No filter COMPILATION semantics change; the fix is a refusal at the data-door ingress (and the engine aggregate-refusal envelope + the search field-set withholding), before any filter compiler runs. Each named surface is **out of scope**: 1. `driver-sql` `applyFilterCondition` (and `driver-sqlite-wasm` / local `driver-turso` by inheritance) — out of scope: untouched; refusal precedes the driver. 2. `driver-turso` RemoteTransport `buildWhereSQL` — out of scope: untouched. 3. service-analytics `compileScopedFilterToSql` — out of scope: untouched; the analytics door's own internal-field refusal landed in PR #22645 (#22634). 4. service-analytics `lowerAnalyticsWhere` — out of scope: untouched. 5. `formula` `matchesFilterCondition` — out of scope: untouched. - half-surface: objectql `applyHaving` / `matchesHaving` — out of scope: untouched; `having` names the aggregated row's columns, where an internal field cannot be named. - `driver-mongodb` `translateFieldOperators` — out of scope: untouched. ## Tests - New pins: `packages/objectql/src/internal-field-positions.test.ts` (aggregate envelope + `$search` withhold, with controls), `packages/metadata-protocol/src/protocol.data-door-internal-field-positions.test.ts` (every evaluate position refused, controls), `packages/qa/dogfood/test/internal-field-data-door-positions.dogfood.test.ts` (member + admin on a real stack, plus the authentication-still-works negative). - Each negative pin is ablation-verified: the door refusal, the aggregate envelope, the `$search` withhold and the explicit-`$searchFields` arm were each mutated on disk (`scripts/ablation-replace.mjs`), the pins observed to fail, and the file restored byte-for-byte (`git diff HEAD` empty). - `@objectstack/objectql` and `@objectstack/metadata-protocol` build and typecheck clean. Affected-surface regression: objectql aggregate/search/judge files (145 tests) and metadata-protocol data-door/search files (159 tests) pass. Implicated gates run green locally (changeset family, `error-status-conformance`, `dispatcher-error-vocabulary`, `engine-double-contract`, `test-source-alias`, `query-options-erasure`, `where-matcher`, `filter-alias-parity`, `issue-citations`, `nul-bytes`, and others); the full farm is CI's. ## Docs The docs-drift list's hand-written pages were read, the data-door pages in full. One correction: `content/docs/api/error-catalog.mdx`'s `INVALID_FIELD` cause said only that a named field does not exist; it now adds that the same code refuses an existing `internal: true` field in an evaluate position, naming the positions. No other page claims an `internal` field is usable in filter, sort, `$search` or `$expand`. `content/docs/releases/` is untouched. ## Patch round 1 (seat review 6100268814) New pins: 14 member + administrator refusal cases (relation condition in four spellings, `$expand` depth-2 filter and sort, depth-1 relation `where` onto the grand target) and 6 controls; `protocol.data-door-internal-field-positions.test.ts` 41/41. Ablations from a committed head, restored byte for byte: the relation-site recursion emptied (10 of 41 red) and the expand-tree recursion removed (4 of 41 red). The engine is unchanged this round. ## Scope Target-OBJECT exposure through `$expand` is a separate axis (#22661). The cross-object search sweep (`searchAll`) is #22640's region; it inherits the engine's search-set withholding through `engine.find` with no change to its own code. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01JfJfBUC3cQ6hhgm9MQK76T --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent c63028e commit 615cba8

8 files changed

Lines changed: 883 additions & 4 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
'@objectstack/objectql': minor
3+
'@objectstack/metadata-protocol': minor
4+
---
5+
6+
fix(objectql,metadata-protocol)!: the data door honours a field's `internal: true` in its filter (related-object conditions included), sort, group-by, aggregate, per-aggregation-filter, `$search` and `$expand` positions at every depth, not only the row (#22646)
7+
8+
Clause-②: no (narrowing)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) No metadata moves: no spec key, authorable spelling, export or stored shape is removed, renamed or re-shaped, so there is nothing for `objectstack migrate meta` to rewrite. What narrows is a runtime read door — the generic data door stops EVALUATING a field whose existing `internal: true` declaration already withholds it from every row, and the remedy is the declaration the author already wrote, never a rewrite of anyone's code or metadata. No exported declaration is removed or narrowed (not runtime-interface-only or type-surface-only); both packages publish (not unpublished); no ADR-0087 id is named or touched (not registered or already-registered). -->
11+
12+
**BREAKING** (an accept-set narrowing), shipped as `minor` under the launch-window convention for breaking changes.
13+
14+
A field declared `internal: true` is withheld from every generic exit (#21197): the engine omits it from every row, select-named included. The ROW position honoured that; the generic data door's EVALUATE positions did not, so a value the platform withholds from rows could still be learned through a position that answers by its stored value.
15+
16+
**FROM.** On `origin/main`, for a member holding read and for an administrator alike, on the list and query data routes (`GET /api/v1/data/:object`, `POST /api/v1/data/:object/query`):
17+
18+
- A **filter** naming such a field was admitted — the predicate reached the driver, so the row came back only when the guess matched: a confirmation oracle over a withheld value.
19+
- A **sort** by such a field was admitted — the order leaks the comparative stored value.
20+
- A **group-by** of, or an **aggregate operand** over, such a field was refused by the engine, but as an undeclared `500 INTERNAL_ERROR` (a bare `Error` with no `code` or `status`), not a declared refusal.
21+
- A **per-aggregation filter** (`aggregations[].filter`) naming it, and a **filter beside an aggregation**, were admitted — the same oracle at a second filter position.
22+
- A **`$search`** whose resolved set included such a field (a non-hidden `internal` text column enters the auto-default) scanned it — a cross-field substring match that confirms a guessed prefix.
23+
- A **nested-relation condition** (a condition on a lookup's related object) naming the related object's `internal` field was evaluated on that object — the parent row came back only when the related value matched, the same oracle one hop away.
24+
- An **`$expand`** entry's own `where` / `orderBy` on its TARGET object's `internal` field was admitted into the expansion sub-read, at the first level and at every deeper level — the expanded record's presence is an oracle on the related row.
25+
26+
**TO.** Each evaluate position is refused `400 INVALID_FIELD`, a declared ADR-0112 envelope naming the object and the field, before the engine is asked:
27+
28+
- Filter, sort, group-by, aggregate operand and per-aggregation filter are refused at the generic data door (`@objectstack/metadata-protocol`), after the field-existence gates, in the shape and envelope of the stored-metadata body/hash family one axis over. A nested-relation condition inside any filter is judged against the RELATED object's own `internal` fields, and every level of an `$expand` tree has its own filter / sort judged against that level's target object; the refusal names the object that declares the field.
29+
- The engine's `aggregate()` refusal (`rejectCredentialAggregation`) now carries the `INVALID_FIELD` / 400 envelope instead of the bare `Error`, so the group-by and aggregate-operand positions answer a declared `400` rather than a `500` — and the `secret` / `password` case that shared that bare `Error` is upgraded with it.
30+
- A `$search` never scans an `internal: true` column: the field is dropped from the set the engine's search expansion resolves over (a withhold, like the auto-default's `hidden` / credential-type exclusions), and an explicit `$searchFields` naming one is refused `400 INVALID_FIELD` at the door rather than silently widened back to the default set.
31+
32+
**Bindings.**
33+
34+
- **Every caller.** The refusal is at the generic data door and takes no user, so an administrator is refused exactly as a member is (#21197). There is no system carve-out at the door.
35+
- **The engine's privileged consumers are untouched.** The API-key verifier's `where: { key: <hash> }` lookup, the share-link / SCIM / approval-token lookups — all call the engine directly under a system context and never pass the generic data door, so authentication keeps working, exactly as #7823 kept the engine's write results whole while the generic-data-path ingress stripped them.
36+
- **One flag reader.** Every position judges `collectInternalReadFields` (engine) or its byte-identical `@objectstack/core` twin `collectInternalWriteResponseFields` (the door, which sits below objectql), never a second list.
37+
- **Unchanged.** An ordinary field in every position is served as before; an object that merely has an `internal` field is served for every other field.
38+
39+
**Scope.** Target-OBJECT exposure through `$expand` is a separate axis (#22661); the cross-object search sweep (`searchAll`) is #22640's region and inherits the engine's search-set withholding through `engine.find` without a change to its own code.

‎content/docs/api/error-catalog.mdx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,13 @@ searchable set gets its own message, and so does a value with the wrong shape
147147
an array entry that is not a string, or a value that is itself neither a
148148
comma-separated string nor an array of field names), `groupBy`, and
149149
`aggregations[].field`.
150+
The same code also refuses a field that DOES exist but is declared `internal: true`,
151+
when a read names it in a position that would evaluate its stored value: a filter
152+
(including a condition on a related object reached through a lookup, and an
153+
aggregation's own `filter`), a sort, a `groupBy`, an `aggregations[].field`, a
154+
`searchFields` entry, or an `expand` entry's own filter or sort at any depth. Such a
155+
field is never returned on a generic read, so it cannot be queried by either — the
156+
refusal names the object that declares it. Query by another field.
150157
Off the request path the same code answers `backfillSummaryNulls`'s
151158
`recomputeUndefinedOnEmpty` (`os migrate summary-nulls
152159
--recompute-undefined-on-empty object.field`) when an entry is not a roll-up
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#22646] The generic data door refuses every EVALUATE position that names a
5+
* field declared `internal: true`, before the engine is asked — the position
6+
* enumeration this card's fix is pinned by.
7+
*
8+
* A field declared `internal: true` is withheld from every generic exit
9+
* (#21197): the engine omits it from every row (a withhold — pinned on the row
10+
* position in objectql's `internal-fields.test.ts`). The EVALUATE positions do
11+
* NOT follow that by omission — a filter is a confirmation oracle, a sort leaks
12+
* the comparative value, a group key IS the value — so this door REFUSES them,
13+
* `INVALID_FIELD` / 400, the same envelope and caller-independence as the
14+
* stored-metadata body/hash family one axis over.
15+
*
16+
* Each refusal is paired with a CONTROL on an ordinary field of the SAME object
17+
* that must keep working and must reach the engine — a door that refused every
18+
* query would pass every "refused" assertion and serve nothing. The door binds
19+
* every caller (no context carve-out here); the member/admin end-to-end split
20+
* is pinned on a real stack in `@objectstack/dogfood`.
21+
*
22+
* The position enumeration is the load-bearing shape: a position added later
23+
* that does not pass through this door turns a case here red.
24+
*/
25+
26+
import { describe, expect, it, vi } from 'vitest';
27+
import type { ServiceObject } from '@objectstack/spec/data';
28+
import { assertEngineFindOnePredicate } from '@objectstack/metadata-core';
29+
import { ObjectStackProtocolImplementation } from './protocol.js';
30+
31+
const VAULT: ServiceObject = {
32+
name: 'pv_vault',
33+
label: 'Vault',
34+
fields: {
35+
id: { name: 'id', label: 'ID', type: 'text' },
36+
name: { name: 'name', label: 'Name', type: 'text' },
37+
rank: { name: 'rank', label: 'Rank', type: 'number' },
38+
// The flagged column — a one-way digest in a `text` column, the shape
39+
// the flag exists for (#7728). Non-hidden, so nothing else withholds it.
40+
secret_hash: { name: 'secret_hash', label: 'Secret', type: 'text', internal: true },
41+
inner_id: { name: 'inner_id', label: 'Inner', type: 'lookup', reference: 'pv_inner' },
42+
},
43+
} as unknown as ServiceObject;
44+
45+
/** The grand target: two relation hops from `pv_holder`, with its own flagged column. */
46+
const INNER: ServiceObject = {
47+
name: 'pv_inner',
48+
label: 'Inner',
49+
fields: {
50+
id: { name: 'id', label: 'ID', type: 'text' },
51+
name: { name: 'name', label: 'Name', type: 'text' },
52+
inner_secret: { name: 'inner_secret', label: 'Inner Secret', type: 'text', internal: true },
53+
},
54+
} as unknown as ServiceObject;
55+
56+
const HOLDER: ServiceObject = {
57+
name: 'pv_holder',
58+
label: 'Holder',
59+
fields: {
60+
id: { name: 'id', label: 'ID', type: 'text' },
61+
name: { name: 'name', label: 'Name', type: 'text' },
62+
vault_id: { name: 'vault_id', label: 'Vault', type: 'lookup', reference: 'pv_vault' },
63+
},
64+
} as unknown as ServiceObject;
65+
66+
const SCHEMAS: Record<string, unknown> = { pv_vault: VAULT, pv_holder: HOLDER, pv_inner: INNER };
67+
const ROWS: Record<string, Record<string, unknown>[]> = {
68+
pv_vault: [{ id: 'v1', name: 'alpha', rank: 1, secret_hash: 'h1' }],
69+
pv_holder: [{ id: 'o1', name: 'owner', vault_id: 'v1' }],
70+
pv_inner: [{ id: 'i1', name: 'inner', inner_secret: 's1' }],
71+
};
72+
73+
function makeProtocol() {
74+
const find = vi.fn(async (object: string, _o: any) => (ROWS[object] ?? []).map((r) => ({ ...r })));
75+
const findOne = vi.fn(async (object: string, o: any) => {
76+
assertEngineFindOnePredicate(object, o);
77+
return { ...(ROWS[object]?.[0] ?? {}) };
78+
});
79+
const aggregate = vi.fn(async () => [{ n: 1 }]);
80+
const count = vi.fn(async () => (ROWS.pv_vault?.length ?? 0));
81+
const engine: any = { registry: { getObject: (n: string) => SCHEMAS[n] }, find, findOne, count, aggregate };
82+
return { p: new ObjectStackProtocolImplementation(engine), find, aggregate, count };
83+
}
84+
85+
async function refusal(run: () => Promise<unknown>): Promise<any> {
86+
let resolved = false;
87+
let caught: any;
88+
try { await run(); resolved = true; } catch (e) { caught = e; }
89+
expect(resolved, 'expected a refusal, but the query ran').toBe(false);
90+
return caught;
91+
}
92+
93+
const F = 'secret_hash';
94+
95+
describe('[#22646] the data door refuses an `internal: true` field in every evaluate position', () => {
96+
const cases: Array<[string, Record<string, unknown>]> = [
97+
['implicit filter', { [F]: 'h1' }],
98+
['filter (OData param)', { filter: JSON.stringify({ [F]: 'h1' }) }],
99+
['filter ($eq, POST body)', { where: { [F]: { $eq: 'h1' } } }],
100+
['filter ($startsWith)', { where: { [F]: { $startsWith: 'h' } } }],
101+
['filter (under $or)', { where: { $or: [{ [F]: 'h1' }, { name: '__none__' }] } }],
102+
['filter (cross-field $field comparand)', { where: { name: { $eq: { $field: F } } } }],
103+
['sort (wire param)', { sort: F }],
104+
['sort (descending)', { sort: `-${F}` }],
105+
['sort (orderBy node)', { orderBy: [{ field: F, order: 'asc' }] }],
106+
['group-by (string)', { groupBy: [F], aggregations: [{ function: 'count', alias: 'n' }] }],
107+
['group-by (object form)', { groupBy: [{ field: F }], aggregations: [{ function: 'count', alias: 'n' }] }],
108+
['aggregate operand', { aggregations: [{ function: 'max', field: F, alias: 'm' }] }],
109+
['aggregation filter', { aggregations: [{ function: 'count', alias: 'n', filter: { [F]: 'h1' } }] }],
110+
['filter beside an aggregation (aggregate where)', { where: { [F]: 'h1' }, aggregations: [{ function: 'count', alias: 'n' }] }],
111+
];
112+
for (const [label, query] of cases) {
113+
it(`${label} is refused (INVALID_FIELD / 400), engine never asked`, async () => {
114+
const { p, find, aggregate, count } = makeProtocol();
115+
const err = await refusal(() => p.findData({ object: 'pv_vault', query }));
116+
expect(err.code).toBe('INVALID_FIELD');
117+
expect(err.status).toBe(400);
118+
expect(err.field).toBe(F);
119+
expect(err.object).toBe('pv_vault');
120+
expect(find).not.toHaveBeenCalled();
121+
expect(aggregate).not.toHaveBeenCalled();
122+
expect(count).not.toHaveBeenCalled();
123+
});
124+
}
125+
126+
it('explicit $searchFields naming the internal field is refused (not silently widened)', async () => {
127+
const { p, find } = makeProtocol();
128+
const err = await refusal(() => p.findData({ object: 'pv_vault', query: { search: 'h', searchFields: F } }));
129+
expect(err.code).toBe('INVALID_FIELD');
130+
expect(err.status).toBe(400);
131+
expect(err.field).toBe(F);
132+
expect(find).not.toHaveBeenCalled();
133+
});
134+
135+
it("a one-level expand's own filter/sort on the TARGET object's internal field is refused", async () => {
136+
for (const query of [
137+
{ expand: { vault_id: { object: 'pv_vault', where: { [F]: 'h1' } } } },
138+
{ expand: { vault_id: { object: 'pv_vault', orderBy: [{ field: F, order: 'asc' }] } } },
139+
]) {
140+
const { p, find } = makeProtocol();
141+
const err = await refusal(() => p.findData({ object: 'pv_holder', query }));
142+
expect(err.code).toBe('INVALID_FIELD');
143+
expect(err.status).toBe(400);
144+
expect(err.field).toBe(F);
145+
expect(err.object).toBe('pv_vault');
146+
expect(find).not.toHaveBeenCalled();
147+
}
148+
});
149+
});
150+
151+
describe('[#22646] CONTROL — an ordinary field of the same object is unchanged', () => {
152+
it('a filter on `name` is served and reaches the engine', async () => {
153+
const { p, find } = makeProtocol();
154+
const res: any = await p.findData({ object: 'pv_vault', query: { name: 'alpha' } });
155+
expect(find).toHaveBeenCalledTimes(1);
156+
expect((res.records ?? res.data ?? res).length).toBe(1);
157+
});
158+
it('a sort on `name` is served', async () => {
159+
const { p, find } = makeProtocol();
160+
await p.findData({ object: 'pv_vault', query: { sort: 'name' } });
161+
expect(find).toHaveBeenCalledTimes(1);
162+
});
163+
it('a group-by on `name` is served and reaches the engine', async () => {
164+
const { p, aggregate } = makeProtocol();
165+
await p.findData({ object: 'pv_vault', query: { groupBy: ['name'], aggregations: [{ function: 'count', alias: 'n' }] } });
166+
expect(aggregate).toHaveBeenCalledTimes(1);
167+
});
168+
it('an aggregate operand over the non-internal numeric `rank` is served', async () => {
169+
const { p, aggregate } = makeProtocol();
170+
await p.findData({ object: 'pv_vault', query: { aggregations: [{ function: 'max', field: 'rank', alias: 'm' }] } });
171+
expect(aggregate).toHaveBeenCalledTimes(1);
172+
});
173+
it('a $searchFields on the ordinary `name` field is served', async () => {
174+
const { p, find } = makeProtocol();
175+
await p.findData({ object: 'pv_vault', query: { search: 'al', searchFields: 'name' } });
176+
expect(find).toHaveBeenCalledTimes(1);
177+
});
178+
});
179+
180+
/**
181+
* Rework round 1 — the positions the first pass did not walk, measured served
182+
* on a real engine and SQL driver before this change (fixture objects only):
183+
* a NESTED-RELATION condition evaluated on the related object, and an `expand`
184+
* tree deeper than one level. Each is refused for a member AND an
185+
* administrator: the door takes no caller carve-out, and the context is passed
186+
* so a future carve-out would have to defeat these cases to land.
187+
*/
188+
const PERSONAS = {
189+
member: { userId: 'u_member', roles: [] as string[] },
190+
admin: { userId: 'u_admin', roles: ['admin'], isPlatformAdmin: true },
191+
} as const;
192+
193+
describe('[#22646] relation conditions and every expand level are judged on their own object', () => {
194+
const cases: Array<[string, string, Record<string, unknown>, string, string]> = [
195+
['relation filter (nested-relation form)', 'pv_holder', { where: { vault_id: { [F]: 'h1' } } }, 'pv_vault', F],
196+
['relation filter under $or', 'pv_holder', { where: { $or: [{ vault_id: { [F]: 'h1' } }, { name: '__none__' }] } }, 'pv_vault', F],
197+
['relation filter (OData filter param)', 'pv_holder', { filter: JSON.stringify({ vault_id: { [F]: 'h1' } }) }, 'pv_vault', F],
198+
['relation filter beside an aggregation', 'pv_holder', { where: { vault_id: { [F]: 'h1' } }, aggregations: [{ function: 'count', alias: 'n' }] }, 'pv_vault', F],
199+
[
200+
'expand depth 2: the grand target\'s filter',
201+
'pv_holder',
202+
{ expand: { vault_id: { object: 'pv_vault', expand: { inner_id: { object: 'pv_inner', where: { inner_secret: 's1' } } } } } },
203+
'pv_inner',
204+
'inner_secret',
205+
],
206+
[
207+
'expand depth 2: the grand target\'s sort',
208+
'pv_holder',
209+
{ expand: { vault_id: { object: 'pv_vault', expand: { inner_id: { object: 'pv_inner', orderBy: [{ field: 'inner_secret', order: 'asc' }] } } } } },
210+
'pv_inner',
211+
'inner_secret',
212+
],
213+
[
214+
'expand depth 1: a relation condition onto the grand target',
215+
'pv_holder',
216+
{ expand: { vault_id: { object: 'pv_vault', where: { inner_id: { inner_secret: 's1' } } } } },
217+
'pv_inner',
218+
'inner_secret',
219+
],
220+
];
221+
for (const [label, object, query, owner, field] of cases) {
222+
for (const [persona, context] of Object.entries(PERSONAS)) {
223+
it(`${label} — ${persona}: INVALID_FIELD / 400 on '${owner}.${field}', engine never asked`, async () => {
224+
const { p, find, aggregate, count } = makeProtocol();
225+
const err = await refusal(() => p.findData({ object, query, context }));
226+
expect(err.code).toBe('INVALID_FIELD');
227+
expect(err.status).toBe(400);
228+
expect(err.object).toBe(owner);
229+
expect(err.field).toBe(field);
230+
expect(find).not.toHaveBeenCalled();
231+
expect(aggregate).not.toHaveBeenCalled();
232+
expect(count).not.toHaveBeenCalled();
233+
});
234+
}
235+
}
236+
});
237+
238+
describe('[#22646] CONTROL — ordinary fields through a relation and at depth are unchanged', () => {
239+
const served: Array<[string, Record<string, unknown>]> = [
240+
['a relation condition on the related object\'s ordinary `name`', { where: { vault_id: { name: 'alpha' } } }],
241+
['an operator bag on the lookup itself (not a relation condition)', { where: { vault_id: { $eq: 'v1' } } }],
242+
['expand depth 2 with an ordinary filter', { expand: { vault_id: { object: 'pv_vault', expand: { inner_id: { object: 'pv_inner', where: { name: 'inner' } } } } } }],
243+
];
244+
for (const [label, query] of served) {
245+
for (const [persona, context] of Object.entries(PERSONAS)) {
246+
it(`${label} — ${persona}: served, the engine is asked`, async () => {
247+
const { p, find } = makeProtocol();
248+
await p.findData({ object: 'pv_holder', query, context });
249+
expect(find).toHaveBeenCalledTimes(1);
250+
});
251+
}
252+
}
253+
});

0 commit comments

Comments
 (0)