Skip to content

Commit 7b76fff

Browse files
fix(service-analytics): the ObjectQL execute face refuses an unrunnable read scope in the withheld READ_SCOPE_COMPILE_FAILED / 500 envelope (#20017)
Part of #19995 — this PR closes the comparand half of the card: every scope shape the engine's two SHARED comparand faces refuse. The card stays open for the residue: scope shapes refused by engine doors that read the object's schema or the request's context, and by `driver-sql`, still answer a relayed 400. Why that half is not done here, and the decision it needs, is under "Residue" below. Clause-②: no ## What changed The ObjectQL execute face composed a row-level read scope into the engine's `where` without judging it. A scope carrying a comparand the engine refuses came back as the engine's `INVALID_FILTER` / 400. The HTTP doors relay a 4xx's message, and that message named the policy's field and comparand. The NativeSQL face and the `/analytics/sql` echo refuse the same scope as `READ_SCOPE_COMPILE_FAILED` / 500 with the message withheld (the #5367 ruling, re-affirmed as #7598 Q2 = A). So one scope got two envelopes, depending on which analytics face served it. - **`read-scope-sql.ts`: new `assertReadScopeComparandsRunnable(scope, objectName)`**, next to the #13640 vacancy guard. It runs the two faces the engine itself runs on every object-form `where` (`assertListComparandShapes` and `normalizeFilterComparandTypes` from `@objectstack/spec/data`) on the scope alone. It re-raises any refusal through the module's one envelope helper, `readScopeCompileError`, keeping the walk's sentence for the operator's log. It is exported from the file only, not from the package entry, so the published surface is unchanged. - **`objectql-strategy.ts`: called at both engine-bound merges.** - `withReadScope` covers the direct path and the cross-object base aggregate. - `resolveFkAttr` covers the referenced object's own scope. - It runs after the vacancy guard and before the `'policy'` mark. **Why at the merge boundary, on the scope alone.** At that point the scope is still a distinguishable object. One line later it is `$and`-composed with the caller's filter, and then nothing downstream can tell whose clause a refusal came from. **Why the refusal set does not move.** The engine's comparand-shape refusal does not read the `'policy'` provenance mark (measured below). Both faces are pure walks whose verdict on a subtree depends neither on the rest of the tree nor on any schema. So the scope alone answers exactly as the scope inside `{ $and: [userFilter, scope] }` does, and nothing the engine serves is refused. A `{ $field }` scope (served on this path under #7598 Q1 = B) is stepped around by both faces, as the engine steps around it. ⛔ This is not a catch around `executeAggregate`. The caller's own `where` still reaches the engine's doors for some shapes, and those keep their `INVALID_FILTER` / 400 with the message. This is pinned, and ablation A5 below shows that pin going red under misattribution. ## Measurement: recorded before the fix Measured on base `14add487b4`, using a scratch probe that is not committed: `AnalyticsService` (ObjectQL only) over a real `ObjectQL`, run twice, once over `SqlDriver` (better-sqlite3) and once over `SqliteWasmDriver`. The two drivers gave the same answer in every row. The HTTP leg went through the real routes: `@objectstack/runtime`'s dispatcher `POST /api/v1/analytics/query` and `@objectstack/rest`'s `POST /analytics/dataset/query`. The read scopes are the `getReadScope` contract filled by hand. The same probe was re-run after the fix, against a rebuilt `dist`. | Read-scope shape class | Layer that answers on the ObjectQL face | Before (ObjectQL face and both HTTP doors) | Refusal text names policy content | After | |---|---|---|---|---| | List in the implicit equality slot | Engine, shared list-shape face | `INVALID_FILTER` / 400, relayed | yes | `READ_SCOPE_COMPILE_FAILED` / 500, withheld | | List under `$eq` | Engine, shared list-shape face | 400, relayed | yes | 500, withheld | | Scalar under `$in` or `$nin` | Engine, shared list-shape face | 400, relayed | yes | 500, withheld | | One-bound `$between` | Engine, shared list-shape face | 400, relayed | yes | 500, withheld | | Null member in `$in` | Engine, shared list-shape face | 400, relayed | yes | 500, withheld | | Plain-object member in `$in`; plain-object comparand under `$eq`; `undefined` comparand | Engine, shared comparand-type face | 400, relayed | yes | 500, withheld | | Emptied `$nin` (control) | Analytics vacancy guard (#13640) | 500, withheld | no (withheld) | unchanged | | List under `$ne` or `$gt`; nested relation value; cross-field reference | `driver-sql`, which reads the mark | 400, message withheld | no | unchanged | | Column the object does not have | `driver-sql`, missing column | 400, relayed | yes | unchanged (residue) | | Retired or unknown operator | `driver-sql` operator vocabulary | 400, relayed | yes (the field) | unchanged (residue) | | Text operator on a non-text field | Engine, declared-type door | 400, relayed | yes | unchanged (residue) | | Temporal comparand the platform cannot read | Engine, temporal door | 400, relayed | yes | unchanged (residue) | | Combinator with a non-array operand | `driver-sql` | 400, relayed | yes | unchanged (residue) | | Non-boolean `$null` or `$exists` | `driver-sql` | 400, relayed | yes (the field) | unchanged (residue) | | Unknown filter placeholder | Engine token resolver | `FILTER_TOKEN_UNKNOWN` / 400, relayed | yes | unchanged (residue) | | Well-formed scope (control) | — | 200, scoped rows | — | unchanged | - **Hypothesis 2 (does the engine refusal read the mark?): confirmed.** The scope reached the engine stamped `'policy'` by `withReadScope`, and the comparand-shape refusal still returned its full text. Of the refusals in the table, only `driver-sql`'s bind and cross-field refusals read the mark. - **Hypothesis 3 (does the ObjectQL face reach the read-scope compiler?): confirmed.** The ObjectQL execute face never reaches `compileScopedFilterToSql`. The NativeSQL face and the echo answered `READ_SCOPE_COMPILE_FAILED` / 500 for every fixed class except two: a plain-object comparand under `$eq` and a null member in `$in`. That compiler compiles both of those (see Acceptance notes). - **Scope reads quoted.** The strategy has four: - `generateSql`'s echo merge compiles through `compileScopedFilterToSql`, is already withheld, and is untouched. - `withReadScope` and `resolveFkAttr` are the two engine-bound merges, and both are now guarded. - `NativeSQLStrategy.applyReadScope` compiles and is untouched. ## Tests The new file is `packages/services/service-analytics/src/__tests__/objectql-read-scope-refusal-envelope.test.ts`, with 19 cases over a real `ObjectQL` and `SqliteWasmDriver`. - **Refusal cases.** Each refused shape class asserts: - `code` `READ_SCOPE_COMPILE_FAILED` and `status` 500; - that the reads every analytics HTTP door takes before relaying prose (`serverFaultProvenance(resolveThrownHttpError(err, 500))` is `'declared'`, `declaredRefusalMessage(err)` is undefined) mean the prose is withheld; - that the thrown message, which is the operator's log channel, still carries the detail. - **Paths covered:** the direct path, a well-formed caller `where` beside a refused scope, the cross-object base scope, and the referenced-object scope. - **Controls:** - a well-formed scope is served with its rows; - a cross-field scope is served; - an emptied `$in` beside an own-rows grant is served; - a well-formed referenced-object scope buckets what it hides as `(restricted)`; - the caller's own `where` refused by the ENGINE stays `INVALID_FILTER` / 400 with its message; - the caller's own `where` in the scope's refused shape stays 400 with its message. **Results:** - Measurement commit `e4908254bc` (test only, before the fix): `Tests 13 failed | 6 passed (19)`. Every refusal case received `INVALID_FILTER`, and every control was green. - At `9a40317b15`: - the new file gives `Tests 19 passed (19)`; - the whole package gives `Test Files 118 passed (118)`, `Tests 2560 passed (2560)`; - `pnpm --filter @objectstack/service-analytics typecheck` exits 0, and `tsc --listFiles` includes the new test. **Ablations.** Each leg ran through `scripts/ablation-replace.mjs` (the anchor must hit exactly once, and the blob must change) with a trap. Each restore was proven: the blob equals HEAD, and `git diff HEAD` is empty. | Leg | Mutation | Result | |---|---|---| | A1 | Delete the `withReadScope` call | 12 failed, 7 passed. Every direct and cross-object-base refusal case received `INVALID_FILTER`. | | A2 | Delete the `resolveFkAttr` call | 1 failed (the referenced-object case), 18 passed | | A3 | Delete the comparand-type face call | 3 failed (the three type-face rows) | | A4 | Delete the list-shape face call | 10 failed (the shape rows, the composed case, both cross-object cases) | | A5 | Judge the COMPOSED tree instead of the scope alone | 1 failed: the caller's engine-refused `where` received `READ_SCOPE_COMPILE_FAILED` instead of `INVALID_FILTER` | ## Gates - **Derived gates.** Derived at `9a40317b15` with `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`: 72 families, a superset of the dispatch-time list. All 72 exited 0. - `check:dual-build-cjs-loads` and `check:type-check-debt` first answered PREREQUISITE NOT MET (exit 3). I built the `./packages/*` closure and re-ran both, and both exited 0. - The `--ran` reconciliation reported 72 derived, 72 run, 0 unrun. - **Issue citations.** `GITHUB_TOKEN=… node scripts/check-issue-citations.mjs` exited 0: 10 citations judged, 10 resolve. - **Lint, narrowed to the four touched TypeScript files.** `eslint --no-inline-config --format json` read 4 files and reported 0 errors and 0 warnings. `eslint.config.mjs` enables no type-aware linting (no `parserOptions.project`, no `projectService`), so this diff cannot move a verdict on an untouched file. `pnpm lint` itself is CI's. ## Acceptance notes - **`@objectstack/objectql` is a new devDependency**, aliased to source in the package's `vitest.config.ts` with an anchored regex. This is the repair `check:test-source-alias` prescribes; the ledger does not grow, and the gate is green. The refusal under test is the engine's own, so a stub bridge would only have tested the stub. - **Envelope reuse.** The guard lives in `read-scope-sql.ts` because `readScopeCompileError` is module-local by design. A guard in the strategy file would have needed a second spelling of the envelope. - **A host with its own `executeAggregate` bridge:** the scope is now judged against the spec's shared comparand faces whichever bridge executes, the same posture as the vacancy guard. A custom bridge that used to tolerate one of these off-contract scope shapes now gets the withheld 500. The engine bridge refused all of them already. - **Precedence.** When the caller's `where` and the scope are both refused, and only the engine would refuse the caller's clause, the scope's 500 now answers first. A caller clause refused by the analytics `where` door still answers first, with its 400. - **NativeSQL face and echo:** unchanged. They compile two shapes (a plain-object comparand under `$eq`, a null member in `$in`) that the shared faces and the ObjectQL face refuse. This was measured with a stubbed `executeRawSql`, so the database-level outcome is unmeasured. It is reported to the seat and not touched here. - **`origin/main` has moved on by one commit** (`fc6ddb87a4`, driver-sqlite-wasm text round-trip). It shares no path with this diff and is not merged. - **Files not touched:** `filter-normalizer.ts`, `packages/spec`, `packages/objectql`, `packages/rest`, `packages/runtime`. ## Residue: why "Part of" These classes still answer the engine's or the driver's 400 with the message relayed: - a column the object does not have; - a retired or unknown operator; - a text operator on a non-text field; - a temporal comparand the platform cannot read; - a combinator with a non-array operand; - a non-boolean `$null` or `$exists`; - an unknown filter placeholder. They are refused by engine doors that read the object's schema or the request's context, and by `driver-sql`'s own compile. This package cannot judge them without a second copy of those rules. The sound fix is at the refusing layer: the refusal reads the `'policy'` provenance mark (#8220), as `driver-sql`'s bind and cross-field refusals already do. That change is in the engine and driver lanes, and it opens an envelope question between the #5367 and #7929 rulings. It is handed back to the seat as a decision. --- _Generated by [Claude Code](https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4463966 commit 7b76fff

7 files changed

Lines changed: 501 additions & 2 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/service-analytics': patch
3+
---
4+
5+
fix(service-analytics): the ObjectQL execute face refuses a read scope it cannot run in the withheld `READ_SCOPE_COMPILE_FAILED` / 500 envelope, not the engine's `INVALID_FILTER` / 400 (#19995)
6+
7+
Clause-②: no
8+
9+
A row-level read scope carrying a comparand the engine's shared comparand faces refuse — a list in the equality slot, a scalar under `$in` / `$nin`, a one-bound `$between`, a null list member, a plain-object or `undefined` comparand — used to reach `engine.aggregate` composed with the caller's own filter, and came back as the engine's `INVALID_FILTER` / 400. A 4xx's message is relayed to the caller, and this one named the policy's fields and comparands. The NativeSQL execute face and the `/analytics/sql` echo already refused the same scope as a server fault with the message withheld (the #5367 ruling), so one scope got two envelopes depending on which analytics face served it.
10+
11+
The ObjectQL strategy now judges the scope on its own at both engine-bound merge sites (the base aggregate, direct and cross-object, and the referenced object's scope in the cross-object label lookup), with the same two shared functions the engine runs, before composing it. A refusal there is `READ_SCOPE_COMPILE_FAILED` / 500: `POST /analytics/query` and `POST /analytics/dataset/query` withhold its message, and the full text goes to the operator's log.
12+
13+
Unchanged: which scopes are served. The judgement uses the engine's own functions, so a scope the engine serves is still served, including a `{ $field }` cross-field scope and an emptied `$in` beside an own-rows grant. The caller's own `where` still answers `INVALID_FILTER` / 400 with its message, whether the analytics door or the engine refuses it.

‎packages/services/service-analytics/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"devDependencies": {
3232
"@objectstack/driver-sql": "workspace:*",
3333
"@objectstack/driver-sqlite-wasm": "workspace:*",
34+
"@objectstack/objectql": "workspace:*",
3435
"@types/node": "^26.2.0",
3536
"@types/sql.js": "^1.4.11",
3637
"sql.js": "^1.14.1",
Lines changed: 371 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,371 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#19995] The ObjectQL execute face refuses a read scope the engine cannot run
5+
* in the SAME withheld envelope as the other two analytics faces —
6+
* `READ_SCOPE_COMPILE_FAILED` / 500 — and never as the engine's
7+
* `INVALID_FILTER` / 400.
8+
*
9+
* ## The ruling this holds on the third face
10+
*
11+
* #5367 (re-affirmed as #7598 Q2 = A), recorded in `read-scope-sql.ts`'s
12+
* header: a read-scope refusal is a SERVER fault, and its message — which names
13+
* the policy's fields and comparands — goes to the operator's log, never into a
14+
* response. The NativeSQL execute face and the `/analytics/sql` echo compile
15+
* the scope with `compileScopedFilterToSql` and hold that. The ObjectQL execute
16+
* face never meets that compiler: `withReadScope` ANDs the scope into the
17+
* `where` handed to `engine.aggregate`, and a scope carrying a comparand the
18+
* engine's shared comparand faces refuse came back as the engine's own
19+
* `INVALID_FILTER` / 400 — a 4xx whose prose the HTTP doors relay verbatim.
20+
* One scope, two envelopes, and the 400 one is the disclosure #5367 closed.
21+
*
22+
* ## Why the verdict is taken at the merge boundary, on the scope ALONE
23+
*
24+
* The engine's comparand-shape refusal does not read the `'policy'` provenance
25+
* mark `withReadScope` stamps (#8220) — measured: the scope's refusal text came
26+
* back whole. And one line after the boundary the scope is `$and`-composed with
27+
* the caller's own filter, after which no consumer can tell whose clause a
28+
* refusal came from. So the scope is judged by the two shared faces the engine
29+
* itself runs on the object-form `where` (`@objectstack/spec/data`'s
30+
* `assertListComparandShapes` and `normalizeFilterComparandTypes`) BEFORE the
31+
* composition — same functions, so the same verdicts, and nothing the engine
32+
* serves is refused here.
33+
*
34+
* ⛔ Not a catch around `executeAggregate`. The caller's own `where` still
35+
* reaches the engine through the object-form door for some shapes, and those
36+
* refusals are the caller's to read: `INVALID_FILTER` / 400 with the message
37+
* kept. The controls below pin exactly that.
38+
*
39+
* ## What "withheld" is asserted as here
40+
*
41+
* Every HTTP door that answers an analytics query decides whether to relay a
42+
* thrown message through the same two reads in `@objectstack/types` —
43+
* `serverFaultProvenance(resolveThrownHttpError(err, 500))` and
44+
* `declaredRefusalMessage(err)` — and relays the prose only for a 4xx or a
45+
* declared refusal. So a refusal that declares `status: 500` with no refusal
46+
* flag is withheld at every door by construction, and a 4xx is relayed. That is
47+
* what each case asserts; the thrown message itself keeps the full detail on
48+
* purpose, because the operator's log is now its only destination (the
49+
* inventory in `read-scope-refusal-envelope.test.ts` pins the same split).
50+
*
51+
* ## The engine is real, because the claim is about the engine's refusal
52+
*
53+
* A real `ObjectQL` over `SqliteWasmDriver` (a `SqlDriver`) stands behind
54+
* `executeAggregate`, bridged the way `AnalyticsServicePlugin`'s auto-bridge
55+
* bridges it. A stub bridge would have made every expectation below a
56+
* statement about the stub. Every read scope is the `getReadScope` contract
57+
* filled by hand, never by the RLS compiler: the contract is what a host
58+
* provider fills, and the CEL lowering is only one producer of it.
59+
*/
60+
61+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
62+
import { ObjectQL } from '@objectstack/objectql';
63+
import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
64+
import { declaredRefusalMessage, resolveThrownHttpError, serverFaultProvenance } from '@objectstack/types';
65+
import type { AnalyticsQuery } from '@objectstack/spec/contracts';
66+
import type { FilterCondition } from '@objectstack/spec/data';
67+
import { DatasetSchema } from '@objectstack/spec/ui';
68+
69+
import { AnalyticsService, type AnalyticsServiceConfig } from '../analytics-service.js';
70+
import { compileDataset } from '../dataset-compiler.js';
71+
72+
const BASE = 'deal';
73+
const REF = 'account';
74+
75+
const BASE_FIELDS: Record<string, Record<string, unknown>> = {
76+
id: { type: 'text', name: 'id' },
77+
region: { type: 'text', name: 'region' },
78+
owner: { type: 'text', name: 'owner' },
79+
account: { type: 'text', name: 'account' },
80+
amount: { type: 'number', name: 'amount' },
81+
budget: { type: 'number', name: 'budget' },
82+
};
83+
const REF_FIELDS: Record<string, Record<string, unknown>> = {
84+
id: { type: 'text', name: 'id' },
85+
tier: { type: 'text', name: 'tier' },
86+
};
87+
88+
const BASE_ROWS = [
89+
{ id: 'd1', region: 'emea', owner: 'u_me', account: 'acc_gold', amount: 10, budget: 5 },
90+
{ id: 'd2', region: 'apac', owner: 'u_other', account: 'acc_silver', amount: 20, budget: 50 },
91+
{ id: 'd3', region: 'amer', owner: 'u_me', account: 'acc_gold', amount: 30, budget: 25 },
92+
];
93+
const REF_ROWS = [
94+
{ id: 'acc_gold', tier: 'gold' },
95+
{ id: 'acc_silver', tier: 'silver' },
96+
];
97+
98+
const dataset = DatasetSchema.parse({
99+
name: 'deal_pipeline',
100+
label: 'Deal pipeline',
101+
object: BASE,
102+
include: [REF],
103+
dimensions: [
104+
{ name: 'region', field: 'region', type: 'string' },
105+
{ name: 'account_tier', field: 'account.tier', type: 'string' },
106+
],
107+
measures: [{ name: 'deal_count', aggregate: 'count' }],
108+
});
109+
110+
/** A base-only query: `execute()`'s direct path, one `withReadScope` merge. */
111+
const DIRECT: AnalyticsQuery = { cube: 'deal_pipeline', dimensions: ['region'], measures: ['deal_count'] };
112+
/** A cross-object dimension: `executeCrossObject` + `resolveFkAttr`, two merges. */
113+
const CROSS: AnalyticsQuery = { cube: 'deal_pipeline', dimensions: ['account_tier'], measures: ['deal_count'] };
114+
115+
interface WireBearingError extends Error {
116+
code?: unknown;
117+
status?: unknown;
118+
}
119+
120+
const quiet = {
121+
debug() {},
122+
info() {},
123+
warn() {},
124+
error() {},
125+
child() {
126+
return quiet;
127+
},
128+
};
129+
130+
/**
131+
* Each row: a scope shape one of the two shared comparand faces refuses, and
132+
* the policy content its refusal text names. `face` records which of the two
133+
* the row exercises, so a later edit dropping one face's call shows up as a
134+
* block of red rows rather than a single one.
135+
*/
136+
const REFUSED_SCOPES: Array<{ name: string; face: 'shape' | 'type'; scope: unknown; secrets: string[] }> = [
137+
{
138+
name: 'a list in the implicit equality slot',
139+
face: 'shape',
140+
scope: { region: ['emea', 'restricted_apac'] },
141+
secrets: ['region', 'restricted_apac'],
142+
},
143+
{
144+
name: 'a list under $eq',
145+
face: 'shape',
146+
scope: { region: { $eq: ['emea', 'restricted_apac'] } },
147+
secrets: ['region', 'restricted_apac'],
148+
},
149+
{
150+
name: 'a scalar under $in',
151+
face: 'shape',
152+
scope: { region: { $in: 'restricted_emea' } },
153+
secrets: ['region', 'restricted_emea'],
154+
},
155+
{
156+
name: 'a scalar under $nin',
157+
face: 'shape',
158+
scope: { region: { $nin: 'restricted_emea' } },
159+
secrets: ['region', 'restricted_emea'],
160+
},
161+
{
162+
name: 'a one-bound $between',
163+
face: 'shape',
164+
scope: { amount: { $between: [424242] } },
165+
secrets: ['amount', '424242'],
166+
},
167+
{
168+
name: 'a null member in $in',
169+
face: 'shape',
170+
scope: { region: { $in: [null, 'restricted_emea'] } },
171+
secrets: ['region'],
172+
},
173+
{
174+
name: 'a list shape nested in an $or beside a well-formed arm',
175+
face: 'shape',
176+
scope: { $or: [{ owner: 'u_me' }, { region: ['emea', 'restricted_apac'] }] },
177+
secrets: ['region', 'restricted_apac'],
178+
},
179+
{
180+
name: 'a plain-object member in $in',
181+
face: 'type',
182+
scope: { region: { $in: [{ restricted_key: 1 }] } },
183+
secrets: ['region', 'restricted_key'],
184+
},
185+
{
186+
name: 'a plain-object comparand under $eq',
187+
face: 'type',
188+
scope: { region: { $eq: { restricted_key: 1 } } },
189+
secrets: ['region', 'restricted_key'],
190+
},
191+
{
192+
name: 'an undefined comparand',
193+
face: 'type',
194+
scope: { region: { $eq: undefined } },
195+
secrets: ['region'],
196+
},
197+
];
198+
199+
function assertWithheldServerFault(err: WireBearingError | undefined, secrets: string[]): void {
200+
expect(err, 'the scope must be refused').toBeInstanceOf(Error);
201+
expect(err?.code).toBe('READ_SCOPE_COMPILE_FAILED');
202+
expect(err?.status).toBe(500);
203+
// The reads every analytics HTTP door takes before relaying prose: a
204+
// producer-declared 5xx that is not a declared refusal ⇒ withheld.
205+
expect(serverFaultProvenance(resolveThrownHttpError(err, 500))).toBe('declared');
206+
expect(declaredRefusalMessage(err)).toBeUndefined();
207+
// …and the detail is RELOCATED, not deleted: the operator's log still has it.
208+
for (const secret of secrets) expect(String(err?.message)).toContain(secret);
209+
}
210+
211+
describe('[#19995] ObjectQL execute face — a read scope the engine refuses is a withheld server fault', () => {
212+
let driver: SqliteWasmDriver;
213+
let engine: ObjectQL;
214+
let service: AnalyticsService;
215+
/** Swapped per case; the `getReadScope` contract filled by hand. */
216+
let scopes: Record<string, unknown> = {};
217+
218+
beforeAll(async () => {
219+
driver = new SqliteWasmDriver({ filename: ':memory:' });
220+
(driver as unknown as { logger: unknown }).logger = quiet;
221+
await driver.initObjects([
222+
{ name: BASE, fields: BASE_FIELDS },
223+
{ name: REF, fields: REF_FIELDS },
224+
] as never);
225+
for (const row of BASE_ROWS) await driver.create(BASE, { ...row });
226+
for (const row of REF_ROWS) await driver.create(REF, { ...row });
227+
228+
engine = new ObjectQL({ logger: quiet });
229+
engine.registerDriver(driver as never, true);
230+
await engine.init();
231+
engine.registerObject({ name: BASE, label: 'Deal', fields: BASE_FIELDS } as never);
232+
engine.registerObject({ name: REF, label: 'Account', fields: REF_FIELDS } as never);
233+
234+
const compiled = compileDataset(dataset);
235+
const config: AnalyticsServiceConfig = {
236+
cubes: [compiled.cube],
237+
logger: quiet,
238+
// ObjectQL only — the face `compileScopedFilterToSql` never sees.
239+
queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }),
240+
getAllowedRelationships: () => compiled.allowedRelationships,
241+
getReadScope: (object: string) => (scopes[object] ?? undefined) as FilterCondition | undefined,
242+
// The auto-bridge's own mapping (`plugin.ts`): `filter` → `where`,
243+
// `method` → `function`. The filter travels verbatim, so what answers is
244+
// the engine's lowering of it.
245+
executeAggregate: async (objectName, options) =>
246+
(await engine.aggregate(objectName, {
247+
where: options.filter,
248+
groupBy: options.groupBy,
249+
aggregations: options.aggregations?.map((a) => ({
250+
function: a.method,
251+
field: a.field,
252+
alias: a.alias,
253+
...(a.filter ? { filter: a.filter } : {}),
254+
})),
255+
timezone: options.timezone,
256+
context: options.context,
257+
} as never)) as Record<string, unknown>[],
258+
};
259+
service = new AnalyticsService(config);
260+
});
261+
262+
afterAll(async () => {
263+
await driver?.disconnect?.();
264+
});
265+
266+
async function outcome(
267+
query: AnalyticsQuery,
268+
scopeFor: Record<string, unknown>,
269+
): Promise<{ refusal?: WireBearingError; rows?: Record<string, unknown>[] }> {
270+
scopes = scopeFor;
271+
try {
272+
const result = await service.query(query);
273+
return { rows: result.rows };
274+
} catch (e) {
275+
return { refusal: e as WireBearingError };
276+
} finally {
277+
scopes = {};
278+
}
279+
}
280+
281+
/** `{ dimensionValue: count }`, order-free. */
282+
function counts(rows: Record<string, unknown>[] | undefined, dim: string): Record<string, number> {
283+
return Object.fromEntries((rows ?? []).map((r) => [String(r[dim]), Number(r.deal_count)]));
284+
}
285+
286+
describe('the direct path (`execute()` → `withReadScope`)', () => {
287+
for (const c of REFUSED_SCOPES) {
288+
it(`${c.name} (${c.face} face) → READ_SCOPE_COMPILE_FAILED / 500, prose withheld`, async () => {
289+
const { refusal, rows } = await outcome(DIRECT, { [BASE]: c.scope });
290+
expect(rows, 'a scope the engine cannot run must not be served').toBeUndefined();
291+
assertWithheldServerFault(refusal, c.secrets);
292+
});
293+
}
294+
295+
it('a well-formed caller `where` beside a refused scope → still the scope’s withheld 500', async () => {
296+
const { refusal } = await outcome(
297+
{ ...DIRECT, where: { owner: 'u_me' } } as AnalyticsQuery,
298+
{ [BASE]: { region: ['emea', 'restricted_apac'] } },
299+
);
300+
assertWithheldServerFault(refusal, ['region', 'restricted_apac']);
301+
});
302+
});
303+
304+
describe('the cross-object path', () => {
305+
it('a refused BASE scope (`executeCrossObject` → `withReadScope`) → withheld 500', async () => {
306+
const { refusal, rows } = await outcome(CROSS, { [BASE]: { region: ['emea', 'restricted_apac'] } });
307+
expect(rows).toBeUndefined();
308+
assertWithheldServerFault(refusal, ['region', 'restricted_apac']);
309+
});
310+
311+
it('a refused REFERENCED-object scope (`resolveFkAttr`) → withheld 500', async () => {
312+
const { refusal, rows } = await outcome(CROSS, { [REF]: { tier: { $eq: ['gold', 'restricted_tier'] } } });
313+
expect(rows).toBeUndefined();
314+
assertWithheldServerFault(refusal, ['tier', 'restricted_tier']);
315+
});
316+
317+
it('CONTROL: a well-formed referenced-object scope buckets what it hides as restricted', async () => {
318+
const { refusal, rows } = await outcome(CROSS, { [REF]: { tier: 'gold' } });
319+
expect(refusal).toBeUndefined();
320+
expect(counts(rows, 'account_tier')).toEqual({ gold: 2, '(restricted)': 1 });
321+
});
322+
});
323+
324+
describe('controls — what must NOT move', () => {
325+
it('a well-formed scope is served with exactly its rows', async () => {
326+
const { refusal, rows } = await outcome(DIRECT, { [BASE]: { region: { $in: ['emea', 'amer'] } } });
327+
expect(refusal).toBeUndefined();
328+
expect(counts(rows, 'region')).toEqual({ emea: 1, amer: 1 });
329+
});
330+
331+
it('a cross-field scope (#7598 Q1 = B) is still served on this face — the faces step around `{ $field }`', async () => {
332+
const { refusal, rows } = await outcome(DIRECT, { [BASE]: { amount: { $gt: { $field: 'budget' } } } });
333+
expect(refusal).toBeUndefined();
334+
expect(counts(rows, 'region')).toEqual({ emea: 1, amer: 1 });
335+
});
336+
337+
it('an emptied `$in` at even polarity beside an own-rows grant (#13570) is still served', async () => {
338+
const { refusal, rows } = await outcome(DIRECT, {
339+
[BASE]: { $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] },
340+
});
341+
expect(refusal).toBeUndefined();
342+
expect(counts(rows, 'region')).toEqual({ emea: 1, amer: 1 });
343+
});
344+
345+
it('the caller’s own `where`, refused by the ENGINE, stays INVALID_FILTER / 400 with its message', async () => {
346+
// This shape passes the analytics `where` door and is refused by the
347+
// engine's shape face — the same face the scope guard calls. A blanket
348+
// catch around `executeAggregate` would turn this 400 into a 500.
349+
const { refusal } = await outcome(
350+
{ ...DIRECT, where: { region: { $in: [null, 'emea'] } } } as AnalyticsQuery,
351+
{ [BASE]: { owner: 'u_me' } },
352+
);
353+
expect(refusal).toBeInstanceOf(Error);
354+
expect(refusal?.code).toBe('INVALID_FILTER');
355+
expect(refusal?.status).toBe(400);
356+
expect(String(refusal?.message)).toContain('region');
357+
expect(serverFaultProvenance(resolveThrownHttpError(refusal, 500))).toBeUndefined();
358+
});
359+
360+
it('the caller’s own `where` with the scope’s refused shape stays INVALID_FILTER / 400 with its message', async () => {
361+
const { refusal } = await outcome(
362+
{ ...DIRECT, where: { region: ['emea', 'apac'] } } as AnalyticsQuery,
363+
{ [BASE]: { owner: 'u_me' } },
364+
);
365+
expect(refusal).toBeInstanceOf(Error);
366+
expect(refusal?.code).toBe('INVALID_FILTER');
367+
expect(refusal?.status).toBe(400);
368+
expect(String(refusal?.message)).toContain('region');
369+
});
370+
});
371+
});

0 commit comments

Comments
 (0)