From e4908254bc488f37532381ff7247a42971a283e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 18:48:29 +0000 Subject: [PATCH 1/3] test(service-analytics): measure the ObjectQL execute face's read-scope refusal envelope Runs the ObjectQL strategy against a real ObjectQL engine over SqliteWasmDriver with hand-filled read scopes the engine's shared comparand faces refuse. Committed before the fix: on this tree every refusal case answers the engine's INVALID_FILTER / 400 (13 red), while the six controls (well-formed scope, cross-field scope, emptied $in beside an own-rows grant, the caller's own where refused by the engine and by the analytics door, a well-formed referenced-object scope) are green. Adds @objectstack/objectql as a devDependency, aliased to source in the package's vitest config so the verdict is about the checkout, not a build. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude --- .../services/service-analytics/package.json | 1 + ...jectql-read-scope-refusal-envelope.test.ts | 370 ++++++++++++++++++ .../service-analytics/vitest.config.ts | 23 +- pnpm-lock.yaml | 3 + 4 files changed, 396 insertions(+), 1 deletion(-) create mode 100644 packages/services/service-analytics/src/__tests__/objectql-read-scope-refusal-envelope.test.ts diff --git a/packages/services/service-analytics/package.json b/packages/services/service-analytics/package.json index 376df4e580b..bc315e0c2cb 100644 --- a/packages/services/service-analytics/package.json +++ b/packages/services/service-analytics/package.json @@ -31,6 +31,7 @@ "devDependencies": { "@objectstack/driver-sql": "workspace:*", "@objectstack/driver-sqlite-wasm": "workspace:*", + "@objectstack/objectql": "workspace:*", "@types/node": "^26.2.0", "@types/sql.js": "^1.4.11", "sql.js": "^1.14.1", diff --git a/packages/services/service-analytics/src/__tests__/objectql-read-scope-refusal-envelope.test.ts b/packages/services/service-analytics/src/__tests__/objectql-read-scope-refusal-envelope.test.ts new file mode 100644 index 00000000000..d497ea0254c --- /dev/null +++ b/packages/services/service-analytics/src/__tests__/objectql-read-scope-refusal-envelope.test.ts @@ -0,0 +1,370 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#19995] The ObjectQL execute face refuses a read scope the engine cannot run + * in the SAME withheld envelope as the other two analytics faces — + * `READ_SCOPE_COMPILE_FAILED` / 500 — and never as the engine's + * `INVALID_FILTER` / 400. + * + * ## The ruling this holds on the third face + * + * #5367 (re-affirmed as #7598 Q2 = A), recorded in `read-scope-sql.ts`'s + * header: a read-scope refusal is a SERVER fault, and its message — which names + * the policy's fields and comparands — goes to the operator's log, never into a + * response. The NativeSQL execute face and the `/analytics/sql` echo compile + * the scope with `compileScopedFilterToSql` and hold that. The ObjectQL execute + * face never meets that compiler: `withReadScope` ANDs the scope into the + * `where` handed to `engine.aggregate`, and a scope carrying a comparand the + * engine's shared comparand faces refuse came back as the engine's own + * `INVALID_FILTER` / 400 — a 4xx whose prose the HTTP doors relay verbatim. + * One scope, two envelopes, and the 400 one is the disclosure #5367 closed. + * + * ## Why the verdict is taken at the merge boundary, on the scope ALONE + * + * The engine's comparand-shape refusal does not read the `'policy'` provenance + * mark `withReadScope` stamps (#8220) — measured: the scope's refusal text came + * back whole. And one line after the boundary the scope is `$and`-composed with + * the caller's own filter, after which no consumer can tell whose clause a + * refusal came from. So the scope is judged by the two shared faces the engine + * itself runs on the object-form `where` (`@objectstack/spec/data`'s + * `assertListComparandShapes` and `normalizeFilterComparandTypes`) BEFORE the + * composition — same functions, so the same verdicts, and nothing the engine + * serves is refused here. + * + * ⛔ Not a catch around `executeAggregate`. The caller's own `where` still + * reaches the engine through the object-form door for some shapes, and those + * refusals are the caller's to read: `INVALID_FILTER` / 400 with the message + * kept. The controls below pin exactly that. + * + * ## What "withheld" is asserted as here + * + * Every HTTP door that answers an analytics query decides whether to relay a + * thrown message through the same two reads in `@objectstack/types` — + * `serverFaultProvenance(resolveThrownHttpError(err, 500))` and + * `declaredRefusalMessage(err)` — and relays the prose only for a 4xx or a + * declared refusal. So a refusal that declares `status: 500` with no refusal + * flag is withheld at every door by construction, and a 4xx is relayed. That is + * what each case asserts; the thrown message itself keeps the full detail on + * purpose, because the operator's log is now its only destination (the + * inventory in `read-scope-refusal-envelope.test.ts` pins the same split). + * + * ## The engine is real, because the claim is about the engine's refusal + * + * A real `ObjectQL` over `SqliteWasmDriver` (a `SqlDriver`) stands behind + * `executeAggregate`, bridged the way `AnalyticsServicePlugin`'s auto-bridge + * bridges it. A stub bridge would have made every expectation below a + * statement about the stub. Every read scope is the `getReadScope` contract + * filled by hand, never by the RLS compiler: the contract is what a host + * provider fills, and the CEL lowering is only one producer of it. + */ + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { ObjectQL } from '@objectstack/objectql'; +import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm'; +import { declaredRefusalMessage, resolveThrownHttpError, serverFaultProvenance } from '@objectstack/types'; +import type { AnalyticsQuery } from '@objectstack/spec/contracts'; +import type { FilterCondition } from '@objectstack/spec/data'; +import { DatasetSchema } from '@objectstack/spec/ui'; + +import { AnalyticsService } from '../analytics-service.js'; +import { compileDataset } from '../dataset-compiler.js'; + +const BASE = 'deal'; +const REF = 'account'; + +const BASE_FIELDS: Record> = { + id: { type: 'text', name: 'id' }, + region: { type: 'text', name: 'region' }, + owner: { type: 'text', name: 'owner' }, + account: { type: 'text', name: 'account' }, + amount: { type: 'number', name: 'amount' }, + budget: { type: 'number', name: 'budget' }, +}; +const REF_FIELDS: Record> = { + id: { type: 'text', name: 'id' }, + tier: { type: 'text', name: 'tier' }, +}; + +const BASE_ROWS = [ + { id: 'd1', region: 'emea', owner: 'u_me', account: 'acc_gold', amount: 10, budget: 5 }, + { id: 'd2', region: 'apac', owner: 'u_other', account: 'acc_silver', amount: 20, budget: 50 }, + { id: 'd3', region: 'amer', owner: 'u_me', account: 'acc_gold', amount: 30, budget: 25 }, +]; +const REF_ROWS = [ + { id: 'acc_gold', tier: 'gold' }, + { id: 'acc_silver', tier: 'silver' }, +]; + +const dataset = DatasetSchema.parse({ + name: 'deal_pipeline', + label: 'Deal pipeline', + object: BASE, + include: [REF], + dimensions: [ + { name: 'region', field: 'region', type: 'string' }, + { name: 'account_tier', field: 'account.tier', type: 'string' }, + ], + measures: [{ name: 'deal_count', aggregate: 'count' }], +}); + +/** A base-only query: `execute()`'s direct path, one `withReadScope` merge. */ +const DIRECT: AnalyticsQuery = { cube: 'deal_pipeline', dimensions: ['region'], measures: ['deal_count'] }; +/** A cross-object dimension: `executeCrossObject` + `resolveFkAttr`, two merges. */ +const CROSS: AnalyticsQuery = { cube: 'deal_pipeline', dimensions: ['account_tier'], measures: ['deal_count'] }; + +interface WireBearingError extends Error { + code?: unknown; + status?: unknown; +} + +const quiet = { + debug() {}, + info() {}, + warn() {}, + error() {}, + child() { + return quiet; + }, +}; + +/** + * Each row: a scope shape one of the two shared comparand faces refuses, and + * the policy content its refusal text names. `face` records which of the two + * the row exercises, so a later edit dropping one face's call shows up as a + * block of red rows rather than a single one. + */ +const REFUSED_SCOPES: Array<{ name: string; face: 'shape' | 'type'; scope: unknown; secrets: string[] }> = [ + { + name: 'a list in the implicit equality slot', + face: 'shape', + scope: { region: ['emea', 'restricted_apac'] }, + secrets: ['region', 'restricted_apac'], + }, + { + name: 'a list under $eq', + face: 'shape', + scope: { region: { $eq: ['emea', 'restricted_apac'] } }, + secrets: ['region', 'restricted_apac'], + }, + { + name: 'a scalar under $in', + face: 'shape', + scope: { region: { $in: 'restricted_emea' } }, + secrets: ['region', 'restricted_emea'], + }, + { + name: 'a scalar under $nin', + face: 'shape', + scope: { region: { $nin: 'restricted_emea' } }, + secrets: ['region', 'restricted_emea'], + }, + { + name: 'a one-bound $between', + face: 'shape', + scope: { amount: { $between: [424242] } }, + secrets: ['amount', '424242'], + }, + { + name: 'a null member in $in', + face: 'shape', + scope: { region: { $in: [null, 'restricted_emea'] } }, + secrets: ['region'], + }, + { + name: 'a list shape nested in an $or beside a well-formed arm', + face: 'shape', + scope: { $or: [{ owner: 'u_me' }, { region: ['emea', 'restricted_apac'] }] }, + secrets: ['region', 'restricted_apac'], + }, + { + name: 'a plain-object member in $in', + face: 'type', + scope: { region: { $in: [{ restricted_key: 1 }] } }, + secrets: ['region', 'restricted_key'], + }, + { + name: 'a plain-object comparand under $eq', + face: 'type', + scope: { region: { $eq: { restricted_key: 1 } } }, + secrets: ['region', 'restricted_key'], + }, + { + name: 'an undefined comparand', + face: 'type', + scope: { region: { $eq: undefined } }, + secrets: ['region'], + }, +]; + +function assertWithheldServerFault(err: WireBearingError | undefined, secrets: string[]): void { + expect(err, 'the scope must be refused').toBeInstanceOf(Error); + expect(err?.code).toBe('READ_SCOPE_COMPILE_FAILED'); + expect(err?.status).toBe(500); + // The reads every analytics HTTP door takes before relaying prose: a + // producer-declared 5xx that is not a declared refusal ⇒ withheld. + expect(serverFaultProvenance(resolveThrownHttpError(err, 500))).toBe('declared'); + expect(declaredRefusalMessage(err)).toBeUndefined(); + // …and the detail is RELOCATED, not deleted: the operator's log still has it. + for (const secret of secrets) expect(String(err?.message)).toContain(secret); +} + +describe('[#19995] ObjectQL execute face — a read scope the engine refuses is a withheld server fault', () => { + let driver: SqliteWasmDriver; + let engine: ObjectQL; + let service: AnalyticsService; + /** Swapped per case; the `getReadScope` contract filled by hand. */ + let scopes: Record = {}; + + beforeAll(async () => { + driver = new SqliteWasmDriver({ filename: ':memory:' }); + (driver as unknown as { logger: unknown }).logger = quiet; + await driver.initObjects([ + { name: BASE, fields: BASE_FIELDS }, + { name: REF, fields: REF_FIELDS }, + ] as never); + for (const row of BASE_ROWS) await driver.create(BASE, { ...row }); + for (const row of REF_ROWS) await driver.create(REF, { ...row }); + + engine = new ObjectQL({ logger: quiet }); + engine.registerDriver(driver as never, true); + await engine.init(); + engine.registerObject({ name: BASE, label: 'Deal', fields: BASE_FIELDS } as never); + engine.registerObject({ name: REF, label: 'Account', fields: REF_FIELDS } as never); + + const compiled = compileDataset(dataset); + service = new AnalyticsService({ + cubes: [compiled.cube], + logger: quiet, + // ObjectQL only — the face `compileScopedFilterToSql` never sees. + queryCapabilities: () => ({ nativeSql: false, objectqlAggregate: true, inMemory: false }), + getAllowedRelationships: () => compiled.allowedRelationships, + getReadScope: (object: string) => (scopes[object] ?? undefined) as FilterCondition | undefined, + // The auto-bridge's own mapping (`plugin.ts`): `filter` → `where`, + // `method` → `function`. The filter travels verbatim, so what answers is + // the engine's lowering of it. + executeAggregate: async (objectName, options) => + (await engine.aggregate(objectName, { + where: options.filter, + groupBy: options.groupBy, + aggregations: options.aggregations?.map((a) => ({ + function: a.method, + field: a.field, + alias: a.alias, + ...(a.filter ? { filter: a.filter } : {}), + })), + timezone: options.timezone, + context: options.context, + } as never)) as Record[], + } as never); + }); + + afterAll(async () => { + await driver?.disconnect?.(); + }); + + async function outcome( + query: AnalyticsQuery, + scopeFor: Record, + ): Promise<{ refusal?: WireBearingError; rows?: Record[] }> { + scopes = scopeFor; + try { + const result = await service.query(query); + return { rows: result.rows }; + } catch (e) { + return { refusal: e as WireBearingError }; + } finally { + scopes = {}; + } + } + + /** `{ dimensionValue: count }`, order-free. */ + function counts(rows: Record[] | undefined, dim: string): Record { + return Object.fromEntries((rows ?? []).map((r) => [String(r[dim]), Number(r.deal_count)])); + } + + describe('the direct path (`execute()` → `withReadScope`)', () => { + for (const c of REFUSED_SCOPES) { + it(`${c.name} (${c.face} face) → READ_SCOPE_COMPILE_FAILED / 500, prose withheld`, async () => { + const { refusal, rows } = await outcome(DIRECT, { [BASE]: c.scope }); + expect(rows, 'a scope the engine cannot run must not be served').toBeUndefined(); + assertWithheldServerFault(refusal, c.secrets); + }); + } + + it('a well-formed caller `where` beside a refused scope → still the scope’s withheld 500', async () => { + const { refusal } = await outcome( + { ...DIRECT, where: { owner: 'u_me' } } as AnalyticsQuery, + { [BASE]: { region: ['emea', 'restricted_apac'] } }, + ); + assertWithheldServerFault(refusal, ['region', 'restricted_apac']); + }); + }); + + describe('the cross-object path', () => { + it('a refused BASE scope (`executeCrossObject` → `withReadScope`) → withheld 500', async () => { + const { refusal, rows } = await outcome(CROSS, { [BASE]: { region: ['emea', 'restricted_apac'] } }); + expect(rows).toBeUndefined(); + assertWithheldServerFault(refusal, ['region', 'restricted_apac']); + }); + + it('a refused REFERENCED-object scope (`resolveFkAttr`) → withheld 500', async () => { + const { refusal, rows } = await outcome(CROSS, { [REF]: { tier: { $eq: ['gold', 'restricted_tier'] } } }); + expect(rows).toBeUndefined(); + assertWithheldServerFault(refusal, ['tier', 'restricted_tier']); + }); + + it('CONTROL: a well-formed referenced-object scope buckets what it hides as restricted', async () => { + const { refusal, rows } = await outcome(CROSS, { [REF]: { tier: 'gold' } }); + expect(refusal).toBeUndefined(); + expect(counts(rows, 'account_tier')).toEqual({ gold: 2, '(restricted)': 1 }); + }); + }); + + describe('controls — what must NOT move', () => { + it('a well-formed scope is served with exactly its rows', async () => { + const { refusal, rows } = await outcome(DIRECT, { [BASE]: { region: { $in: ['emea', 'amer'] } } }); + expect(refusal).toBeUndefined(); + expect(counts(rows, 'region')).toEqual({ emea: 1, amer: 1 }); + }); + + it('a cross-field scope (#7598 Q1 = B) is still served on this face — the faces step around `{ $field }`', async () => { + const { refusal, rows } = await outcome(DIRECT, { [BASE]: { amount: { $gt: { $field: 'budget' } } } }); + expect(refusal).toBeUndefined(); + expect(counts(rows, 'region')).toEqual({ emea: 1, amer: 1 }); + }); + + it('an emptied `$in` at even polarity beside an own-rows grant (#13570) is still served', async () => { + const { refusal, rows } = await outcome(DIRECT, { + [BASE]: { $or: [{ owner: { $in: [] } }, { owner: 'u_me' }] }, + }); + expect(refusal).toBeUndefined(); + expect(counts(rows, 'region')).toEqual({ emea: 1, amer: 1 }); + }); + + it('the caller’s own `where`, refused by the ENGINE, stays INVALID_FILTER / 400 with its message', async () => { + // This shape passes the analytics `where` door and is refused by the + // engine's shape face — the same face the scope guard calls. A blanket + // catch around `executeAggregate` would turn this 400 into a 500. + const { refusal } = await outcome( + { ...DIRECT, where: { region: { $in: [null, 'emea'] } } } as AnalyticsQuery, + { [BASE]: { owner: 'u_me' } }, + ); + expect(refusal).toBeInstanceOf(Error); + expect(refusal?.code).toBe('INVALID_FILTER'); + expect(refusal?.status).toBe(400); + expect(String(refusal?.message)).toContain('region'); + expect(serverFaultProvenance(resolveThrownHttpError(refusal, 500))).toBeUndefined(); + }); + + it('the caller’s own `where` with the scope’s refused shape stays INVALID_FILTER / 400 with its message', async () => { + const { refusal } = await outcome( + { ...DIRECT, where: { region: ['emea', 'apac'] } } as AnalyticsQuery, + { [BASE]: { owner: 'u_me' } }, + ); + expect(refusal).toBeInstanceOf(Error); + expect(refusal?.code).toBe('INVALID_FILTER'); + expect(refusal?.status).toBe(400); + expect(String(refusal?.message)).toContain('region'); + }); + }); +}); diff --git a/packages/services/service-analytics/vitest.config.ts b/packages/services/service-analytics/vitest.config.ts index 5e0591efc44..8c97122c185 100644 --- a/packages/services/service-analytics/vitest.config.ts +++ b/packages/services/service-analytics/vitest.config.ts @@ -1,10 +1,11 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. -// This config exists for exactly one setting; everything else stays on +// This config exists for exactly two settings; everything else stays on // vitest's defaults, deliberately — a key added here re-specifies behaviour // for every test file in the package (packages/cli/vitest.config.ts's header // records the incident that taught that). import { defineConfig } from 'vitest/config'; +import path from 'node:path'; export default defineConfig({ test: { @@ -16,4 +17,24 @@ export default defineConfig({ // Enforced repo-wide by scripts/check-console-intercept-disarm.mjs. disableConsoleIntercept: true, }, + resolve: { + alias: [ + { + // [#19995] `objectql-read-scope-refusal-envelope.test.ts` runs the + // strategy against a REAL `ObjectQL` engine, because the refusal it + // pins is the engine's own. Unaliased, the workspace link resolves the + // engine to `dist/`, and the verdict becomes a function of build state: + // a `dist` merely BEHIND would answer with the engine's old comparand + // doors. `pnpm check:test-source-alias` is the gate; aliasing is its + // intended repair (never a wider ledger entry). + // + // ANCHORED regex, array form: a bare string `find` matches by PREFIX, + // so a FILE replacement would also swallow the published `/core` + // subpath and resolve it to `…/objectql/src/index.ts/core` — `ENOTDIR` + // at run time, from a config that reads as correct. + find: /^@objectstack\/objectql$/, + replacement: path.resolve(__dirname, '../../objectql/src/index.ts'), + }, + ], + }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6cd22a12977..67ac19f8d70 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2394,6 +2394,9 @@ importers: '@objectstack/driver-sqlite-wasm': specifier: workspace:* version: link:../../drivers/driver-sqlite-wasm + '@objectstack/objectql': + specifier: workspace:* + version: link:../../objectql '@types/node': specifier: ^26.2.0 version: 26.2.0 From 920ee2645afbe54518b303fa065ed9bea5ece624 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 18:54:38 +0000 Subject: [PATCH 2/3] fix(service-analytics): the ObjectQL execute face refuses an unrunnable read scope in the withheld 500 envelope A read scope carrying a comparand the engine's shared comparand faces refuse reached engine.aggregate composed with the caller's filter and came back as the engine's INVALID_FILTER / 400, whose message the HTTP doors relay. The NativeSQL face and the echo refuse the same scope as READ_SCOPE_COMPILE_FAILED / 500 with the message withheld. assertReadScopeComparandsRunnable (read-scope-sql.ts, next to the vacancy guard) runs the engine's own two faces, assertListComparandShapes and normalizeFilterComparandTypes from @objectstack/spec/data, on the scope alone and re-raises any refusal in the module's one envelope. It is called at both engine-bound merges: withReadScope (direct and cross-object base) and resolveFkAttr (the referenced object's scope). The caller's own where is not judged here and keeps its 400. Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude --- .../19995-objectql-read-scope-envelope.md | 13 ++++ .../service-analytics/src/read-scope-sql.ts | 74 +++++++++++++++++++ .../src/strategies/objectql-strategy.ts | 18 ++++- 3 files changed, 104 insertions(+), 1 deletion(-) create mode 100644 .changeset/19995-objectql-read-scope-envelope.md diff --git a/.changeset/19995-objectql-read-scope-envelope.md b/.changeset/19995-objectql-read-scope-envelope.md new file mode 100644 index 00000000000..fb4f355ed73 --- /dev/null +++ b/.changeset/19995-objectql-read-scope-envelope.md @@ -0,0 +1,13 @@ +--- +'@objectstack/service-analytics': patch +--- + +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) + +Clause-②: no + +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. + +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. + +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. diff --git a/packages/services/service-analytics/src/read-scope-sql.ts b/packages/services/service-analytics/src/read-scope-sql.ts index 2bdfd8b8603..35184098df9 100644 --- a/packages/services/service-analytics/src/read-scope-sql.ts +++ b/packages/services/service-analytics/src/read-scope-sql.ts @@ -1,6 +1,9 @@ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. import type { FilterCondition } from '@objectstack/spec/data'; +// [#19995] The engine's own shared comparand faces — run on a read scope, alone, +// at the ObjectQL merge sites by {@link assertReadScopeComparandsRunnable}. +import { assertListComparandShapes, normalizeFilterComparandTypes } from '@objectstack/spec/data'; import type { RegisteredErrorCode } from '@objectstack/spec/api'; import { type LikeShape } from './like-pattern.js'; import { textMatchPredicateSql, normalizeSqlDialect } from './text-match-sql.js'; @@ -414,6 +417,20 @@ import { * already refused by the bare-array arm. {@link assertNoListInEqualitySlot} * refuses it in this module's envelope. See there for the measured answers, the * reachability reading, and why `$ne` is not judged here. + * + * ## The ObjectQL ENGINE path refuses a bad comparand in THIS envelope too (#19995) + * + * The #13640 section above is the vacancy half of the engine path; this is the + * comparand half. A scope carrying a comparand the ENGINE's shared comparand + * faces refuse was handed to `engine.aggregate` and came back as the engine's + * `INVALID_FILTER` / 400, a 4xx whose prose the HTTP doors relay — while the + * NativeSQL face and the echo refused the same scope in the withheld envelope + * above. One scope, two envelopes, and the 400 one is the disclosure #5367 + * closed. The engine's refusal does not read the `'policy'` provenance mark + * (#8220); only `driver-sql`'s cross-field and bind refusals do. + * {@link assertReadScopeComparandsRunnable} closes it at the two engine-bound + * merge sites. See there for why its refusal set is exactly the engine's, and + * for what it deliberately leaves to the engine. */ const IDENT = /^[a-z_][a-z0-9_]*$/i; @@ -647,6 +664,63 @@ export function assertReadScopeCannotVacate(scope: unknown, objectName: string): ); } +/** + * [#19995] Refuse, in this module's envelope, a read scope the ENGINE would + * refuse for one of its comparands — judged on the scope ALONE, before it is + * composed with the caller's filter. + * + * The door for the two ENGINE-bound merges: `ObjectQLStrategy.withReadScope` + * (the direct and the cross-object base aggregate) and `resolveFkAttr` (the + * referenced object's scope). There the scope used to reach `engine.aggregate` + * unjudged, and a comparand the engine refuses came back as its + * `INVALID_FILTER` / 400, message relayed. At the merge site the scope is + * still a distinguishable object; one line later it is `$and`-composed with + * the caller's own filter and no consumer can tell whose clause a refusal + * came from — which is why this is a judgement here and ⛔ never a catch + * around `executeAggregate`: the caller's own `where` still reaches the + * engine's doors for some shapes, and those refusals are the caller's to read. + * + * ## Why the refusal set is exactly the engine's + * + * The two faces called below are the ones the engine runs on every + * object-form `where` (`lowerWhereFilterArray`, `@objectstack/objectql`): + * `@objectstack/spec/data`'s list-shape face and comparand-type face. Both are + * pure walks whose verdict on a subtree does not depend on the rest of the + * tree or on any object's schema, so the scope alone answers exactly as the + * scope inside `{ $and: [userFilter, scope] }` does. Same functions, same + * verdicts: nothing the engine serves is refused here, and a `{ $field }` + * reference (served on this path under #7598 Q1 = B) is stepped around by + * both, as the engine steps around it. + * + * ## What it deliberately does not judge + * + * The engine refuses other scope shapes through doors that read the object's + * SCHEMA or the request's CONTEXT (text operators on non-text fields, temporal + * comparands, filter placeholders), and `driver-sql` refuses more at compile + * time. Judging those here would mean a second copy of rules this package + * cannot see; their envelope is the engine's and the driver's to give. + * + * Anything the two walks throw is attributable to the scope — they read + * nothing else — so every throw is re-raised in the one envelope, the walk's + * own sentence kept for the operator's log. + * + * @param scope the `StrategyContext.getReadScope` output, exactly as returned + * @param objectName the object the scope was requested for — for the operator's + * log only; withheld from the response by the `READ_SCOPE_COMPILE_FAILED` / + * 500 declaration, like every message in this module. + */ +export function assertReadScopeComparandsRunnable(scope: unknown, objectName: string): void { + try { + assertListComparandShapes(scope, undefined, 'readScope'); + normalizeFilterComparandTypes(scope, undefined, 'readScope'); + } catch (e) { + throw readScopeCompileError( + `[read-scope-sql] read scope for "${objectName}" carries a comparand the engine refuses — ` + + `${e instanceof Error ? e.message : String(e)} (fail-closed).`, + ); + } +} + /** * Compile a child node into its OWN bind buffer. * diff --git a/packages/services/service-analytics/src/strategies/objectql-strategy.ts b/packages/services/service-analytics/src/strategies/objectql-strategy.ts index 37bb0510f17..b3d329cead7 100644 --- a/packages/services/service-analytics/src/strategies/objectql-strategy.ts +++ b/packages/services/service-analytics/src/strategies/objectql-strategy.ts @@ -16,7 +16,11 @@ import { type NormalizedFilterNode, } from './filter-normalizer.js'; import { findCrossFieldComparand, isFieldReference } from '../comparand-shape.js'; -import { assertReadScopeCannotVacate, compileScopedFilterToSql } from '../read-scope-sql.js'; +import { + assertReadScopeCannotVacate, + assertReadScopeComparandsRunnable, + compileScopedFilterToSql, +} from '../read-scope-sql.js'; import { nonTextColumnResolver, textOperatorPolarity } from '../non-text-column.js'; import { invalidMemberError } from '../dataset-refusal.js'; import { type LikeShape } from '../like-pattern.js'; @@ -650,6 +654,14 @@ export class ObjectQLStrategy implements AnalyticsStrategy { // the same disposition from `compileScopedFilterToSql` itself (#13571); // this is the same ruling at the door that compiler never sees. assertReadScopeCannotVacate(scope, objectName); + // [#19995] …and the comparand half of the same door. A scope carrying a + // comparand the engine's shared faces refuse came back as the engine's + // `INVALID_FILTER` / 400 — a 4xx the HTTP doors relay verbatim, naming the + // policy's field and comparand, where NativeSQL and the echo refuse the + // same scope in the withheld `READ_SCOPE_COMPILE_FAILED` / 500 (#5367). + // Judged on the scope ALONE, for the attribution reason above; the + // caller's own `where` keeps reaching the engine's doors and its 400. + assertReadScopeComparandsRunnable(scope, objectName); const scopeFilter = markFilterSubtreeProvenance(scope as Record, 'policy'); if (!userFilter) return scopeFilter; return { $and: [userFilter, scopeFilter] }; @@ -1133,6 +1145,10 @@ export class ObjectQLStrategy implements AnalyticsStrategy { // landing in the RESTRICTED bucket. Guarded before the mark, so a refused // scope is never stamped as vouched-for policy content. if (scope != null) assertReadScopeCannotVacate(scope, refObject); + // [#19995] …and the comparand half, as at `withReadScope`: a comparand the + // engine refuses would come back as its relayed 400, naming the referenced + // object's policy. Before the mark, for the same reason as the line above. + if (scope != null) assertReadScopeComparandsRunnable(scope, refObject); if (scope != null) markFilterSubtreeProvenance(scope, 'policy'); const filter = scope != null ? { $and: [idFilter, scope] } : idFilter; const rows = await ctx.executeAggregate(refObject, { From 9a40317b15ed67c01ea5d7eaca0a8fb9afdfb938 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 24 Sep 2026 18:56:35 +0000 Subject: [PATCH 3/3] test(service-analytics): type the envelope test's service config instead of casting it away Claude-Session: https://claude.ai/code/session_01Evb5jFDZGKQE9KG4jbMfMF Co-authored-by: Claude --- .../__tests__/objectql-read-scope-refusal-envelope.test.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/services/service-analytics/src/__tests__/objectql-read-scope-refusal-envelope.test.ts b/packages/services/service-analytics/src/__tests__/objectql-read-scope-refusal-envelope.test.ts index d497ea0254c..680f792d57f 100644 --- a/packages/services/service-analytics/src/__tests__/objectql-read-scope-refusal-envelope.test.ts +++ b/packages/services/service-analytics/src/__tests__/objectql-read-scope-refusal-envelope.test.ts @@ -66,7 +66,7 @@ import type { AnalyticsQuery } from '@objectstack/spec/contracts'; import type { FilterCondition } from '@objectstack/spec/data'; import { DatasetSchema } from '@objectstack/spec/ui'; -import { AnalyticsService } from '../analytics-service.js'; +import { AnalyticsService, type AnalyticsServiceConfig } from '../analytics-service.js'; import { compileDataset } from '../dataset-compiler.js'; const BASE = 'deal'; @@ -232,7 +232,7 @@ describe('[#19995] ObjectQL execute face — a read scope the engine refuses is engine.registerObject({ name: REF, label: 'Account', fields: REF_FIELDS } as never); const compiled = compileDataset(dataset); - service = new AnalyticsService({ + const config: AnalyticsServiceConfig = { cubes: [compiled.cube], logger: quiet, // ObjectQL only — the face `compileScopedFilterToSql` never sees. @@ -255,7 +255,8 @@ describe('[#19995] ObjectQL execute face — a read scope the engine refuses is timezone: options.timezone, context: options.context, } as never)) as Record[], - } as never); + }; + service = new AnalyticsService(config); }); afterAll(async () => {