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
4461import { describe , it , expect , afterEach , vi } from 'vitest' ;
4562// The mocked module (see `vi.mock` below), loaded at module top.
4663import { matchesFilterCondition } from '@objectstack/formula' ;
64+ import { JSON_COLUMN_INCOMPATIBLE_OPERATORS , jsonColumnOperatorRefusalText } from '@objectstack/core' ;
4765import { ObjectQL } from '@objectstack/objectql' ;
4866import { SqlDriver } from '@objectstack/driver-sql' ;
4967import { SqliteWasmDriver } from '@objectstack/driver-sqlite-wasm' ;
5068import { PermissionSetSchema } from '@objectstack/spec/security' ;
5169import { SecurityPlugin } from './security-plugin.js' ;
5270import { defaultPermissionSets } from './objects/default-permission-sets.js' ;
5371import {
72+ declaredJsonStoredColumns ,
5473 declaredMultiValueColumns ,
5574 declaredTemporalColumns ,
75+ findJsonColumnCheckRefusal ,
76+ jsonColumnCheckRefusalCarriedBy ,
5677 storedFormCheckFilter ,
5778 storedFormCheckJudge ,
5879 storedFormImage ,
@@ -117,8 +138,11 @@ afterEach(async () => {
117138} ) ;
118139
119140let 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
186211type 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+
293513describe ( 'the stored-form step reads the declaration, never the values' , ( ) => {
294514 const COLUMNS = declaredTemporalColumns ( {
295515 fields : {
0 commit comments