Skip to content

Commit 5d0e4e2

Browse files
fix(metadata-protocol)!: one stored-metadata filter collector and search narrowing, owned by the door and called by the reader seam; cross-field and deep family reads refused (#21619)
Fixes #21544 Clause-②: yes (narrowing) The generic data door now owns the stored-metadata family's one filter-field collector and its one default-search narrowing. Both are exported module functions, and the door and the in-process reader-context seam (`@objectstack/runtime`) call the same two. The card's measure-first step found a door gap: two filter shapes read a family column at the generic data door without the family's refusal ever seeing them. Per the ruling, the door therefore adopts the stricter collector in this landing, and under the card's raise rule the card is p1. Measurement below. ## The door-gap measurement (measured before any collector was chosen) Measured at `b610eabf72` with a composed kernel (ObjectQL, a default datasource, HTTP server, platform objects, auth, security, sharing, REST and the dispatcher) and the administrator signed in. The family credential was stored by the production writer (`PUT /meta/datasource/NAME`). Every request went through three doors: `POST /api/v1/data/OBJECT/query` (HTTP), the in-process door (`protocol.findData`) and the seam (`serveStoredMetadataReadsThrough` over the engine). Both family tables were covered, on `driver-sqlite-wasm` and on `driver-memory`. | shape at the door | request (body of `POST /api/v1/data/sys_metadata/query`) | column read | answer before this PR | |---|---|---|---| | positive control: direct reference | `{"where":{"metadata":{"$ne":"zzz"}}}` | `metadata` | 400 `INVALID_FIELD` (family refusal) | | **cross-field comparand** | `{"where":{"type":"datasource","name":{"$ne":{"$field":"metadata"}}}}` | `metadata` | **200, 1 row**; the `$eq` twin answers 0 rows; `type` `$lt` `$field metadata` answers every row and `$gt` answers none (SQL). The family column is evaluated. | | **cross-field comparand** | same, with `checksum` (and `previous_checksum` / `change_note` on `sys_metadata_history`) | the hash column / the note | **200**, partitioned the same way (SQL) | | **cross-field comparand in an aggregation filter** | `{"groupBy":["type"],"aggregations":[{"function":"count","alias":"n","filter":{"name":{"$ne":{"$field":"metadata"}}}}]}` | `metadata` | **200**, `n` = 1 (the `$eq` twin gives `n` = 0) | | **direct predicate below the depth backstop** | the body filter `{"metadata":{"$contains":"STORED_CREDENTIAL"}}` wrapped in 33 nested one-armed `$and` levels | `metadata` | **200, the row** for the stored credential, **0 rows** for a wrong guess, **the row** for a prefix. A credential oracle on both drivers, both tables, over HTTP and in-process, in `where` and in an aggregation filter. At 32 levels the same filter is refused (control). | | dotted key | `{"where":{"metadata.x":"zzz"}}` | none | 400 `INVALID_FIELD` from the door's dotted-path verdict (`param: where`). Moot: never evaluated. | | `having` | `having` naming `metadata` / `checksum`, or `{ "$field": … }` to one | none | 400 `INVALID_FILTER`: the engine judges every `having` name against the aggregated row's columns. Grouping by a family column is refused, and `min` / `max` of the `textarea` / `text` family columns is refused by the engine's aggregate-field-type door. Moot. | The two bold shapes are the door gap. The cross-field one is a SQL-driver reach. On `driver-memory` the reference is not resolved at all (see Acceptance notes). The depth one is a reach on both drivers. ## What this changes ### Public surface: `@objectstack/metadata-protocol` (additive, `minor`) - `collectStoredMetadataFilterFields(object, query): string[]` is the family's one filter-field collector. It returns every column a read query's filters read, across `where`, the engine's `filter` alias and each `aggregations[i].filter`. That means each key's head plus each cross-field `{ $field }` comparand's head, at any depth: the walk is iterative and cycle-safe, with no depth backstop. Anything beneath an unrecognised `$` key is read as a condition, and a `FilterArray` is lowered first. It is `[]` outside the family. It does **not** read `having`, whose names are the aggregated row's. - `narrowStoredMetadataSearch(object, query, schema, wireSpelling?): string[] | undefined` is the family's one default-search narrowing. It moved, unchanged in its answers, from the door's private method of the same name: - an explicit list naming the body or a hash column is refused under the caller's wire spelling; - a default search returns the narrowed field set for the caller to run as `searchFields`; - an emptied set is refused; - `undefined` means "run as is". - `type StoredMetadataSearchSchema` is the definition slice the narrowing reads. ### Accept-set changes: the generic data door, `sys_metadata` / `sys_metadata_history` only This applies to `GET /api/v1/data/:object`, `POST /api/v1/data/:object/query` and in-process `findData`. 1. A cross-field comparand naming the body, `checksum`, `previous_checksum` or `change_note` changes from **200 to 400 `INVALID_FIELD`**, with `param: filter` and `field` set to the column, before the engine is asked. This covers `where`, `$not` and an aggregation filter. 2. A family-column predicate, key or comparand, nested more than 32 combinators deep changes from **200 to 400 `INVALID_FIELD`**, in the same envelope. 3. Some shapes were refused before and stay refused, with a different refusal, in-process only. An unrecognised `$` key wrapping a family column, an array-form aggregation filter naming one, and a malformed `$field` reference to one were the engine's `INVALID_FILTER`. They now get the family's `INVALID_FIELD`. Over HTTP, the array-form aggregation filter is still refused earlier by REST validation. Unchanged: - **Every other object.** The collector answers `[]` outside the family, so the door collects nothing there. - **Dotted keys.** The dotted-path verdict still answers first. - **Every search answer** (explicit list, default narrowing, emptied set). These are pinned identical at the door and the seam. ### `@objectstack/runtime` (`patch`) The seam (`stored-metadata-reader-seam.ts`) changes as follows: - `narrowFamilySearch` is **deleted**, along with the `filterHeadFields` wrapper and the `@objectstack/plugin-security` `collectConditionFields` import. The seam now calls the door's two exports. - `count` runs the query the guard returns (H2). The seam's accept set is unchanged: everything it refused before it still refuses. Two seam refusals change code, from the engine's `INVALID_FILTER` to the family's `INVALID_FIELD`: an unrecognised `$` key wrapping a family column, and a malformed `$field` reference to one. ## Readings on the dispatch's hypotheses - **H1 (dotted / cross-field), confirmed in part.** The dotted half is moot at the door: the dotted-path verdict refuses it. The cross-field half is a measured gap. The depth backstop was a second gap the measurement found. - **H2 (`count` discards the guard's return), confirmed and corrected.** `count` now consumes the guard's return. The pin: a default search through `count` arrives narrowed. - **H3 (`having`), moot.** See the table. The collector deliberately does not read `having`: an aggregation alias spelled like a family column is a legitimate count, and a parity control pins it as run. - **H4 (the door's own answers unchanged), confirmed.** - The existing door suites pass unchanged: `#21207` search, hash and note, `#21120` body. - The parity table pins explicit list, default search and emptied set identical at the door and the seam. - **H5 (other `collectConditionFields` readers).** After this PR its one reader is `@objectstack/plugin-security`'s own FLS predicate guard (`collectQueryFields` / `assertReadableQueryFields`). That guard does not judge the family. `@objectstack/runtime` still depends on `@objectstack/plugin-security` through `security/resolve-execution-context.ts`. No `plugin-security` edit. ## Tests - New in `packages/metadata-protocol/src/protocol.data-door-stored-metadata-filter-reads.test.ts`: - collector cases (16 shapes on both tables, plus non-family, cycle and shared-node pins); - narrowing cases; - door pins: 6 gap shapes × 5 family columns, each refused with `code` / `status` / `param` / `field` / `object` and the engine never asked; - scalar-column and non-family controls. - New in `packages/runtime/src/stored-metadata-reader-seam.test.ts`: the door / seam parity table. It is one table of 24 cases: 7 search cases (explicit list ×4, default ×2, emptied); 14 collector cases (direct, dotted key ×2, cross-field comparand ×3, dotted reference, list reference, `$not`, unknown `$` key, depth 33 ×2, aggregation filter ×2); and 3 controls. Each case runs through `findData` and through the seam, and the two outcomes must be equal. There is also a narrowed-default pin and the `count` pin. - `pnpm --filter @objectstack/metadata-protocol exec vitest run`: 208 files passed, 3 skipped; 3266 tests passed, 19 skipped (at `5513190ca5`, after merging `main`). - `pnpm --filter @objectstack/runtime exec vitest run --project local`: 318 files passed; 4514 passed, 19 skipped (at `5513190ca5`). `--project repo`: 3 files, 751 passed (at `9be38c5987`). - At the final head `0ac5749662`: - the three family door suites: 99 passed; - the seam, reader-contexts, body-writes and boundary suites: 86 passed. - `typecheck`: both packages green (at `5513190ca5`). `--listFiles` confirms both new tests are inside each package's tsc program. ## Reverse verification (both at `be99ceee6a`, through `scripts/ablation-replace.mjs`, mutation and restore proven on disk) 1. **The seam reverted to its own narrowing and collector.** The file was swapped to its base blob `27efc3412999`; on disk, `narrowFamilySearch` = 1, `collectConditionFields` = 3 and the new collector = 0. Result: **2 red** (the parity case for an unrecognised `$` key wrapping a body filter, and the `count` pin) and 41 green. Restored with `git checkout HEAD -- PATH`: blob == HEAD `51fe56bb5e73`, `git diff HEAD` empty. 2. **The door's collector reverted** to the ingress key collector expression. On disk the ablation marker = 1 and the new call = 0. Result: **exactly the 30 door-gap pins red** (6 shapes × 5 columns); the 69 others stayed green (collector and narrowing units, controls, the existing `#21207` / `#21120` door suites). Restored: blob == HEAD `fa64d2b26cd9`, `git diff HEAD` empty. Both are src-resolved: each subject is imported by relative path, so no `dist/` leg was needed. ## Gates (at `0ac5749662`) - `node scripts/pm/dispatch-gates.mjs --commands` derived 64 families; all 64 were run and exited 0. Reconciled with `--ran`: 64 run, 0 NOT-MEASURED, 0 UNRUN. - Two needed a step first: - `check:dual-build-cjs-loads` printed `PREREQUISITE NOT MET` before a full `turbo build`, then exited 0. - `check:engine-double-contract` flagged the new door doubles' `findOne`. `findData` never reads `findOne`, so the doubles carry none, and the pinned ledger is untouched. - Lint is a proven narrowing. ESLint ran with `--no-inline-config --format json` on the 6 touched TS files at `0ac5749662`: 6 files, 0 errors, 0 warnings. The checked population is read from `eslint.config.mjs` (`--print-config` per file). Type-aware linting is not enabled anywhere: no `parserOptions.project` or `projectService`, `project=null` per file. So this diff cannot move any untouched file's verdict. ## File surface - Declared: - the data door's read regions of `packages/metadata-protocol/src/protocol.ts`. The hydration region is untouched; PR #21603 was merged in from `main`. - `packages/metadata-protocol/src/index.ts` - `packages/runtime/src/stored-metadata-reader-seam.ts` - tests in both packages - `.changeset/21544-door-narrowing-export.md` - Beyond the claim's list, one sentence of comment in `packages/metadata-protocol/src/metadata-redaction.ts`. It is the `storedMetadataBodyPredicateRefusal` docblock's parenthetical, which named the old collector as the source of `filterFields` and would have been false after this change. No code. - The changeset carries `Clause-②: yes (narrowing)` and an ADR-0087 `not-required (no-migration-prescription)` disposition marker. `check:adr-0087-registration` reads it. ## Acceptance notes (observations, not filed) - **`driver-memory` does not resolve a cross-field reference in `where`.** It compares against the literal reference object, so on a non-family object `{ "name": { "$eq": { "$field": "name" } } }` answered 0 rows (SQL: every row). It was measured at `b610eabf72` through the generic data door on a memory-backed composition. Public reach is not measured after `9a4182a752` (the in-memory engine is no longer a boot store), so it is not filed. Carrier: none. - **`count` / `count_distinct` over a family column is still served,** at the door and the seam alike. It discloses equality only, which the keyed content hash already serves per row. - **The ingress gate's `collectFilterFieldKeys` keeps its 32-level backstop for the existence question.** That question is not the family's. The backstop's reach on an unknown field nested below it is an unmeasured inference. --- _Generated by [Claude Code](https://claude.ai/code/session_017ErfyP2Rx7XWHJA27QjyUi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 36ad321 commit 5d0e4e2

7 files changed

Lines changed: 690 additions & 161 deletions

File tree

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
---
2+
'@objectstack/metadata-protocol': minor
3+
'@objectstack/runtime': patch
4+
---
5+
6+
fix(metadata-protocol)!: the generic data door refuses a stored-metadata filter that reads the body or a content hash through a cross-field comparand or below its depth backstop, and exports its one filter-field collector and one search narrowing for the reader-context seam (#21544)
7+
8+
Clause-②: yes (narrowing)
9+
10+
<!-- adr-0087: not-required (no-migration-prescription) no metadata body, authorable key, spelling, export or stored shape moves; what changes is which read-query shapes the generic data door accepts over the two stored-metadata tables, and two module functions are added to the package surface, so `objectstack migrate meta` has nothing to rewrite. The other categories are closed on facts: both packages publish (not `unpublished`); no ADR-0087 id covers a refused query shape (not `registered` / `already-registered`); and the change is runtime behaviour plus additive exports, not a declaration (not `runtime-interface-only` / `type-surface-only`). -->
11+
12+
**BREAKING**: this narrows what the generic data door (`GET /api/v1/data/:object`, `POST /api/v1/data/:object/query` and the in-process `findData`) accepts when it reads `sys_metadata` or `sys_metadata_history`. Two filter shapes read the stored body column or a content-hash column (`checksum`, `previous_checksum`, or the history table's `change_note`) without the family's refusal ever seeing them, and both ran before this release:
13+
14+
- a cross-field comparand naming one of those columns — `{ "name": { "$ne": { "$field": "metadata" } } }`, in `where` or in an aggregation's `filter`, under `$not` included. The SQL drivers evaluate it row by row, so row presence disclosed the column's value;
15+
- a filter on one of those columns nested more than 32 combinators deep, which the door's field collector stopped reading at. A body `$contains` of a stored credential answered the row and a wrong guess answered none.
16+
17+
Both now answer the door's `400 INVALID_FIELD`, naming the column, before the query runs — the answer the same filter already gets when it names the column directly. The route: filter those tables by their scalar columns (the type, the name, the state and the like), compare scalar columns with each other, and read the bodies with a plain list, which is served projected. Every other column of the two tables, and every other object, is unchanged; a dotted key into one of those columns was, and stays, refused by the door's dotted-path rule. It ships as `minor` under the launch-window convention for accept-set narrowings.
18+
19+
- **`@objectstack/metadata-protocol`** exports two module functions the generic data door now calls itself:
20+
- `collectStoredMetadataFilterFields(object, query)` — the family's one filter-field collector: every column a read query's filters read (`where`, the engine's `filter` alias and each aggregation filter): each key's head and each cross-field `{ $field }` comparand, at any depth. `[]` outside the family.
21+
- `narrowStoredMetadataSearch(object, query, schema, wireSpelling?)` — the family's one default-search narrowing: an explicit search-field list naming the body or a hash column is refused, a default search is narrowed to the searchable set without them (returned for the caller to run as `searchFields`), and a set that narrows to nothing is refused. The `StoredMetadataSearchSchema` type it reads is exported beside it.
22+
- **`@objectstack/runtime`**: the stored-metadata reader-context seam (`ctx.api.object(...)` for action and hook bodies, a handler's `ctx.api`, and `ctx.engine.find`) calls those two functions instead of its own copy of the narrowing and `@objectstack/plugin-security`'s condition walk, so the seam and the door answer every family filter and search identically. A `count` through the seam now runs the query the guard returns. The seam's accept set is unchanged: every shape it refused before it still refuses, now through the door's collector.

‎packages/metadata-protocol/src/index.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,13 @@ export {
220220
storedMetadataHashEvaluateRefusal,
221221
storedMetadataSearchRefusal,
222222
} from './metadata-redaction.js';
223+
// [#21544] …and what those refusals are FED: the door's one default-search
224+
// narrowing and its one filter-field collector, module functions the door
225+
// itself calls. Exported so the reader-context seam calls the same two
226+
// functions rather than re-stating either — a second control flow over the
227+
// same columns is where the two doors would drift.
228+
export { collectStoredMetadataFilterFields, narrowStoredMetadataSearch } from './protocol.js';
229+
export type { StoredMetadataSearchSchema } from './protocol.js';
223230

224231
export type { MetadataHostEngine } from './host-engine.js';
225232

‎packages/metadata-protocol/src/metadata-redaction.ts‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -742,8 +742,9 @@ export function storedMetadataBodyGroupingRefusal(object: string, groupBy: unkno
742742
* the grouping refusal above take. Same family, shape and code: `INVALID_FIELD`
743743
* / 400, naming the field, the object and the offending `param`.
744744
*
745-
* `filterFields` is the set of head field names the caller's `where` names
746-
* (`collectFilterFieldKeys`), and `sortFields` the fields its `orderBy` names.
745+
* `filterFields` is the set of columns the caller's filters read
746+
* (`collectStoredMetadataFilterFields`, `protocol.ts`: each key's head and each
747+
* cross-field comparand's), and `sortFields` the fields its `orderBy` names.
747748
* Filter is judged before sort — a query that does both reads "the filter was
748749
* not run" first. `undefined` when neither names the body column.
749750
*/
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* [#21544] The stored-metadata family's ONE filter-field collector and ONE
5+
* default-search narrowing — the two module functions the generic data door
6+
* calls and exports, so the in-process reader-context seam
7+
* (`@objectstack/runtime`) calls the same two (the door / seam parity table
8+
* lives beside the seam, `stored-metadata-reader-seam.test.ts`).
9+
*
10+
* The door's collector used to be the ingress key collector, and the
11+
* measurement on this card found two shapes that READ a family column at the
12+
* generic data door without the family's refusal ever seeing it, on both
13+
* `sys_metadata` and `sys_metadata_history`, over HTTP and in-process alike:
14+
*
15+
* - a cross-field comparand (`{ name: { $ne: { $field: 'metadata' } } }`),
16+
* which the SQL drivers evaluate row by row — the comparison partitioned
17+
* the rows by the stored body's value;
18+
* - a direct predicate nested below the key collector's 32-level depth
19+
* backstop, which ran as written: a body `$contains` of the stored
20+
* credential answered the row and a wrong guess answered none, on
21+
* driver-sqlite-wasm and driver-memory — a credential oracle.
22+
*
23+
* Both are refused now, before the engine is asked, in the family's envelope.
24+
*/
25+
26+
import { describe, expect, it, vi } from 'vitest';
27+
import { SysMetadataHistoryObject, SysMetadataObject } from '@objectstack/metadata-core';
28+
import {
29+
collectStoredMetadataFilterFields,
30+
narrowStoredMetadataSearch,
31+
ObjectStackProtocolImplementation,
32+
} from './protocol.js';
33+
34+
/** `n` nested `$and` levels above `leaf`: the leaf sits at depth `n`. */
35+
const deep = (n: number, leaf: Record<string, unknown>): Record<string, unknown> =>
36+
(n === 0 ? leaf : { $and: [deep(n - 1, leaf)] });
37+
38+
const sorted = (names: readonly string[]) => [...names].sort();
39+
40+
describe('[#21544] collectStoredMetadataFilterFields — every column a read query\'s filters read', () => {
41+
const cases: Array<[string, unknown, string[]]> = [
42+
['a key', { where: { type: 'view' } }, ['type']],
43+
['a dotted key reads its head', { where: { 'metadata.config': 'x' } }, ['metadata']],
44+
['a cross-field comparand', { where: { name: { $eq: { $field: 'metadata' } } } }, ['metadata', 'name']],
45+
['a reference as the implicit-equality comparand', { where: { name: { $field: 'checksum' } } }, ['checksum', 'name']],
46+
['a reference in a list', { where: { name: { $in: [{ $field: 'checksum' }] } } }, ['checksum', 'name']],
47+
[
48+
'a reference in an addDays offset',
49+
{ where: { created_at: { $lte: { $field: 'created_at', addDays: { $field: 'previous_checksum' } } } } },
50+
['created_at', 'previous_checksum'],
51+
],
52+
['a dotted reference reads its head', { where: { name: { $ne: { $field: 'metadata.x' } } } }, ['metadata', 'name']],
53+
[
54+
'under $and / $or / $not',
55+
{ where: { $or: [{ $not: { name: { $lt: { $field: 'change_note' } } } }, { $and: [{ type: 'x' }] }] } },
56+
['change_note', 'name', 'type'],
57+
],
58+
['under an unrecognised $ key', { where: { $nor: [{ metadata: 'x' }] } }, ['metadata']],
59+
[
60+
'a nested-relation condition: its keys are another object\'s, a reference beneath it is read',
61+
{ where: { organization_id: { metadata: 'x', name: { $eq: { $field: 'checksum' } } } } },
62+
['checksum', 'organization_id'],
63+
],
64+
['at any depth: a key 40 levels down', { where: deep(40, { metadata: { $contains: 'z' } }) }, ['metadata']],
65+
['at any depth: a reference 40 levels down', { where: deep(40, { name: { $ne: { $field: 'checksum' } } }) }, ['checksum', 'name']],
66+
['a FilterArray is lowered first', { where: [['metadata', 'contains', 'z']] }, ['metadata']],
67+
['the engine\'s `filter` alias', { filter: { checksum: 'x' } }, ['checksum']],
68+
[
69+
'each aggregation filter',
70+
{ aggregations: [{ function: 'count', alias: 'n', filter: { name: { $ne: { $field: 'metadata' } } } }] },
71+
['metadata', 'name'],
72+
],
73+
[
74+
'not `having`: its names are the aggregated row\'s, an alias spelled like a family column included',
75+
{ groupBy: ['type'], aggregations: [{ function: 'count', alias: 'metadata' }], having: { metadata: { $gt: 0 } } },
76+
[],
77+
],
78+
];
79+
for (const [label, query, expected] of cases) {
80+
it(label, () => {
81+
expect(sorted(collectStoredMetadataFilterFields('sys_metadata', query))).toEqual(expected);
82+
expect(sorted(collectStoredMetadataFilterFields('sys_metadata_history', query))).toEqual(expected);
83+
});
84+
}
85+
86+
it('[] for an object outside the family, and for a query that is not a record', () => {
87+
expect(collectStoredMetadataFilterFields('file_blob', { where: { metadata: 'x' } })).toEqual([]);
88+
expect(collectStoredMetadataFilterFields('sys_metadata', null)).toEqual([]);
89+
expect(collectStoredMetadataFilterFields('sys_metadata', [{ metadata: 'x' }])).toEqual([]);
90+
});
91+
92+
it('terminates on a self-referential live object, and reads a node shared as a comparand and a condition both ways', () => {
93+
const cyclic: Record<string, unknown> = { type: 'x' };
94+
cyclic.$and = [cyclic];
95+
expect(collectStoredMetadataFilterFields('sys_metadata', { where: cyclic })).toEqual(['type']);
96+
const shared = { metadata: 'x' };
97+
expect(sorted(collectStoredMetadataFilterFields('sys_metadata', { where: { name: { $eq: shared }, $and: [shared] } })))
98+
.toEqual(['metadata', 'name']);
99+
});
100+
});
101+
102+
describe('[#21544] narrowStoredMetadataSearch — the one default-search narrowing', () => {
103+
function refusalOf(run: () => unknown): any {
104+
try {
105+
run();
106+
} catch (e) {
107+
return e;
108+
}
109+
throw new Error('expected a refusal, but the search was not refused');
110+
}
111+
112+
it('an explicit list naming the body or a hash column is refused, under the slot the caller used', () => {
113+
for (const [query, wire, param, field] of [
114+
[{ search: 'z', searchFields: ['name', 'metadata'] }, {}, 'searchFields', 'metadata'],
115+
[{ search: 'z', searchFields: 'name, checksum' }, {}, 'searchFields', 'checksum'],
116+
[{ search: { query: 'z', fields: ['previous_checksum'] } }, {}, 'search', 'previous_checksum'],
117+
[{ search: 'z', searchFields: 'change_note' }, { searchFields: '$searchFields' }, '$searchFields', 'change_note'],
118+
] as const) {
119+
const err = refusalOf(() => narrowStoredMetadataSearch('sys_metadata_history', query, SysMetadataHistoryObject, wire));
120+
expect(err).toMatchObject({ code: 'INVALID_FIELD', status: 400, param, field, object: 'sys_metadata_history' });
121+
}
122+
});
123+
124+
it('an explicit list that names neither runs as it is', () => {
125+
expect(narrowStoredMetadataSearch('sys_metadata', { search: 'z', searchFields: ['name'] }, SysMetadataObject)).toBeUndefined();
126+
});
127+
128+
it('a default search is narrowed to the searchable set without the body and hash columns', () => {
129+
for (const [object, schema, unscannable] of [
130+
['sys_metadata', SysMetadataObject, ['metadata', 'checksum']],
131+
['sys_metadata_history', SysMetadataHistoryObject, ['metadata', 'checksum', 'previous_checksum', 'change_note']],
132+
] as const) {
133+
const narrowed = narrowStoredMetadataSearch(object, { search: 'z' }, schema);
134+
expect(narrowed).toContain('name');
135+
for (const column of unscannable) expect(narrowed).not.toContain(column);
136+
}
137+
});
138+
139+
it('a default search whose set narrows to nothing is refused, never handed on empty', () => {
140+
const onlyFamily = { ...SysMetadataObject, searchableFields: ['metadata', 'checksum'] };
141+
const err = refusalOf(() => narrowStoredMetadataSearch('sys_metadata', { search: 'z' }, onlyFamily, { search: '$search' }));
142+
expect(err).toMatchObject({ code: 'INVALID_FIELD', status: 400, param: '$search', field: 'metadata' });
143+
});
144+
145+
it('runs as it is outside the family, without a search, and without a readable field map', () => {
146+
expect(narrowStoredMetadataSearch('file_blob', { search: 'z', searchFields: ['metadata'] }, SysMetadataObject)).toBeUndefined();
147+
expect(narrowStoredMetadataSearch('sys_metadata', { where: { type: 'view' } }, SysMetadataObject)).toBeUndefined();
148+
for (const schema of [undefined, {}, { fields: [] }, { fields: {} }]) {
149+
expect(narrowStoredMetadataSearch('sys_metadata', { search: 'z' }, schema)).toBeUndefined();
150+
}
151+
});
152+
});
153+
154+
describe('[#21544] data door — a filter that reads the body or a hash without naming it as a key is refused', () => {
155+
const SCHEMAS: Record<string, unknown> = {
156+
sys_metadata: SysMetadataObject,
157+
sys_metadata_history: SysMetadataHistoryObject,
158+
file_blob: { name: 'file_blob', fields: { name: { name: 'name', type: 'text' }, checksum: { name: 'checksum', type: 'text' } } },
159+
};
160+
161+
function makeProtocol() {
162+
const find = vi.fn(async () => []);
163+
const aggregate = vi.fn(async () => []);
164+
const count = vi.fn(async () => 0);
165+
// `findData` reads through find / count / aggregate only — no `findOne`
166+
// on this double, so a door that started calling it would fail loudly.
167+
const engine: any = { registry: { getObject: (n: string) => SCHEMAS[n] }, find, count, aggregate };
168+
return { p: new ObjectStackProtocolImplementation(engine), find, aggregate, count };
169+
}
170+
171+
async function refusal(run: () => Promise<unknown>): Promise<any> {
172+
try {
173+
await run();
174+
} catch (e) {
175+
return e;
176+
}
177+
throw new Error('expected a refusal, but the query ran');
178+
}
179+
180+
const shapes: Array<[string, string, (column: string) => Record<string, unknown>]> = [
181+
['a cross-field comparand in `where`', 'filter', (c) => ({ where: { name: { $ne: { $field: c } } } })],
182+
['a cross-field comparand under $not', 'filter', (c) => ({ where: { $not: { type: { $lt: { $field: c } } } } })],
183+
['a direct predicate 33 levels deep', 'filter', (c) => ({ where: deep(33, { [c]: { $contains: 'guess' } }) })],
184+
['a cross-field comparand 33 levels deep', 'filter', (c) => ({ where: deep(33, { name: { $eq: { $field: c } } }) })],
185+
[
186+
'a cross-field comparand in an aggregation filter',
187+
'filter',
188+
(c) => ({ groupBy: ['type'], aggregations: [{ function: 'count', alias: 'n', filter: { name: { $ne: { $field: c } } } }] }),
189+
],
190+
[
191+
'a direct predicate 33 levels deep in an aggregation filter',
192+
'filter',
193+
(c) => ({ groupBy: ['type'], aggregations: [{ function: 'count', alias: 'n', filter: deep(33, { [c]: { $contains: 'guess' } }) }] }),
194+
],
195+
];
196+
const columns: Array<[string, string]> = [
197+
['sys_metadata', 'metadata'],
198+
['sys_metadata', 'checksum'],
199+
['sys_metadata_history', 'metadata'],
200+
['sys_metadata_history', 'previous_checksum'],
201+
['sys_metadata_history', 'change_note'],
202+
];
203+
for (const [label, param, build] of shapes) {
204+
for (const [object, column] of columns) {
205+
it(`${label}, reading '${column}' on '${object}': INVALID_FIELD / 400, and the engine is never asked`, async () => {
206+
const { p, find, aggregate, count } = makeProtocol();
207+
const err = await refusal(() => p.findData({ object, query: build(column) }));
208+
expect(err).toMatchObject({ code: 'INVALID_FIELD', status: 400, param, field: column, object });
209+
expect(find).not.toHaveBeenCalled();
210+
expect(aggregate).not.toHaveBeenCalled();
211+
expect(count).not.toHaveBeenCalled();
212+
});
213+
}
214+
}
215+
216+
it('control: the same shapes over scalar columns of a family table still run', async () => {
217+
const { p, find, aggregate } = makeProtocol();
218+
await p.findData({ object: 'sys_metadata', query: { where: { name: { $ne: { $field: 'type' } } } } });
219+
await p.findData({ object: 'sys_metadata', query: { where: deep(33, { type: 'view' }) } });
220+
expect(find).toHaveBeenCalledTimes(2);
221+
await p.findData({
222+
object: 'sys_metadata',
223+
query: { groupBy: ['type'], aggregations: [{ function: 'count', alias: 'n', filter: { name: { $ne: { $field: 'type' } } } }] },
224+
});
225+
expect(aggregate).toHaveBeenCalledTimes(1);
226+
});
227+
228+
it('control: an object outside the family reads its own `checksum` through a comparand and at depth', async () => {
229+
const { p, find } = makeProtocol();
230+
await p.findData({ object: 'file_blob', query: { where: { name: { $ne: { $field: 'checksum' } } } } });
231+
await p.findData({ object: 'file_blob', query: { where: deep(33, { checksum: 'sha256:0000' }) } });
232+
expect(find).toHaveBeenCalledTimes(2);
233+
});
234+
});

0 commit comments

Comments
 (0)