Skip to content

Commit 97239c3

Browse files
fix(plugin-security): the RLS write check refuses an operator the read refuses on a declared JSON-stored column (#21254) (#21317)
Fixes #21254 Clause-②: no The row-level write `check` now refuses an operator the read refuses on a column the object declares JSON-stored, with the read's `INVALID_FILTER` / 400 and the read's words. The operator set is `@objectstack/core`'s `JSON_COLUMN_INCOMPATIBLE_OPERATORS` plus implicit equality. The core module's "two faces, one rule" gains a third face. `storedFormCheckJudge` imports the set and `jsonColumnOperatorRefusalText` and copies neither. Its error constructor is its own, as each face keeps its own, and it carries the read's envelope. There is no `packages/core` edit and no authoring-door refusal (triage ruling `5942971732`). ### Premise, re-measured on `main` at `5a9292e6f` Harness: `ObjectQL.insert` + `SecurityPlugin`, a member resolving a permission set, `using` and `check` the same predicate. `tags` is declared `tags` and `meta` is declared `json`. Both driver families (driver-sql on better-sqlite3, and driver-sqlite-wasm) gave the same answer in every row. The read is a system-stored row of the same value, read by the member under the same policy. | `check` | member writes | write on `main` | read, same policy | write on this branch | |---|---|---|---|---| | `record.tags != 'x'` | `['x']`, and `'x'` | **admitted**, stored `["x"]` | 400 `INVALID_FILTER` | 400 `INVALID_FILTER` | | `!(record.tags in ['x'])` | `['x']` | **admitted**, stored `["x"]` | 400 | 400 | | `record.tags == 'x'` | `['x']` | 403 | 400 | 400 | | `record.tags in ['x']` | `['x']` | 403 | 400 | 400 | | `record.tags > 'a'` | `['x']` | 400 (the evaluator's list-under-ordering refusal) | 400 | 400 (this refusal's words) | | `record.meta != 'x'` / `record.meta == 'x'` | a scalar | **admitted**, stored | 400 | 400 | | `record.tags.contains('x')` / `!record.tags.contains('x')` | `['x']` / `['y']` | admitted or 403, by membership | shown or hidden, alike | unchanged | | `record.tags != null` | `['x']` | admitted | shown | unchanged | | `record.title != 'x'` (`text`) | `'y'` / `'x'` | admitted / 403 | shown / hidden | unchanged | On this branch the refused write's message is byte-identical to the read's (pinned against `jsonColumnOperatorRefusalText(...).message`, imported). The full diagnostic names the field, the operator and the policy, and it goes to the server log, which the message points to. ### ⚠️ Two deviations the PM should read 1. **Rows 3 to 5 change their answer.** They are not admitted before or after, and nothing is stored. The pin says "the two rows that already refuse are unchanged". The ruling's rule refuses "an operator in the core set, on a declared JSON-stored / multi-valued column, with the read side's code and status", and `$eq`, `$in` and `$gt` are in that set. So `record.tags == 'x'` and `record.tags in ['x']` move from 403 to the read's 400. `record.tags > 'a'` keeps 400 `INVALID_FILTER` with core's words. Keeping 403 there would need either a subset of the set with `$eq` / `$in` exempted (a second copy of the rule), or a verdict that depends on the record. I took the ruling's rule as written and read "unchanged" as "still refused, nothing stored". If the 403 must stay, that is a ruling question. 2. **File surface widened by one file: `security-plugin.ts` (+21/-1).** It is the judge's only caller. The core message says "the full diagnostic is in the server log". Without a log line on this face that sentence would be false. The judge carries the diagnostic on the error under a symbol, which keeps it off the wire, the way `@objectstack/formula`'s comparison-class refusal does. The gate's existing `satisfiesCheck` catch logs it beside the policy name. Conflict check: none of the 13 open PRs touches `packages/plugins/plugin-security/src/security-plugin.ts`, read at this write. ### Changes - `packages/plugins/plugin-security/src/rls-check-stored-form.ts`: - `declaredJsonStoredColumns`: the declared multi-valued columns plus the spec's `STRUCTURED_JSON_TYPES`. This is the same population the evaluator already asks `$contains` membership of on the same declaration, so no new classification. - `findJsonColumnCheckRefusal`: a pure walk over the parts as compiled, using the traversal of objectql's per-aggregation gate. It takes the policy name from the compiler's marks. - The refusal error, with `code` `INVALID_FILTER`, `status` 400 and `httpStatus` 400. - `jsonColumnCheckRefusalCarriedBy`. - `storedFormCheckJudge` finds the refusal once and throws it for every image before any is evaluated, so the verdict comes from the declaration and never from a record. - `packages/plugins/plugin-security/src/security-plugin.ts`: logs the carried diagnostic (deviation 2). - `packages/plugins/plugin-security/src/rls-check-stored-form.test.ts`: - The card's table at the engine door on both drivers: the write, the stored row, the read's envelope and message, and the log line. - A by-id update and a predicate update under a check-only policy. - Controls: `contains` and its negation, presence, and a scalar column. - Unit pins beside the module: every operator in the imported set, implicit equality for any comparand, depth and part paths, the membership and presence pair left alone, no declaration meaning no refusal, and the diagnostic kept off the wire. - `packages/plugins/plugin-security/src/rls-stored-list-ordering-fails-closed.test.ts`: fixture triage of the stage-2e pins this rule supersedes. - The nine scalar cells on declared JSON columns (`tags` / `meta` are `json`, `watchers` is a `multiple` lookup) were compared before. They are now refused 400, as the read is. - The `null` control and the stage-2a equality control (`C3`) move to the `text` column `status`, where the evaluator still decides, so their subject stays covered. - `.changeset/21254-rls-write-check-json-column-operator-refusal.md`: `@objectstack/plugin-security` `patch`, `Clause-②: no`. No export of any package changes; `rls-check-stored-form` is not on the plugin's `exports`. ### Verification (at `f9d5020aa`, after merging `origin/main` `393ae878d`) `origin/main` now carries the core module's field-class parameter. This face calls the text builder with three arguments, so it gets the default class, whose words are unchanged. The workspace was rebuilt after the merge. - `pnpm --filter @objectstack/plugin-security test`: 157 files passed, 3450 passed, 23 skipped. Before the stage-2e triage the same run had 22 red cells in that one file, listed above. - `pnpm --filter @objectstack/plugin-security typecheck`: exit 0, including `check:test-typecheck` (0 files / 0 errors in the test-layer ledger). - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 63 commands. On the rebuilt workspace all 63 ran at this head with exit 0, and `--ran` reconciled "63 derived, 63 run, 0 NOT-MEASURED, 0 UNRUN" (a derived zero: every line carries its exit code). On the first pass, before the full build, `check:dual-build-cjs-loads` and `check:i18n` exited 3 (PREREQUISITE NOT MET). That was a missing `dist/`, not a finding. - ESLint, narrowed to the 4 touched source files with `--no-inline-config --format json`: 4 files, 0 errors, 0 warnings. The config never enables type-aware linting (its own header says so), so this diff cannot move the verdict on an untouched file. - Not run locally, declared to CI: the full lint farm, `Test Core`, `Dogfood`, `Temporal Conformance` and `Build Core`. ### Ablations (run at the committed head `9e6b96b95`; `scripts/ablation-replace.mjs` proved each mutation on disk and each restore: blob equals `HEAD`, and `git diff HEAD` is empty) The subject is imported relatively (`./rls-check-stored-form.js`) inside the package's own `src`, so no `dist/` is involved. All three ran `vitest run src/rls-check-stored-form.test.ts`. | leg | mutation | result | what went red | |---|---|---|---| | refusal removed | the judge's `if (refusal) throw …` line deleted | 23 failed / 81 passed | every refused cell on both drivers (rows 1 and 2 included), the update pin, and the 3 unit refusal pins | | refusal applied to the membership pair | `$contains` / `$notContains` added to the walk's refusal test | 26 failed / 78 passed | every `contains` / negated-`contains` control, this card's and the existing multi-value ones | | refusal applied to non-JSON columns | the walk's `!jsonStored.has(key)` skip removed | 44 failed / 60 passed | every scalar-column control (`title != 'x'`, `title in ['x']`, `title == …`), and the unit pins on depth and leave-alone | ### Docs `content/docs/**` (outside `releases/`) and `skills/**` were searched for sentences on how a row-level `check` evaluates operators on multi-valued or JSON columns. That covers `permissions/rls.mdx`, `skills/objectstack-data/rules/security.md`, and every "JSON-stored", "multi-value field" and `$contains` page. No page states it, so no sentence becomes false and no doc is edited. ## Acceptance notes - **A fourth in-process face still answers the old way: `security.explain`'s record attribution.** It evaluates the same policies with the evaluator and the declared columns, but without this refusal. It answers `visible: true, decidedBy: rls` for a record under `record.tags != 'x'`, `record.meta == 'x'` or `!(record.tags in ['x'])`, while the find under the same policy answers 400 `INVALID_FILTER`. This was measured in-process through the registered `security` service on driver-sql at `5a56607ab`, a merge of this branch that predates the latest `main`; the HTTP route was not driven. It is reported to the seat as a finding and is not touched here. - **Refusal order.** On the write face this refusal is judged before the evaluator's own shape refusals. A policy that is malformed and also aims a refused operator at a JSON column gets this message. Both answers are `INVALID_FILTER` / 400. The read faces judge the comparand shape first. - **`$notContains` reaches the check only as a filter.** The CEL lowering spells a negated `contains` as `$not` over `$contains`. The engine-door control uses that spelling, and `$notContains` itself is pinned at unit level. - **No `driver-memory` or `driver-mongodb` measurement.** The pins are on the two SQL families the card measured. - **`record.tags == null` / `!= null` lower to the presence spelling** and are unchanged. --- _Generated by [Claude Code](https://claude.ai/code/session_01DiCSbmJrkzNhuEAier4VoJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 222ecc2 commit 97239c3

5 files changed

Lines changed: 521 additions & 25 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/plugin-security': patch
3+
---
4+
5+
fix(plugin-security): a row-level `check` refuses an operator the read refuses on a field declared JSON-stored, with the read's `INVALID_FILTER` / 400, so a policy whose read is refused no longer admits writes (#21254)
6+
7+
Clause-②: no
8+
9+
The read a row-level policy scopes refuses a scalar comparison, an ordering or a text operator (`@objectstack/core`'s `JSON_COLUMN_INCOMPATIBLE_OPERATORS`, and implicit equality) on a field the object declares JSON-stored: a structured-JSON type (`json`, `address`, …), or a multi-valued field (`tags`, `multiselect`, `checkboxes`, or a `select` / `lookup` / `user` / `file` / `image` flagged `multiple: true`). The write `check` evaluated the same operators against the stored list instead. Measured through `ObjectQL.insert` with `SecurityPlugin` on two SQLite driver families, as a member resolving a permission set, with the same predicate as `using` and `check`:
10+
11+
| `check` | written | write, before | read |
12+
|---|---|---|---|
13+
| `record.tags != 'x'` | `['x']` or `'x'` | admitted, stored `["x"]` | 400 |
14+
| `!(record.tags in ['x'])` | `['x']` | admitted, stored `["x"]` | 400 |
15+
| `record.tags == 'x'` / `record.tags in ['x']` | `['x']` | 403 | 400 |
16+
| `record.tags > 'a'` | `['x']` | 400 | 400 |
17+
| `record.meta != 'x'` / `record.meta == 'x'` (`meta` is `json`) | a scalar | admitted, stored | 400 |
18+
19+
Now the write check refuses every one of these with the read's answer: `INVALID_FILTER` / 400 and the read's words, which withhold the field and the operator. The refusal reads the object's declaration, never the record, so a policy is refused for every row or for none, on the insert, a by-id update and a predicate update. The diagnostic, which names the field, the operator and the policy, goes to the server log. Rows that already refused still store nothing; their answer is now the read's.
20+
21+
Unchanged: `contains` and its negation (`$contains` / `$notContains`), and the presence checks (`== null`, `!= null`), answer on such a field as before; a field declared neither way keeps every operator; an object whose schema cannot be loaded is judged as before. To repair a refused policy, test membership with `contains` (for example `!record.tags.contains('x')`).

‎packages/plugins/plugin-security/src/rls-check-stored-form.test.ts‎

Lines changed: 224 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,23 @@
2929
* | `!record.tags.contains('x')` | `'x'` | admitted | `["x"]` | hidden |
3030
* | `record.tags.contains('x')`, a by-id update | `'x'` | 403 | `["x"]` | shown |
3131
*
32+
* [#21254] An operator the read refuses on a declared JSON-stored column
33+
* (`@objectstack/core`'s `JSON_COLUMN_INCOMPATIBLE_OPERATORS`, or implicit
34+
* equality) is refused by the write check too, with the read's
35+
* `INVALID_FILTER` / 400 and core's words. Measured on `main` before the step:
36+
*
37+
* | `check` | written | write, before | stored | read |
38+
* |---|---|---|---|---|
39+
* | `record.tags != 'x'` | `['x']` or `'x'` | admitted | `["x"]` | 400 |
40+
* | `!(record.tags in ['x'])` | `['x']` | admitted | `["x"]` | 400 |
41+
* | `record.tags == 'x'` | `['x']` | 403 | — | 400 |
42+
* | `record.tags in ['x']` | `['x']` | 403 | — | 400 |
43+
* | `record.tags > 'a'` | `['x']` | 400 | — | 400 |
44+
* | `record.meta != 'x'` / `record.meta == 'x'` (`json`) | `'y'` / `'x'` | admitted | the scalar | 400 |
45+
*
46+
* The membership pair and the presence predicates answer as before, and so
47+
* does every operator on a column declared neither way.
48+
*
3249
* ## formula's whole-day copy is out of reach here
3350
*
3451
* `@objectstack/formula`'s matcher carries its own copy of the whole-day upper
@@ -44,15 +61,19 @@
4461
import { describe, it, expect, afterEach, vi } from 'vitest';
4562
// The mocked module (see `vi.mock` below), loaded at module top.
4663
import { matchesFilterCondition } from '@objectstack/formula';
64+
import { JSON_COLUMN_INCOMPATIBLE_OPERATORS, jsonColumnOperatorRefusalText } from '@objectstack/core';
4765
import { ObjectQL } from '@objectstack/objectql';
4866
import { SqlDriver } from '@objectstack/driver-sql';
4967
import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm';
5068
import { PermissionSetSchema } from '@objectstack/spec/security';
5169
import { SecurityPlugin } from './security-plugin.js';
5270
import { defaultPermissionSets } from './objects/default-permission-sets.js';
5371
import {
72+
declaredJsonStoredColumns,
5473
declaredMultiValueColumns,
5574
declaredTemporalColumns,
75+
findJsonColumnCheckRefusal,
76+
jsonColumnCheckRefusalCarriedBy,
5677
storedFormCheckFilter,
5778
storedFormCheckJudge,
5879
storedFormImage,
@@ -117,8 +138,11 @@ afterEach(async () => {
117138
});
118139

119140
let seq = 0;
120-
/** One engine and plugin, with ONE policy whose `using` and `check` are the same predicate. */
121-
async function boot(makeDriver: () => Driver, predicate: string) {
141+
/**
142+
* One engine and plugin, with ONE policy whose `using` and `check` are the same
143+
* predicate — or, given `using`, a policy whose `using` is that one instead.
144+
*/
145+
async function boot(makeDriver: () => Driver, predicate: string, using: string = predicate) {
122146
const OBJ = `qa_due_stored_${process.pid}_${++seq}`;
123147
const engine = new ObjectQL();
124148
engine.registerDriver(makeDriver() as never, true);
@@ -142,6 +166,7 @@ async function boot(makeDriver: () => Driver, predicate: string) {
142166
start_time: { name: 'start_time', type: 'time' },
143167
tags: { name: 'tags', type: 'tags' },
144168
owners: { name: 'owners', type: 'select', multiple: true, options: [{ label: 'X', value: 'x' }, { label: 'XY', value: 'xy' }] },
169+
meta: { name: 'meta', type: 'json' },
145170
},
146171
},
147172
],
@@ -152,7 +177,7 @@ async function boot(makeDriver: () => Driver, predicate: string) {
152177
const set = PermissionSetSchema.parse({
153178
name: 'qa_due_guard',
154179
objects: { [OBJ]: { allowRead: true, allowCreate: true, allowEdit: true, allowDelete: true } },
155-
rowLevelSecurity: [{ name: 'due_guard', object: OBJ, operation: 'all', using: predicate, check: predicate }],
180+
rowLevelSecurity: [{ name: 'due_guard', object: OBJ, operation: 'all', using, check: predicate }],
156181
});
157182
const services: Record<string, unknown> = {
158183
manifest: { register: vi.fn() },
@@ -180,7 +205,7 @@ async function boot(makeDriver: () => Driver, predicate: string) {
180205
((await engine.find(OBJ, { where: { id }, context: SYS_CTX } as never)) as Array<Record<string, unknown>>)[0];
181206
const shownTo = async (id: string) =>
182207
((await engine.find(OBJ, { where: { id }, context: caller } as never)) as unknown[]).length > 0;
183-
return { OBJ, engine, caller, storedRow, shownTo };
208+
return { OBJ, engine, caller, storedRow, shownTo, logger: ctx.logger };
184209
}
185210

186211
type Envelope = { code: string; status: number };
@@ -290,6 +315,201 @@ for (const [driverName, makeDriver] of DRIVERS) {
290315
});
291316
}
292317

318+
// [#21254] The card's table at the engine write door, beside the read the same
319+
// policy scopes. A refused cell names what the withheld diagnostic names: the
320+
// column, the operator, and whether it is the bare equality spelling.
321+
const REFUSED: Envelope = { code: 'INVALID_FILTER', status: 400 };
322+
type JsonColumnCell = {
323+
predicate: string;
324+
column: string;
325+
value: unknown;
326+
refused?: [field: string, op: string, bare: boolean];
327+
/** A cell the check still evaluates: whether it admits, the read shows the row, and what is stored. */
328+
admitted?: boolean;
329+
stored?: unknown;
330+
};
331+
const JSON_COLUMN_CELLS: JsonColumnCell[] = [
332+
// The card's rows 1–2: admitted before, while the read refused the policy.
333+
{ predicate: "record.tags != 'x'", column: 'tags', value: ['x'], refused: ['tags', '$ne', false] },
334+
{ predicate: "record.tags != 'x'", column: 'tags', value: 'x', refused: ['tags', '$ne', false] },
335+
{ predicate: "!(record.tags in ['x'])", column: 'tags', value: ['x'], refused: ['tags', '$in', false] },
336+
// Rows 3–5: refused before (403, 403, 400). Nothing is stored, as before; the answer is now the read's.
337+
{ predicate: "record.tags == 'x'", column: 'tags', value: ['x'], refused: ['tags', '=', true] },
338+
{ predicate: "record.tags in ['x']", column: 'tags', value: ['x'], refused: ['tags', '$in', false] },
339+
{ predicate: "record.tags > 'a'", column: 'tags', value: ['x'], refused: ['tags', '$gt', false] },
340+
// A `select` flagged `multiple`, and a structured-JSON column holding a scalar.
341+
{ predicate: "record.owners != 'x'", column: 'owners', value: ['x'], refused: ['owners', '$ne', false] },
342+
{ predicate: "record.meta != 'x'", column: 'meta', value: 'y', refused: ['meta', '$ne', false] },
343+
{ predicate: "record.meta == 'x'", column: 'meta', value: 'x', refused: ['meta', '=', true] },
344+
// Control: the membership pair — `contains` and its negation — answers on the stored list as before.
345+
{ predicate: "record.tags.contains('x')", column: 'tags', value: ['x'], admitted: true, stored: ['x'] },
346+
{ predicate: "record.tags.contains('x')", column: 'tags', value: ['y'], admitted: false, stored: ['y'] },
347+
{ predicate: "!record.tags.contains('x')", column: 'tags', value: ['x'], admitted: false, stored: ['x'] },
348+
{ predicate: "!record.tags.contains('x')", column: 'tags', value: ['y'], admitted: true, stored: ['y'] },
349+
// Control: presence answers on such a column.
350+
{ predicate: 'record.tags != null', column: 'tags', value: ['x'], admitted: true, stored: ['x'] },
351+
// Control: a column declared neither way keeps every operator, the refused rows' among them.
352+
{ predicate: "record.title != 'x'", column: 'title', value: 'y', admitted: true, stored: 'y' },
353+
{ predicate: "record.title != 'x'", column: 'title', value: 'x', admitted: false, stored: 'x' },
354+
{ predicate: "record.title in ['x']", column: 'title', value: 'x', admitted: true, stored: 'x' },
355+
];
356+
357+
/** The write gate's server-log lines for this refusal. */
358+
const refusalLines = (logger: { warn: unknown }): string[] =>
359+
(logger.warn as ReturnType<typeof vi.fn>).mock.calls.map((c) => String(c[0])).filter((l) => l.includes('RLS check REFUSED'));
360+
361+
for (const [driverName, makeDriver] of DRIVERS) {
362+
describe(`[#21254] ${driverName}: an operator the read refuses on a declared JSON-stored column is refused by the write check, with the read's answer`, () => {
363+
for (const cell of JSON_COLUMN_CELLS) {
364+
const verdict = cell.refused ? 'refused 400 INVALID_FILTER, as the read is' : cell.admitted ? 'admitted and shown' : 'refused 403 and hidden';
365+
it(`${cell.predicate}, ${cell.column} written as ${show(cell.value)}: ${verdict}`, async () => {
366+
const r = await boot(makeDriver, cell.predicate);
367+
const written = await r.engine
368+
.insert(r.OBJ, { id: 'w', [cell.column]: cell.value }, { context: r.caller } as never)
369+
.then(() => 'admitted' as const, (e: unknown) => e);
370+
await r.engine.insert(r.OBJ, { id: 'r', [cell.column]: cell.value }, { context: SYS_CTX } as never);
371+
if (!cell.refused) {
372+
expect(written === 'admitted' ? written : envelopeOf(written)).toEqual(cell.admitted ? 'admitted' : DENIED);
373+
expect((await r.storedRow('r'))?.[cell.column]).toEqual(cell.stored);
374+
expect(await r.shownTo('r')).toBe(cell.admitted);
375+
expect(refusalLines(r.logger)).toEqual([]);
376+
return;
377+
}
378+
const [field, op, bare] = cell.refused;
379+
const words = jsonColumnOperatorRefusalText(field, op, bare);
380+
// The write: the read's code and status, and core's words, which withhold the field and the operator.
381+
expect(envelopeOf(written)).toEqual(REFUSED);
382+
expect((written as Error).message).toBe(words.message);
383+
expect(await r.storedRow('w')).toBeUndefined();
384+
// The diagnostic the message points to is in the server log, beside the policy.
385+
const lines = refusalLines(r.logger);
386+
expect(lines).toHaveLength(1);
387+
expect(lines[0]).toContain("policy 'due_guard'");
388+
expect(lines[0]).toContain(words.diagnostic);
389+
// The read the same policy scopes, over the same value stored by the system: the same answer.
390+
const read = await r.engine
391+
.find(r.OBJ, { where: { id: 'r' }, context: r.caller } as never)
392+
.then(() => 'answered' as const, (e: unknown) => e);
393+
expect(envelopeOf(read)).toEqual(REFUSED);
394+
expect((read as Error).message).toBe(words.message);
395+
});
396+
}
397+
398+
// The `using` here is a column declared neither way: under the same
399+
// predicate an update's pre-image read is refused first, by the driver,
400+
// and its gate fails closed 403 before any check runs.
401+
it("record.tags != 'x' as the check: a by-id update and a predicate update are refused 400 too, and change nothing", async () => {
402+
const r = await boot(makeDriver, "record.tags != 'x'", "record.title == 'batch'");
403+
await r.engine.insert(r.OBJ, { id: 'u', title: 'batch', tags: ['y'] }, { context: SYS_CTX } as never);
404+
expect(await outcome(r.engine.update(r.OBJ, { tags: 'x' }, { where: { id: 'u' }, context: r.caller } as never)))
405+
.toEqual(REFUSED);
406+
expect(await outcome(r.engine.update(r.OBJ, { tags: ['x'] }, { where: { title: 'batch' }, multi: true, context: r.caller } as never)))
407+
.toEqual(REFUSED);
408+
expect((await r.storedRow('u'))?.tags).toEqual(['y']);
409+
});
410+
});
411+
}
412+
413+
describe('[#21254] the JSON-column refusal reads the declaration, never the record', () => {
414+
const DECLARED = {
415+
fields: {
416+
tags: { type: 'tags', multiple: false },
417+
labels: { type: 'multiselect', multiple: false },
418+
owners: { type: 'select', multiple: true },
419+
meta: { type: 'json', multiple: false },
420+
home: { type: 'address', multiple: false },
421+
status: { type: 'select', multiple: false },
422+
title: { type: 'text', multiple: false },
423+
due_on: { type: 'date', multiple: false },
424+
},
425+
};
426+
const JSON_STORED = declaredJsonStoredColumns(DECLARED);
427+
/** The refusal a judgement raised, with everything a caller and the log read from it. */
428+
const refusalOf = (judge: (image: Record<string, unknown>) => boolean, image: Record<string, unknown>) => {
429+
try {
430+
judge(image);
431+
} catch (e) {
432+
const x = e as Error & { code?: string; status?: number; httpStatus?: number };
433+
return { envelope: { code: x.code, status: x.status, httpStatus: x.httpStatus }, message: x.message, carried: jsonColumnCheckRefusalCarriedBy(e), error: e };
434+
}
435+
return null;
436+
};
437+
const IMAGES = [{ tags: ['x'] }, { tags: 'x' }, { tags: null }, {}, { tags: ['y'], meta: 'x', owners: ['x'] }];
438+
439+
it('names exactly the multi-valued and structured-JSON columns, and none without a declaration', () => {
440+
expect([...JSON_STORED].sort()).toEqual(['home', 'labels', 'meta', 'owners', 'tags']);
441+
expect(declaredJsonStoredColumns(undefined).size).toBe(0);
442+
});
443+
444+
it("refuses every operator in core's set on such a column, with the read's envelope and core's words, for every image", () => {
445+
expect(JSON_COLUMN_INCOMPATIBLE_OPERATORS.size).toBeGreaterThan(0);
446+
for (const op of JSON_COLUMN_INCOMPATIBLE_OPERATORS) {
447+
const judge = storedFormCheckJudge([{ tags: { [op]: 'x' } }], DECLARED);
448+
const words = jsonColumnOperatorRefusalText('tags', op, false);
449+
for (const image of IMAGES) {
450+
const got = refusalOf(judge, image);
451+
expect(got?.envelope, op).toEqual({ code: 'INVALID_FILTER', status: 400, httpStatus: 400 });
452+
expect(got?.message, op).toBe(words.message);
453+
expect(got?.carried, op).toMatchObject({ field: 'tags', operator: op, path: `check[0].tags.${op}`, diagnostic: words.diagnostic });
454+
}
455+
}
456+
});
457+
458+
it('refuses implicit equality on such a column whatever the comparand, as the bare spelling', () => {
459+
for (const comparand of ['x', null, ['x'], new Date('2026-01-05T00:00:00Z'), 5]) {
460+
const got = refusalOf(storedFormCheckJudge([{ meta: comparand }], DECLARED), { meta: 'x' });
461+
expect(got?.carried).toMatchObject({ field: 'meta', operator: '=', path: 'check[0].meta', diagnostic: jsonColumnOperatorRefusalText('meta', '=', true).diagnostic });
462+
}
463+
});
464+
465+
it('finds it at any depth under $and / $or / $not and in any part, and names where', () => {
466+
const nested = findJsonColumnCheckRefusal(
467+
[{ $and: [{ title: 'x' }, { $or: [{ tags: { $null: true } }, { $not: { home: { $eq: 'x' } } }] }] }],
468+
JSON_STORED,
469+
);
470+
expect(nested).toMatchObject({ field: 'home', operator: '$eq', path: 'check[0].$and[1].$or[1].$not.home.$eq' });
471+
expect(findJsonColumnCheckRefusal([{ title: { $ne: 'x' } }, { labels: { $nin: ['a'] } }], JSON_STORED))
472+
.toMatchObject({ field: 'labels', operator: '$nin', path: 'check[1].labels.$nin' });
473+
});
474+
475+
it('leaves the membership pair, the presence predicates and every column declared neither way to the evaluator', () => {
476+
const parts = [
477+
{ tags: { $contains: 'x' } },
478+
{ tags: { $notContains: 'x' } },
479+
{ $not: { owners: { $contains: 'x' } } },
480+
{ tags: { $null: true } },
481+
{ meta: { $exists: true } },
482+
{ home: { $empty: false } },
483+
{ title: { $ne: 'x' } },
484+
{ title: 'x' },
485+
{ status: { $in: ['x'] } },
486+
{ due_on: { $gt: '2026-01-05' } },
487+
];
488+
for (const part of parts) expect(findJsonColumnCheckRefusal([part], JSON_STORED), JSON.stringify(part)).toBeNull();
489+
const contains = storedFormCheckJudge([{ tags: { $contains: 'x' } }], DECLARED);
490+
const notContains = storedFormCheckJudge([{ tags: { $notContains: 'x' } }], DECLARED);
491+
expect([contains({ tags: ['x'] }), contains({ tags: ['y'] })]).toEqual([true, false]);
492+
expect([notContains({ tags: ['x'] }), notContains({ tags: ['y'] })]).toEqual([false, true]);
493+
const scalar = storedFormCheckJudge([{ title: { $ne: 'x' } }], DECLARED);
494+
expect([scalar({ title: 'y' }), scalar({ title: 'x' })]).toEqual([true, false]);
495+
});
496+
497+
it('refuses nothing where the object hands over no declaration: judged as before', () => {
498+
expect(findJsonColumnCheckRefusal([{ tags: { $ne: 'x' } }], declaredJsonStoredColumns(undefined))).toBeNull();
499+
expect(storedFormCheckJudge([{ tags: { $ne: 'y' } }], undefined)({ tags: 'x' })).toBe(true);
500+
});
501+
502+
it('keeps the field and the operator off the wire: they travel on the error only for the server log', () => {
503+
const got = refusalOf(storedFormCheckJudge([{ owners: { $ne: 'secret_member' } }], DECLARED), {});
504+
const wire = JSON.stringify({ ...(got!.error as object), message: got!.message });
505+
expect(wire).not.toContain('owners');
506+
expect(wire).not.toContain('$ne');
507+
expect(got?.carried?.diagnostic).toContain('"owners"');
508+
expect(jsonColumnCheckRefusalCarriedBy(new Error('other'))).toBeNull();
509+
expect(jsonColumnCheckRefusalCarriedBy(null)).toBeNull();
510+
});
511+
});
512+
293513
describe('the stored-form step reads the declaration, never the values', () => {
294514
const COLUMNS = declaredTemporalColumns({
295515
fields: {

0 commit comments

Comments
 (0)