From 59b994e50687d2eafc0406514063513a2bee26c5 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 14 Jul 2026 21:41:24 +1000 Subject: [PATCH 1/6] feat(stack-drizzle): EQL v3 JSON selector-with-constraint querying MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add ops.selector(col, '$.path') to the v3 Drizzle integration (#623): comparison methods (eq/ne/gt/gte/lt/lte) bound to a JSONPath into a types.Json column, emitting `eql_v3.(eql_v3."->"(col,''), eql_v3."->"(''::eql_v3_json,''))`. Its unique power over `contains` is ORDERING at a path (col->'$.age' > 21). - Selector hash from encryptQuery(path, searchableJson) (string needle → ste_vec_selector). - Comparison entry from a STORAGE encrypt of {path: value} — its ciphertext `c` is required by the eql_v3_jsonb_entry domain (a query term can't carry it). - One new dialect fn (selectorEntry); reuses equality/comparison for the compare. - v1: dot-notation object paths; array/wildcard rejected with a clear error. Core needs no change. Supabase is a follow-up (#650) — blocked by PostgREST's inability to cast a filter value. Refs #623 Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- .changeset/eql-v3-json-selector.md | 16 ++ .../json-selector.integration.test.ts | 137 +++++++++++++++ packages/stack-drizzle/src/v3/operators.ts | 159 ++++++++++++++++++ packages/stack-drizzle/src/v3/sql-dialect.ts | 17 ++ 4 files changed, 329 insertions(+) create mode 100644 .changeset/eql-v3-json-selector.md create mode 100644 packages/stack-drizzle/integration/json-selector.integration.test.ts diff --git a/.changeset/eql-v3-json-selector.md b/.changeset/eql-v3-json-selector.md new file mode 100644 index 000000000..af1fc3fcd --- /dev/null +++ b/.changeset/eql-v3-json-selector.md @@ -0,0 +1,16 @@ +--- +'@cipherstash/stack-drizzle': minor +--- + +Add EQL v3 JSON **selector-with-constraint** querying to the Drizzle integration +(#623). `ops.selector(col, '$.path')` returns comparison methods bound to a +JSONPath into a `types.Json` column — `eq`/`ne`/`gt`/`gte`/`lt`/`lte` — emitting +`col->'' ` over the encrypted document. Its unique power +over `contains` is **ordering at a path** (`col->'$.age' > 21`), which +containment cannot express. + +Complements the existing `contains` (JSONB `@>`) containment operator. Core +`@cipherstash/stack` needs no change — the selector term and comparison entry are +produced by `encryptQuery`/`encrypt` on the existing `types.Json` surface. v1 +supports dot-notation object paths; array-index/wildcard paths are rejected with +a clear error. The Supabase adapter is tracked separately. diff --git a/packages/stack-drizzle/integration/json-selector.integration.test.ts b/packages/stack-drizzle/integration/json-selector.integration.test.ts new file mode 100644 index 000000000..b6e620a3a --- /dev/null +++ b/packages/stack-drizzle/integration/json-selector.integration.test.ts @@ -0,0 +1,137 @@ +/** + * Live JSONPath selector-with-constraint for the v3 `types.Json()` column (#623). + * Distinct from containment (`ops.contains`, `@>`): this extracts the encrypted + * leaf entry at a JSONPath and compares it, so it can express ORDERING at a path + * (`col->'$.age' > 25`) that containment cannot. Emits + * `eql_v3.(eql_v3."->"(col, ''), eql_v3."->"(''::eql_v3_json, ''))`. + * + * The selector hash comes from `encryptQuery(path, searchableJson)`; the + * comparison entry from a STORAGE `encrypt` of `{path: value}` (its ciphertext + * `c` is required by the `eql_v3_jsonb_entry` domain — a query term can't carry it). + */ + +import type { JsonDocument } from '@cipherstash/stack/eql/v3' +import { EncryptionV3 } from '@cipherstash/stack/v3' +import { databaseUrl, unwrapResult } from '@cipherstash/test-kit' +import { and, asc, eq, type SQL } from 'drizzle-orm' +import { integer, pgTable, text } from 'drizzle-orm/pg-core' +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + createEncryptionOperatorsV3, + extractEncryptionSchemaV3, + types, +} from '../src/v3/index.js' + +const sqlClient = postgres(databaseUrl(), { prepare: false }) + +const TABLE_NAME = 'protect_ci_v3_json_selector' +const RUN = `run-${Date.now()}-${Math.random().toString(36).slice(2, 8)}` + +const docTable = pgTable(TABLE_NAME, { + id: integer('id').primaryKey().generatedAlwaysAsIdentity(), + rowKey: text('row_key').notNull(), + testRunId: text('test_run_id').notNull(), + doc: types.Json('doc'), +}) + +const schema = extractEncryptionSchemaV3(docTable) + +// Distinct ages so ordering-at-a-selector has a definite expected set. +const DOCS: Record = { + ada: { user: 'ada@example.com', age: 30 }, + grace: { user: 'grace@example.com', age: 20 }, + zoe: { user: 'zoe@example.com', age: 40 }, +} + +type SelectRow = { rowKey: string } +let client: Awaited> +let ops: ReturnType +let db: ReturnType + +async function matching(condition: SQL): Promise { + const rows = (await db + .select({ rowKey: docTable.rowKey }) + .from(docTable) + .where(and(eq(docTable.testRunId, RUN), condition)) + .orderBy(asc(docTable.rowKey))) as SelectRow[] + return rows.map((row) => row.rowKey) +} + +beforeAll(async () => { + client = await EncryptionV3({ schemas: [schema] }) + ops = createEncryptionOperatorsV3(client) + db = drizzle({ client: sqlClient }) + + await sqlClient.unsafe(`DROP TABLE IF EXISTS ${TABLE_NAME}`) + await sqlClient.unsafe(` + CREATE TABLE ${TABLE_NAME} ( + id INTEGER PRIMARY KEY GENERATED ALWAYS AS IDENTITY, + row_key TEXT NOT NULL, + test_run_id TEXT NOT NULL, + doc public.eql_v3_json NOT NULL + ) + `) + + const rows = Object.entries(DOCS).map(([rowKey, doc]) => ({ + rowKey, + testRunId: RUN, + doc, + })) + const encrypted = unwrapResult( + await client.bulkEncryptModels(rows, schema), + ) as Array> + await db.insert(docTable).values(encrypted as never) +}, 120000) + +afterAll(async () => { + await sqlClient`DELETE FROM ${sqlClient(TABLE_NAME)} WHERE test_run_id = ${RUN}` + await sqlClient.end() +}, 30000) + +describe('v3 drizzle JSON selector-with-constraint (live pg)', () => { + it('equality at a scalar selector', async () => { + expect( + await matching(await ops.selector(docTable.doc, '$.age').eq(30)), + ).toEqual(['ada']) + }, 30000) + + it('equality at a string selector', async () => { + const condition = await ops + .selector(docTable.doc, '$.user') + .eq('zoe@example.com') + expect(await matching(condition)).toEqual(['zoe']) + }, 30000) + + it('ordering at a selector: greater-than (the form containment cannot express)', async () => { + // ages: ada 30, grace 20, zoe 40 → > 25 selects ada, zoe. + expect( + await matching(await ops.selector(docTable.doc, '$.age').gt(25)), + ).toEqual(['ada', 'zoe']) + }, 30000) + + it('ordering at a selector: less-than', async () => { + expect( + await matching(await ops.selector(docTable.doc, '$.age').lt(35)), + ).toEqual(['ada', 'grace']) + }, 30000) + + it('ordering at a selector: gte inclusive boundary', async () => { + expect( + await matching(await ops.selector(docTable.doc, '$.age').gte(30)), + ).toEqual(['ada', 'zoe']) + }, 30000) + + it('returns nothing when no leaf satisfies the constraint', async () => { + expect( + await matching(await ops.selector(docTable.doc, '$.age').gt(100)), + ).toEqual([]) + }, 30000) + + it('rejects array/wildcard selector paths (v1 supports object keys only)', async () => { + await expect( + ops.selector(docTable.doc, '$.items[0].name').eq('x'), + ).rejects.toThrow(/not yet supported/) + }, 30000) +}) diff --git a/packages/stack-drizzle/src/v3/operators.ts b/packages/stack-drizzle/src/v3/operators.ts index e8105202d..0476552c2 100644 --- a/packages/stack-drizzle/src/v3/operators.ts +++ b/packages/stack-drizzle/src/v3/operators.ts @@ -66,6 +66,58 @@ type OperandEncryptionClient = { opts: never, ): ChainableOperation encryptQuery(terms: never): ChainableOperation + /** + * Storage encryption. Needed ONLY by the JSON selector-with-constraint path: + * `col->'sel' value` compares two `eql_v3_jsonb_entry` values, and that + * domain requires ciphertext `c` — which a ciphertext-free query term cannot + * carry. So the right-hand entry is a STORAGE encryption of `{path: value}`, + * from which the selector entry is extracted (see `selectorCompare`). + */ + encrypt(value: never, opts: never): ChainableOperation +} + +/** + * Reconstruct the nested object a JSONPath addresses, with `value` at the leaf: + * `$.bar` → `{ bar: value }`, `$.wee.deep` → `{ wee: { deep: value } }`. This is + * the storage-needle document whose ste_vec entry at `path` supplies the + * ciphertext-bearing comparison entry. + * + * v1 supports dot-notation OBJECT keys only. Array-index / wildcard paths are + * rejected: the storage needle can't be reconstructed for them yet (a follow-up). + */ +function reconstructSelectorDocument( + path: string, + value: unknown, +): Record { + const bare = path.replace(/^\$\.?/, '') + const segments = bare.split('.').filter((segment) => segment.length > 0) + if (segments.length === 0) { + throw new Error(`JSON selector path "${path}" has no field segments.`) + } + for (const segment of segments) { + if (/[[\]*]/.test(segment)) { + throw new Error( + `JSON selector path "${path}" uses array/wildcard syntax, which is not yet supported — use dot-notation object keys (e.g. "$.a.b").`, + ) + } + } + const root: Record = {} + let cursor = root + segments.forEach((segment, index) => { + if (index === segments.length - 1) { + cursor[segment] = value + } else { + const next: Record = {} + cursor[segment] = next + cursor = next + } + }) + return root +} + +/** `$`-root a bare path so the selector needle matches the reconstructed doc. */ +function normalizeJsonPath(path: string): string { + return path.startsWith('$') ? path : `$.${path}` } /** @@ -571,6 +623,106 @@ export function createEncryptionOperatorsV3( return sql`${JSON.stringify(result.data)}::eql_v3.query_jsonb` } + /** + * JSONPath selector-with-constraint on an `eql_v3_json` (`ste_vec`) column: + * `col->'path' value`. Extracts the encrypted leaf entry at `path` on both + * sides and compares them with the `eql_v3_jsonb_entry` comparators. + * + * Two operands, encrypted differently: + * - the SELECTOR (`path`): `encryptQuery(path, searchableJson)` on a string + * needle infers a `ste_vec_selector` term → the bare HMAC selector hash, bound + * as the `text` argument of `eql_v3."->"`. + * - the VALUE (right-hand entry): a STORAGE encryption of the reconstructed + * `{path: value}` document — its ste_vec entry carries the ciphertext `c` the + * `eql_v3_jsonb_entry` domain requires (a query term is ciphertext-free and + * would fail the domain CHECK). The same selector extracts it from the needle. + * + * Note equality-at-selector is also expressible via `contains(col, {path: value})`; + * this path's unique power is ORDERING at a selector (`gt`/`gte`/`lt`/`lte`). + */ + async function selectorCompare( + col: SQLWrapper, + path: string, + op: EqualityOp | ComparisonOp, + value: unknown, + operator: string, + opts?: EncryptionOperatorCallOpts, + ): Promise { + const ctx = resolveContext(col, operator) + requireIndex( + ctx, + JSON_CONTAINMENT_INDEXES, + operator, + 'JSON selector (searchableJson)', + ) + requireNonNullOperand(ctx, value, operator) + + const selResult = await applyOperationOptions( + client.encryptQuery( + normalizeJsonPath(path) as never, + { + table: ctx.table, + column: ctx.builder, + queryType: 'searchableJson', + } as never, + ), + opts, + ) + if (selResult.failure) { + throw operandFailure(ctx, operator, selResult.failure.message) + } + // A v3 selector term is the bare HMAC hash string; guard the shape so a + // wrapped envelope can't silently bind as JSON text. + const selValue = selResult.data + const selHash = + typeof selValue === 'string' ? selValue : JSON.stringify(selValue) + + const encResult = await applyOperationOptions( + client.encrypt( + reconstructSelectorDocument(path, value) as never, + { + table: ctx.table, + column: ctx.builder, + } as never, + ), + opts, + ) + if (encResult.failure) { + throw operandFailure(ctx, operator, encResult.failure.message) + } + + const selSql = sql`${selHash}::text` + const needleDoc = sql`${JSON.stringify(encResult.data)}::public.eql_v3_json` + const leftEntry = v3Dialect.selectorEntry(colSql(col), selSql) + const rightEntry = v3Dialect.selectorEntry(needleDoc, selSql) + return op === 'eq' || op === 'ne' + ? v3Dialect.equality(op, leftEntry, rightEntry) + : v3Dialect.comparison(op, leftEntry, rightEntry) + } + + /** Comparison methods bound to a `col->'path'` selector, mirroring the scalar + * operators. Async: each encrypts its operand. */ + function selectorOps(col: SQLWrapper, path: string) { + const at = + (op: EqualityOp | ComparisonOp) => + (value: unknown, opts?: EncryptionOperatorCallOpts) => + selectorCompare(col, path, op, value, `selector(${path}).${op}`, opts) + return { + /** `col->'path' = value` (encrypted equality at the selector). */ + eq: at('eq'), + /** `col->'path' <> value`. */ + ne: at('ne'), + /** `col->'path' > value` (encrypted ordering at the selector). */ + gt: at('gt'), + /** `col->'path' >= value`. */ + gte: at('gte'), + /** `col->'path' < value`. */ + lt: at('lt'), + /** `col->'path' <= value`. */ + lte: at('lte'), + } + } + async function inArrayOp( left: SQLWrapper, values: unknown[], @@ -674,6 +826,13 @@ export function createEncryptionOperatorsV3( * a `ste_vec` index (a `types.Json` column). */ contains: (l: SQLWrapper, r: unknown, opts?: EncryptionOperatorCallOpts) => containsJsonOp(l, r, 'contains', opts), + /** JSONPath selector-with-constraint on a `types.Json` (`ste_vec`) column. + * Returns comparison methods bound to `col->'path'` — e.g. + * `await ops.selector(users.doc, '$.age').gt(21)` emits + * `col->'' > `. Its unique power over `contains` is ORDERING at + * a path (`gt`/`gte`/`lt`/`lte`); `eq`/`ne` are also provided. Dot-notation + * object paths only in v1. */ + selector: (l: SQLWrapper, path: string) => selectorOps(l, path), /** Membership: ORs one encrypted `eq` term per value. The whole list is * encrypted in one `encryptQuery` batch crossing. Rejects an empty list; * requires a `unique` or `ore` index. */ diff --git a/packages/stack-drizzle/src/v3/sql-dialect.ts b/packages/stack-drizzle/src/v3/sql-dialect.ts index 38a3a5f1a..641663034 100644 --- a/packages/stack-drizzle/src/v3/sql-dialect.ts +++ b/packages/stack-drizzle/src/v3/sql-dialect.ts @@ -64,6 +64,23 @@ export const v3Dialect = { return sql`${left} OPERATOR(public.@>) ${enc}` }, + /** + * Extract the encrypted JSONB leaf entry at a selector: `eql_v3."->"(src, sel)` + * → `eql_v3_jsonb_entry`. `src` is either an `eql_v3_json` column or a needle + * document literal already cast to `eql_v3_json`; `sel` is the bare HMAC + * selector hash bound as `text`. The returned entry is what the + * `eql_v3.{eq,neq,lt,lte,gt,gte}(jsonb_entry, jsonb_entry)` comparators take — + * so a selector-with-constraint is `equality`/`comparison` applied to two of + * these extractions (column side and needle side) rather than column vs operand. + * + * The root `eql_v3_json` domain has NO comparison operators (they're blocked in + * the bundle), which is why the selector must be extracted before comparing. + * `eql_v3."->"` is quoted because `->` is an operator-named function. + */ + selectorEntry(source: SQL, sel: SQL): SQL { + return sql`${fn('"->"')}(${source}, ${sel})` + }, + orderBy(left: SQL, flavour: 'ope' | 'ore'): SQL { // eql-3.0.0 splits the ordering extractor by term flavour: `ord_term` // takes the OPE-backed `_ord` domains (returns eql_v3_internal.ope_cllw), From 64e8998a55fde9d159f5c0cb7d15fbdf417c7945 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 15 Jul 2026 08:55:14 +1000 Subject: [PATCH 2/6] refactor(stack-drizzle): ciphertext-free v3 JSON selector-with-constraint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rework the selector path to be entirely ciphertext-free (the storage-payload RHS was wrong — it leaked ciphertext into the WHERE clause). Both operands are query terms; the comparison reduces to the encrypted term on each side: eql_v3.(eql_v3.jsonb_path_query_first(col,'')) eql_v3.() where is eq_term (=/<>) or ord_term (/>=) — both collapse to bytea (hmac_256 / ope_cllw) and compare natively. - selector hash: encryptQuery(path, searchableJson) (string → ste_vec_selector). - RHS: a scalar encryptQuery(value, { queryType: equality|orderAndRange }) term cast to eql_v3.query__, T inferred from the leaf value's JS type. - Drops the client `encrypt` requirement and the doc reconstruction entirely. Assumes protect-ffi keys the scalar query term with the column's key (verified by the live tests). The bundle lacks a (jsonb_entry, query_) operator, so the direct-function form is used; adding it upstream is an EQL follow-up. Refs #623 Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- .../json-selector.integration.test.ts | 12 +- packages/stack-drizzle/src/v3/operators.ts | 133 +++++++++--------- packages/stack-drizzle/src/v3/sql-dialect.ts | 45 ++++-- 3 files changed, 103 insertions(+), 87 deletions(-) diff --git a/packages/stack-drizzle/integration/json-selector.integration.test.ts b/packages/stack-drizzle/integration/json-selector.integration.test.ts index b6e620a3a..11208e804 100644 --- a/packages/stack-drizzle/integration/json-selector.integration.test.ts +++ b/packages/stack-drizzle/integration/json-selector.integration.test.ts @@ -1,13 +1,13 @@ /** * Live JSONPath selector-with-constraint for the v3 `types.Json()` column (#623). * Distinct from containment (`ops.contains`, `@>`): this extracts the encrypted - * leaf entry at a JSONPath and compares it, so it can express ORDERING at a path - * (`col->'$.age' > 25`) that containment cannot. Emits - * `eql_v3.(eql_v3."->"(col, ''), eql_v3."->"(''::eql_v3_json, ''))`. + * leaf term at a JSONPath and compares it, so it can express ORDERING at a path + * (`col->'$.age' > 25`) that containment cannot. Entirely ciphertext-free — emits + * `eql_v3.(eql_v3.jsonb_path_query_first(col, '')) eql_v3.()`, + * where both sides reduce to the encrypted term (hmac_256 / ope_cllw). * - * The selector hash comes from `encryptQuery(path, searchableJson)`; the - * comparison entry from a STORAGE `encrypt` of `{path: value}` (its ciphertext - * `c` is required by the `eql_v3_jsonb_entry` domain — a query term can't carry it). + * The selector hash comes from `encryptQuery(path, searchableJson)`; the RHS from + * a scalar `encryptQuery(value, { queryType })` term cast to `eql_v3.query__`. */ import type { JsonDocument } from '@cipherstash/stack/eql/v3' diff --git a/packages/stack-drizzle/src/v3/operators.ts b/packages/stack-drizzle/src/v3/operators.ts index 0476552c2..36b089c3f 100644 --- a/packages/stack-drizzle/src/v3/operators.ts +++ b/packages/stack-drizzle/src/v3/operators.ts @@ -66,58 +66,42 @@ type OperandEncryptionClient = { opts: never, ): ChainableOperation encryptQuery(terms: never): ChainableOperation - /** - * Storage encryption. Needed ONLY by the JSON selector-with-constraint path: - * `col->'sel' value` compares two `eql_v3_jsonb_entry` values, and that - * domain requires ciphertext `c` — which a ciphertext-free query term cannot - * carry. So the right-hand entry is a STORAGE encryption of `{path: value}`, - * from which the selector entry is extracted (see `selectorCompare`). - */ - encrypt(value: never, opts: never): ChainableOperation +} + +/** `$`-root a bare path so the selector needle addresses from the document root. */ +function normalizeJsonPath(path: string): string { + return path.startsWith('$') ? path : `$.${path}` } /** - * Reconstruct the nested object a JSONPath addresses, with `value` at the leaf: - * `$.bar` → `{ bar: value }`, `$.wee.deep` → `{ wee: { deep: value } }`. This is - * the storage-needle document whose ste_vec entry at `path` supplies the - * ciphertext-bearing comparison entry. - * - * v1 supports dot-notation OBJECT keys only. Array-index / wildcard paths are - * rejected: the storage needle can't be reconstructed for them yet (a follow-up). + * Reject array/wildcard selector paths (v1 supports object keys only). The + * `->` selector addresses a single leaf; array-index / wildcard paths are a + * follow-up. */ -function reconstructSelectorDocument( - path: string, - value: unknown, -): Record { - const bare = path.replace(/^\$\.?/, '') - const segments = bare.split('.').filter((segment) => segment.length > 0) - if (segments.length === 0) { - throw new Error(`JSON selector path "${path}" has no field segments.`) - } - for (const segment of segments) { - if (/[[\]*]/.test(segment)) { - throw new Error( - `JSON selector path "${path}" uses array/wildcard syntax, which is not yet supported — use dot-notation object keys (e.g. "$.a.b").`, - ) - } +function assertObjectPath(path: string): void { + if (/[[\]*]/.test(path)) { + throw new Error( + `JSON selector path "${path}" uses array/wildcard syntax, which is not yet supported — use dot-notation object keys (e.g. "$.a.b").`, + ) } - const root: Record = {} - let cursor = root - segments.forEach((segment, index) => { - if (index === segments.length - 1) { - cursor[segment] = value - } else { - const next: Record = {} - cursor[segment] = next - cursor = next - } - }) - return root } -/** `$`-root a bare path so the selector needle matches the reconstructed doc. */ -function normalizeJsonPath(path: string): string { - return path.startsWith('$') ? path : `$.${path}` +/** + * The `eql_v3.query__` domain the ciphertext-free selector operand + * casts to, inferred from the leaf value's JS type. `` must match how + * `encryptQuery` typed the term (protect-ffi keys the scalar term with the + * column's key — see #623 discussion), which the live tests verify. + */ +function queryDomainSuffix(value: unknown, kind: 'eq' | 'ord'): string | null { + let base: string | null + if (typeof value === 'bigint') base = 'bigint' + else if (typeof value === 'string') base = 'text' + else if (value instanceof Date) base = 'timestamp' + else if (typeof value === 'number') + base = Number.isInteger(value) ? 'integer' : 'double' + else base = null // boolean has no eq/ord query domain; unsupported here + if (base === null) return null + return `${base}_${kind}` } /** @@ -625,19 +609,18 @@ export function createEncryptionOperatorsV3( /** * JSONPath selector-with-constraint on an `eql_v3_json` (`ste_vec`) column: - * `col->'path' value`. Extracts the encrypted leaf entry at `path` on both - * sides and compares them with the `eql_v3_jsonb_entry` comparators. + * `col->'path' value`, entirely ciphertext-free. Both operands are query + * terms; the comparison reduces to the encrypted term on each side (see + * `v3Dialect.selectorConstraint`). * - * Two operands, encrypted differently: * - the SELECTOR (`path`): `encryptQuery(path, searchableJson)` on a string - * needle infers a `ste_vec_selector` term → the bare HMAC selector hash, bound - * as the `text` argument of `eql_v3."->"`. - * - the VALUE (right-hand entry): a STORAGE encryption of the reconstructed - * `{path: value}` document — its ste_vec entry carries the ciphertext `c` the - * `eql_v3_jsonb_entry` domain requires (a query term is ciphertext-free and - * would fail the domain CHECK). The same selector extracts it from the needle. + * needle infers a `ste_vec_selector` term → the bare HMAC selector hash, the + * `text` argument of `jsonb_path_query_first`. + * - the VALUE: a scalar query term for the leaf — `encryptQuery(value, + * { queryType: equality | orderAndRange })` — cast to `eql_v3.query__`. + * No ciphertext, no storage encryption. * - * Note equality-at-selector is also expressible via `contains(col, {path: value})`; + * Equality-at-selector is also expressible via `contains(col, {path: value})`; * this path's unique power is ORDERING at a selector (`gt`/`gte`/`lt`/`lte`). */ async function selectorCompare( @@ -656,7 +639,19 @@ export function createEncryptionOperatorsV3( 'JSON selector (searchableJson)', ) requireNonNullOperand(ctx, value, operator) + assertObjectPath(path) + + const isEquality = op === 'eq' || op === 'ne' + const suffix = queryDomainSuffix(value, isEquality ? 'eq' : 'ord') + if (suffix === null) { + throw operandFailure( + ctx, + operator, + `no ${isEquality ? 'equality' : 'ordering'} query domain for a ${typeof value} leaf value.`, + ) + } + // Selector hash (LHS): a string needle → ste_vec_selector → bare HMAC hash. const selResult = await applyOperationOptions( client.encryptQuery( normalizeJsonPath(path) as never, @@ -671,33 +666,33 @@ export function createEncryptionOperatorsV3( if (selResult.failure) { throw operandFailure(ctx, operator, selResult.failure.message) } - // A v3 selector term is the bare HMAC hash string; guard the shape so a - // wrapped envelope can't silently bind as JSON text. const selValue = selResult.data const selHash = typeof selValue === 'string' ? selValue : JSON.stringify(selValue) - const encResult = await applyOperationOptions( - client.encrypt( - reconstructSelectorDocument(path, value) as never, + // Value (RHS): a ciphertext-free scalar query term for the leaf. + const valResult = await applyOperationOptions( + client.encryptQuery( + value as never, { table: ctx.table, column: ctx.builder, + queryType: (isEquality ? 'equality' : 'orderAndRange') as never, } as never, ), opts, ) - if (encResult.failure) { - throw operandFailure(ctx, operator, encResult.failure.message) + if (valResult.failure) { + throw operandFailure(ctx, operator, valResult.failure.message) } - const selSql = sql`${selHash}::text` - const needleDoc = sql`${JSON.stringify(encResult.data)}::public.eql_v3_json` - const leftEntry = v3Dialect.selectorEntry(colSql(col), selSql) - const rightEntry = v3Dialect.selectorEntry(needleDoc, selSql) - return op === 'eq' || op === 'ne' - ? v3Dialect.equality(op, leftEntry, rightEntry) - : v3Dialect.comparison(op, leftEntry, rightEntry) + const operand = sql`${JSON.stringify(valResult.data)}::eql_v3.query_${sql.raw(suffix)}` + return v3Dialect.selectorConstraint( + op, + colSql(col), + sql`${selHash}::text`, + operand, + ) } /** Comparison methods bound to a `col->'path'` selector, mirroring the scalar diff --git a/packages/stack-drizzle/src/v3/sql-dialect.ts b/packages/stack-drizzle/src/v3/sql-dialect.ts index 641663034..3b7e50108 100644 --- a/packages/stack-drizzle/src/v3/sql-dialect.ts +++ b/packages/stack-drizzle/src/v3/sql-dialect.ts @@ -65,20 +65,41 @@ export const v3Dialect = { }, /** - * Extract the encrypted JSONB leaf entry at a selector: `eql_v3."->"(src, sel)` - * → `eql_v3_jsonb_entry`. `src` is either an `eql_v3_json` column or a needle - * document literal already cast to `eql_v3_json`; `sel` is the bare HMAC - * selector hash bound as `text`. The returned entry is what the - * `eql_v3.{eq,neq,lt,lte,gt,gte}(jsonb_entry, jsonb_entry)` comparators take — - * so a selector-with-constraint is `equality`/`comparison` applied to two of - * these extractions (column side and needle side) rather than column vs operand. + * A ciphertext-free JSONPath selector-with-constraint on an `eql_v3_json` + * column. Both sides reduce to the encrypted TERM — `eql_v3.hmac_256` for + * equality, `eql_v3_internal.ope_cllw` for ordering — which compare natively + * (bytea), so no ciphertext ever reaches the WHERE clause: * - * The root `eql_v3_json` domain has NO comparison operators (they're blocked in - * the bundle), which is why the selector must be extracted before comparing. - * `eql_v3."->"` is quoted because `->` is an operator-named function. + * ```sql + * eql_v3.(eql_v3.jsonb_path_query_first(col, '')) eql_v3.() + * ``` + * + * - LHS: `jsonb_path_query_first` returns the stored `eql_v3_jsonb_entry` at + * the selector; `eq_term`/`ord_term` read only its `hm`/`op`. + * - RHS: `` is a ciphertext-free scalar query term + * (`eql_v3.query__eq` / `_ord`), already cast by the operator layer. + * - `` is `eq_term` for `eq`/`ne` (compared `=`/`<>`) and `ord_term` for + * `gt`/`gte`/`lt`/`lte`. + * + * The direct-function form is used because the bundle has no + * `(eql_v3_jsonb_entry, eql_v3.query_)` operator (only the scalar domains + * got those); adding it upstream would let this collapse to an operator and + * gain functional-index matching — see the EQL follow-up. */ - selectorEntry(source: SQL, sel: SQL): SQL { - return sql`${fn('"->"')}(${source}, ${sel})` + selectorConstraint( + op: EqualityOp | ComparisonOp, + col: SQL, + selector: SQL, + operand: SQL, + ): SQL { + const isEquality = op === 'eq' || op === 'ne' + const term = isEquality ? 'eq_term' : 'ord_term' + const cmp = { eq: '=', ne: '<>', gt: '>', gte: '>=', lt: '<', lte: '<=' }[ + op + ] + const lhs = sql`${fn(term)}(${fn('jsonb_path_query_first')}(${col}, ${selector}))` + const rhs = sql`${fn(term)}(${operand})` + return sql`${lhs} ${sql.raw(cmp)} ${rhs}` }, orderBy(left: SQL, flavour: 'ope' | 'ore'): SQL { From b5a5f2692347a47befb05b9462e0aed9f8e37fda Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 15 Jul 2026 13:30:17 +1000 Subject: [PATCH 3/6] =?UTF-8?q?fix(stack-drizzle):=20v3=20JSON=20selector?= =?UTF-8?q?=20=E2=80=94=20storage-needle=20RHS=20(interim)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ciphertext-free approach was non-functional: encryptQuery can't mint a scalar/ordering query term against a ste_vec (JSON) column, so the value call threw "Index type not configured" (caught by CI's live integration job). Verified via FFI probes: the stored ste_vec entry at the JSONPath selector carries c + op (ordering) / hm (equality), and encryptQuery('$.path') returns the selector that matches it. So the RHS is a STORAGE encryption of {path: value} whose entry is extracted with jsonb_path_query_first and compared entry-vs-entry (eq_term/ord_term read only hm/op). Ciphertext-free ordering needs a protect-ffi change (cipherstash/protectjs-ffi#137); until then the value's ciphertext is in the WHERE clause — documented inline. Also from the code review: use Promise.all for the two independent encrypts; throw (not silently mis-bind) if the selector term isn't a bare string; fix path normalization (leading $/.) and reject the empty/root path; drop the fragile JS-type→query-domain inference entirely. Refs #623 · protect-ffi gap cipherstash/protectjs-ffi#137 · EQL operator #404 Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- .changeset/eql-v3-json-selector.md | 7 +- .../json-selector.integration.test.ts | 13 +- packages/stack-drizzle/src/v3/operators.ts | 171 +++++++++++------- packages/stack-drizzle/src/v3/sql-dialect.ts | 44 ++--- 4 files changed, 125 insertions(+), 110 deletions(-) diff --git a/.changeset/eql-v3-json-selector.md b/.changeset/eql-v3-json-selector.md index af1fc3fcd..e65a6ac89 100644 --- a/.changeset/eql-v3-json-selector.md +++ b/.changeset/eql-v3-json-selector.md @@ -10,7 +10,12 @@ over `contains` is **ordering at a path** (`col->'$.age' > 21`), which containment cannot express. Complements the existing `contains` (JSONB `@>`) containment operator. Core -`@cipherstash/stack` needs no change — the selector term and comparison entry are +`@cipherstash/stack` needs no change — the selector hash and comparison entry are produced by `encryptQuery`/`encrypt` on the existing `types.Json` surface. v1 supports dot-notation object paths; array-index/wildcard paths are rejected with a clear error. The Supabase adapter is tracked separately. + +The right-hand comparison operand is currently a storage-encrypted needle (its +ste_vec entry carries the ordering term), pending a ciphertext-free ordering +query needle from protect-ffi (cipherstash/protectjs-ffi#137); until then the +value's ciphertext appears in the WHERE clause. diff --git a/packages/stack-drizzle/integration/json-selector.integration.test.ts b/packages/stack-drizzle/integration/json-selector.integration.test.ts index 11208e804..94f17b2c1 100644 --- a/packages/stack-drizzle/integration/json-selector.integration.test.ts +++ b/packages/stack-drizzle/integration/json-selector.integration.test.ts @@ -1,13 +1,14 @@ /** * Live JSONPath selector-with-constraint for the v3 `types.Json()` column (#623). * Distinct from containment (`ops.contains`, `@>`): this extracts the encrypted - * leaf term at a JSONPath and compares it, so it can express ORDERING at a path - * (`col->'$.age' > 25`) that containment cannot. Entirely ciphertext-free — emits - * `eql_v3.(eql_v3.jsonb_path_query_first(col, '')) eql_v3.()`, - * where both sides reduce to the encrypted term (hmac_256 / ope_cllw). + * leaf entry at a JSONPath and compares it, so it can express ORDERING at a path + * (`col->'$.age' > 25`) that containment cannot. Emits + * `eql_v3.(eql_v3.jsonb_path_query_first(col, ''), eql_v3.jsonb_path_query_first(''::eql_v3_json, ''))`. * - * The selector hash comes from `encryptQuery(path, searchableJson)`; the RHS from - * a scalar `encryptQuery(value, { queryType })` term cast to `eql_v3.query__`. + * INTERIM (cipherstash/protectjs-ffi#137): the RHS needle is a STORAGE encryption + * of `{path: value}` — its ste_vec entry carries `c` + `op`/`hm`, which the + * comparison extracts. Once protect-ffi can mint a ciphertext-free ordering query + * needle for a ste_vec column, the RHS drops the ciphertext. */ import type { JsonDocument } from '@cipherstash/stack/eql/v3' diff --git a/packages/stack-drizzle/src/v3/operators.ts b/packages/stack-drizzle/src/v3/operators.ts index 36b089c3f..617b1ff2c 100644 --- a/packages/stack-drizzle/src/v3/operators.ts +++ b/packages/stack-drizzle/src/v3/operators.ts @@ -66,42 +66,64 @@ type OperandEncryptionClient = { opts: never, ): ChainableOperation encryptQuery(terms: never): ChainableOperation -} - -/** `$`-root a bare path so the selector needle addresses from the document root. */ -function normalizeJsonPath(path: string): string { - return path.startsWith('$') ? path : `$.${path}` + /** + * Storage encryption — the JSON selector RHS. See {@link selectorCompare}: + * pending a ciphertext-free ordering query needle from protect-ffi + * (cipherstash/protectjs-ffi#137), the right-hand operand is a STORAGE + * encryption of `{path: value}`, whose ste_vec entry at the selector carries + * the `c` + `op`/`hm` the comparison extracts. + */ + encrypt(value: never, opts: never): ChainableOperation } /** - * Reject array/wildcard selector paths (v1 supports object keys only). The - * `->` selector addresses a single leaf; array-index / wildcard paths are a - * follow-up. + * Parse a dot-notation JSONPath into its object-key segments, rejecting + * array/wildcard syntax (v1 supports object keys only) and the empty/root path. + * `'$.a.b'` / `'a.b'` → `['a', 'b']`. Mirrors core `toJsonPath` normalization + * (which isn't exported publicly) so leading `$`/`.` are handled correctly. */ -function assertObjectPath(path: string): void { - if (/[[\]*]/.test(path)) { +function parseSelectorSegments(path: string): string[] { + const bare = path.replace(/^\$/, '').replace(/^\./, '') + if (/[[\]*]/.test(bare)) { throw new Error( `JSON selector path "${path}" uses array/wildcard syntax, which is not yet supported — use dot-notation object keys (e.g. "$.a.b").`, ) } + const segments = bare.split('.').filter((segment) => segment.length > 0) + if (segments.length === 0) { + throw new Error( + `JSON selector path "${path}" addresses no field — use e.g. "$.a" or "$.a.b".`, + ) + } + return segments +} + +/** `$`-rooted JSONPath for `encryptQuery`'s selector needle. */ +function jsonPathOf(segments: string[]): string { + return `$.${segments.join('.')}` } /** - * The `eql_v3.query__` domain the ciphertext-free selector operand - * casts to, inferred from the leaf value's JS type. `` must match how - * `encryptQuery` typed the term (protect-ffi keys the scalar term with the - * column's key — see #623 discussion), which the live tests verify. + * Nest `value` under the segments: `['a','b']` → `{ a: { b: value } }`. The + * storage-needle document whose ste_vec entry at the path supplies the + * ciphertext-bearing comparison entry. */ -function queryDomainSuffix(value: unknown, kind: 'eq' | 'ord'): string | null { - let base: string | null - if (typeof value === 'bigint') base = 'bigint' - else if (typeof value === 'string') base = 'text' - else if (value instanceof Date) base = 'timestamp' - else if (typeof value === 'number') - base = Number.isInteger(value) ? 'integer' : 'double' - else base = null // boolean has no eq/ord query domain; unsupported here - if (base === null) return null - return `${base}_${kind}` +function reconstructSelectorDocument( + segments: string[], + value: unknown, +): Record { + const root: Record = {} + let cursor = root + segments.forEach((segment, index) => { + if (index === segments.length - 1) { + cursor[segment] = value + } else { + const next: Record = {} + cursor[segment] = next + cursor = next + } + }) + return root } /** @@ -639,60 +661,69 @@ export function createEncryptionOperatorsV3( 'JSON selector (searchableJson)', ) requireNonNullOperand(ctx, value, operator) - assertObjectPath(path) - - const isEquality = op === 'eq' || op === 'ne' - const suffix = queryDomainSuffix(value, isEquality ? 'eq' : 'ord') - if (suffix === null) { - throw operandFailure( - ctx, - operator, - `no ${isEquality ? 'equality' : 'ordering'} query domain for a ${typeof value} leaf value.`, - ) - } - - // Selector hash (LHS): a string needle → ste_vec_selector → bare HMAC hash. - const selResult = await applyOperationOptions( - client.encryptQuery( - normalizeJsonPath(path) as never, - { - table: ctx.table, - column: ctx.builder, - queryType: 'searchableJson', - } as never, + const segments = parseSelectorSegments(path) + + // INTERIM (cipherstash/protectjs-ffi#137): the RHS is a STORAGE-encrypted + // needle, not a ciphertext-free query term. protect-ffi can't yet mint an + // ordering (`op`) query needle for a ste_vec column — `encryptQuery` only + // produces the `hm` equality term. So we storage-encrypt `{path: value}`, + // whose ste_vec entry at the selector carries `c` + `op`/`hm`, and extract + // that entry on the RHS. `eq_term`/`ord_term` read only `hm`/`op`, so the + // comparison is correct; the tradeoff is the value's ciphertext appears in + // the WHERE clause. Once #137 lands, the RHS becomes a ciphertext-free term. + // + // The selector hash and the storage needle are independent → encrypt + // concurrently (mirrors `range`). + const [selResult, docResult] = await Promise.all([ + applyOperationOptions( + client.encryptQuery( + jsonPathOf(segments) as never, + { + table: ctx.table, + column: ctx.builder, + queryType: 'searchableJson', + } as never, + ), + opts, ), - opts, - ) + applyOperationOptions( + client.encrypt( + reconstructSelectorDocument(segments, value) as never, + { + table: ctx.table, + column: ctx.builder, + } as never, + ), + opts, + ), + ]) if (selResult.failure) { throw operandFailure(ctx, operator, selResult.failure.message) } - const selValue = selResult.data - const selHash = - typeof selValue === 'string' ? selValue : JSON.stringify(selValue) + if (docResult.failure) { + throw operandFailure(ctx, operator, docResult.failure.message) + } - // Value (RHS): a ciphertext-free scalar query term for the leaf. - const valResult = await applyOperationOptions( - client.encryptQuery( - value as never, - { - table: ctx.table, - column: ctx.builder, - queryType: (isEquality ? 'equality' : 'orderAndRange') as never, - } as never, - ), - opts, - ) - if (valResult.failure) { - throw operandFailure(ctx, operator, valResult.failure.message) + // A v3 selector term is the bare HMAC hash string; guard the shape so a + // wrapped envelope can't silently bind as a JSON blob and match no rows. + const selValue = selResult.data + if (typeof selValue !== 'string') { + throw operandFailure( + ctx, + operator, + `expected a bare selector hash, got ${typeof selValue}.`, + ) } - const operand = sql`${JSON.stringify(valResult.data)}::eql_v3.query_${sql.raw(suffix)}` - return v3Dialect.selectorConstraint( - op, - colSql(col), - sql`${selHash}::text`, - operand, + const selSql = sql`${selValue}::text` + const leftEntry = v3Dialect.selectorEntry(colSql(col), selSql) + const rightEntry = v3Dialect.selectorEntry( + sql`${JSON.stringify(docResult.data)}::public.eql_v3_json`, + selSql, ) + return op === 'eq' || op === 'ne' + ? v3Dialect.equality(op, leftEntry, rightEntry) + : v3Dialect.comparison(op, leftEntry, rightEntry) } /** Comparison methods bound to a `col->'path'` selector, mirroring the scalar diff --git a/packages/stack-drizzle/src/v3/sql-dialect.ts b/packages/stack-drizzle/src/v3/sql-dialect.ts index 3b7e50108..aac0ae0b1 100644 --- a/packages/stack-drizzle/src/v3/sql-dialect.ts +++ b/packages/stack-drizzle/src/v3/sql-dialect.ts @@ -65,41 +65,19 @@ export const v3Dialect = { }, /** - * A ciphertext-free JSONPath selector-with-constraint on an `eql_v3_json` - * column. Both sides reduce to the encrypted TERM — `eql_v3.hmac_256` for - * equality, `eql_v3_internal.ope_cllw` for ordering — which compare natively - * (bytea), so no ciphertext ever reaches the WHERE clause: + * Extract the encrypted JSONB leaf entry at a selector: + * `eql_v3.jsonb_path_query_first(src, sel)` → `eql_v3_jsonb_entry`. `src` is + * either an `eql_v3_json` column or a storage-needle document already cast to + * `eql_v3_json`; `sel` is the selector hash bound as `text`. The returned entry + * feeds `eql_v3.{eq,neq,lt,lte,gt,gte}(jsonb_entry, jsonb_entry)`, so a selector + * comparison is `equality`/`comparison` applied to two extractions (column side + * and needle side) rather than column vs operand. * - * ```sql - * eql_v3.(eql_v3.jsonb_path_query_first(col, '')) eql_v3.() - * ``` - * - * - LHS: `jsonb_path_query_first` returns the stored `eql_v3_jsonb_entry` at - * the selector; `eq_term`/`ord_term` read only its `hm`/`op`. - * - RHS: `` is a ciphertext-free scalar query term - * (`eql_v3.query__eq` / `_ord`), already cast by the operator layer. - * - `` is `eq_term` for `eq`/`ne` (compared `=`/`<>`) and `ord_term` for - * `gt`/`gte`/`lt`/`lte`. - * - * The direct-function form is used because the bundle has no - * `(eql_v3_jsonb_entry, eql_v3.query_)` operator (only the scalar domains - * got those); adding it upstream would let this collapse to an operator and - * gain functional-index matching — see the EQL follow-up. + * The root `eql_v3_json` domain has no comparison operators (they're blocked in + * the bundle), which is why the selector must be extracted before comparing. */ - selectorConstraint( - op: EqualityOp | ComparisonOp, - col: SQL, - selector: SQL, - operand: SQL, - ): SQL { - const isEquality = op === 'eq' || op === 'ne' - const term = isEquality ? 'eq_term' : 'ord_term' - const cmp = { eq: '=', ne: '<>', gt: '>', gte: '>=', lt: '<', lte: '<=' }[ - op - ] - const lhs = sql`${fn(term)}(${fn('jsonb_path_query_first')}(${col}, ${selector}))` - const rhs = sql`${fn(term)}(${operand})` - return sql`${lhs} ${sql.raw(cmp)} ${rhs}` + selectorEntry(source: SQL, selector: SQL): SQL { + return sql`${fn('jsonb_path_query_first')}(${source}, ${selector})` }, orderBy(left: SQL, flavour: 'ope' | 'ore'): SQL { From f45963fe3c5cde9d68b8ca2d2a8f75d88fe5c4b6 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 15 Jul 2026 13:37:49 +1000 Subject: [PATCH 4/6] docs(skills): document v3 JSON selector-with-constraint (#623) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Update stash-encryption + stash-drizzle skills for the new ops.selector(col, '$.path').{eq,ne,gt,gte,lt,lte} surface — they previously said JSONPath selector queries were 'not yet implemented'. Clarify that direct eq/gt/asc on a Json column still throw; selector-with-constraint is the path-scoped form, with ordering-at-a-path its unique power over containment. Skills ship in the stash tarball, so this carries a stash patch changeset. Refs #623 Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- .changeset/eql-v3-json-selector.md | 5 +++++ skills/stash-drizzle/SKILL.md | 4 +++- skills/stash-encryption/SKILL.md | 22 +++++++++++++--------- 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/.changeset/eql-v3-json-selector.md b/.changeset/eql-v3-json-selector.md index e65a6ac89..866462644 100644 --- a/.changeset/eql-v3-json-selector.md +++ b/.changeset/eql-v3-json-selector.md @@ -1,5 +1,6 @@ --- '@cipherstash/stack-drizzle': minor +'stash': patch --- Add EQL v3 JSON **selector-with-constraint** querying to the Drizzle integration @@ -19,3 +20,7 @@ The right-hand comparison operand is currently a storage-encrypted needle (its ste_vec entry carries the ordering term), pending a ciphertext-free ordering query needle from protect-ffi (cipherstash/protectjs-ffi#137); until then the value's ciphertext appears in the WHERE clause. + +The bundled `stash-encryption` and `stash-drizzle` skills document the new +`ops.selector(...)` surface (they previously said JSONPath selector queries were +not yet implemented). diff --git a/skills/stash-drizzle/SKILL.md b/skills/stash-drizzle/SKILL.md index 2b6dc44ab..61a75056b 100644 --- a/skills/stash-drizzle/SKILL.md +++ b/skills/stash-drizzle/SKILL.md @@ -779,6 +779,7 @@ const dec = await client.bulkDecryptModels(rows, usersSchema) | `gt`, `gte`, `lt`, `lte`, `between`, `notBetween`, `asc`, `desc` | order/range (`Ord`, `OrdOre`, `TextSearch`) | | `matches` | fuzzy free-text token match (`TextMatch`, `TextSearch`) | | `contains` | exact encrypted-JSONB containment (`Json`) | +| `selector(col, '$.path').{eq,ne,gt,gte,lt,lte}` | JSONPath selector-with-constraint on a `Json` column — ordering/equality at a path | | `and`, `or` | combinators — accept lazy (un-awaited) operators and `undefined`, resolve concurrently | | `isNull`, `isNotNull`, `not`, `exists`, `notExists` | Drizzle passthroughs, no encryption | @@ -786,7 +787,8 @@ Differences from the v2 operators to know about: - **`like` / `ilike` do not exist — by design.** v3 free-text search is tokenised bloom matching, not SQL pattern matching; `matches(col, needle)` is the free-text operator. It is fuzzy (order- and multiplicity-insensitive) and one-sided (a match may be a false positive, a non-match never is). Don't pass `%` wildcards. - **`matches` is fuzzy free-text, `contains` is exact JSON containment — two distinct operators (#617).** `matches(col, needle)` requires a `TextMatch` / `TextSearch` column and throws `EncryptionOperatorError` (`requires free-text search`) otherwise. `contains(col, subdoc)` requires a `types.Json` column and throws (`requires JSON containment`) otherwise. -- **`contains` on a `types.Json` column** answers exact encrypted-JSONB containment: `contains(col, { roles: ['admin'] })` matches every row whose document contains that sub-object (jsonb `@>` semantics, no false positives; array containment is position-independent). It emits the `@>` operator with a `query_jsonb` needle — a `Json` column carries no `eq` / ordering, so those operators throw on it. +- **`contains` on a `types.Json` column** answers exact encrypted-JSONB containment: `contains(col, { roles: ['admin'] })` matches every row whose document contains that sub-object (jsonb `@>` semantics, no false positives; array containment is position-independent). It emits the `@>` operator with a `query_jsonb` needle. Applying `eq` / `gt` / `asc` **directly** to a `Json` column throws — use `selector(col, '$.path')` for equality/ordering **at a path** (next bullet). +- **`selector(col, '$.path')` — JSONPath selector-with-constraint on a `types.Json` column.** Returns comparison methods bound to a path: `await ops.selector(events.metadata, '$.age').gt(21)`. Methods: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Its unique power over `contains` is **ordering at a path** (`gt`/`gte`/`lt`/`lte`); `eq`/`ne` are also provided (equality at a path is equivalently `contains(col, { age: 21 })`). v1 supports dot-notation object paths (`$.a`, `$.a.b`); array-index/wildcard and empty/root paths are rejected with a clear `Error`. - **No plaintext-column fallback.** Every v3 operator requires an encrypted v3 column and throws `EncryptionOperatorError` otherwise. Use regular Drizzle operators for non-encrypted columns. - A `null` operand throws — use `isNull()` / `isNotNull()` for NULL checks. - `inArray` / `notInArray` reject an empty list, and encrypt the whole list in a single `encryptQuery` batch crossing. diff --git a/skills/stash-encryption/SKILL.md b/skills/stash-encryption/SKILL.md index a90b25b7d..1a31c5e9a 100644 --- a/skills/stash-encryption/SKILL.md +++ b/skills/stash-encryption/SKILL.md @@ -632,7 +632,7 @@ Each factory in `types` maps 1:1 to a Postgres domain named `public.eql_v3_ **Not yet implemented:** JSONPath selector-with-constraint queries -> (`metadata->'plan' = $1`, `metadata->'age' > $1`) — a distinct third pattern -> the `eql_v3_json` domain supports at the SQL level (`->` / `->>`). Neither the -> query operator nor the selector-string needle typing is wired up yet; tracked -> in [#623](https://github.com/cipherstash/stack/issues/623). +the `stash-drizzle` skill. + +**JSONPath selector-with-constraint** is the second query pattern: it compares +the encrypted value at a JSONPath, e.g. `metadata->'age' > 21`. Through the +Drizzle v3 integration this is `ops.selector(col, '$.path').{eq,ne,gt,gte,lt,lte}(value)`. +Its unique power over containment is **ordering at a path** +(`ops.selector(events.metadata, '$.age').gt(21)`); equality at a path is also +expressible as containment (`contains(col, { age: 21 })`). v1 supports +dot-notation object paths (`$.a`, `$.a.b`); array-index/wildcard and empty/root +paths are rejected with a clear error. Applying `eq` / `gt` / `asc` **directly** +to a `types.Json` column (rather than through `ops.selector`) still throws — the +column declares no scalar capabilities of its own. ### Strongly-Typed Client: `EncryptionV3` From b5b18c328ed54a90211870668e8ca49f07b649ba Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 15 Jul 2026 14:02:52 +1000 Subject: [PATCH 5/6] fix(stack-drizzle): address CodeRabbit review on v3 JSON selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - reconstructSelectorDocument: use null-prototype objects so a `__proto__` path segment becomes an own key instead of invoking the prototype setter (which would mis-serialize the needle → no matches). - Fix stale selectorCompare JSDoc: it still described the reverted ciphertext-free approach ('No ciphertext, no storage encryption') and referenced the renamed selectorConstraint. Now documents the storage-needle interim consistently. - Disclose the interim ciphertext-in-WHERE behavior in both stash-drizzle and stash-encryption skills (each must be self-sufficient). Skipped: CodeRabbit's DROP-TABLE-IF-EXISTS suggestion — the sibling json-contains integration test uses the same DROP+CREATE pattern and the RUN discriminator isolates rows; hardening should be repo-wide, not one file. Refs #623 Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- packages/stack-drizzle/src/v3/operators.ts | 22 ++++++++++++++-------- skills/stash-drizzle/SKILL.md | 2 +- skills/stash-encryption/SKILL.md | 4 ++++ 3 files changed, 19 insertions(+), 9 deletions(-) diff --git a/packages/stack-drizzle/src/v3/operators.ts b/packages/stack-drizzle/src/v3/operators.ts index 617b1ff2c..1b9274457 100644 --- a/packages/stack-drizzle/src/v3/operators.ts +++ b/packages/stack-drizzle/src/v3/operators.ts @@ -112,13 +112,16 @@ function reconstructSelectorDocument( segments: string[], value: unknown, ): Record { - const root: Record = {} + // Null-prototype objects: a segment like `__proto__` must become an OWN key, + // not invoke the prototype setter (which would drop it and mis-serialize the + // needle). JSON.stringify ignores the [[Prototype]], so this serializes fine. + const root: Record = Object.create(null) let cursor = root segments.forEach((segment, index) => { if (index === segments.length - 1) { cursor[segment] = value } else { - const next: Record = {} + const next: Record = Object.create(null) cursor[segment] = next cursor = next } @@ -631,16 +634,19 @@ export function createEncryptionOperatorsV3( /** * JSONPath selector-with-constraint on an `eql_v3_json` (`ste_vec`) column: - * `col->'path' value`, entirely ciphertext-free. Both operands are query - * terms; the comparison reduces to the encrypted term on each side (see - * `v3Dialect.selectorConstraint`). + * `col->'path' value`. Extracts the encrypted leaf entry at `path` on both + * sides (`v3Dialect.selectorEntry` → `jsonb_path_query_first`) and compares them + * with the `eql_v3_jsonb_entry` comparators; `eq_term`/`ord_term` read only the + * entries' `hm`/`op`. * * - the SELECTOR (`path`): `encryptQuery(path, searchableJson)` on a string * needle infers a `ste_vec_selector` term → the bare HMAC selector hash, the * `text` argument of `jsonb_path_query_first`. - * - the VALUE: a scalar query term for the leaf — `encryptQuery(value, - * { queryType: equality | orderAndRange })` — cast to `eql_v3.query__`. - * No ciphertext, no storage encryption. + * - the VALUE (RHS): **interim (cipherstash/protectjs-ffi#137)** — a STORAGE + * `encrypt` of `{path: value}`, whose ste_vec entry carries `c` + `op`/`hm`. + * protect-ffi can't yet mint a ciphertext-free ordering query needle for a + * ste_vec column, so the value's ciphertext appears in the WHERE clause until + * #137 lands; the comparison itself only reads `hm`/`op`. * * Equality-at-selector is also expressible via `contains(col, {path: value})`; * this path's unique power is ORDERING at a selector (`gt`/`gte`/`lt`/`lte`). diff --git a/skills/stash-drizzle/SKILL.md b/skills/stash-drizzle/SKILL.md index 61a75056b..7278e8ae9 100644 --- a/skills/stash-drizzle/SKILL.md +++ b/skills/stash-drizzle/SKILL.md @@ -788,7 +788,7 @@ Differences from the v2 operators to know about: - **`like` / `ilike` do not exist — by design.** v3 free-text search is tokenised bloom matching, not SQL pattern matching; `matches(col, needle)` is the free-text operator. It is fuzzy (order- and multiplicity-insensitive) and one-sided (a match may be a false positive, a non-match never is). Don't pass `%` wildcards. - **`matches` is fuzzy free-text, `contains` is exact JSON containment — two distinct operators (#617).** `matches(col, needle)` requires a `TextMatch` / `TextSearch` column and throws `EncryptionOperatorError` (`requires free-text search`) otherwise. `contains(col, subdoc)` requires a `types.Json` column and throws (`requires JSON containment`) otherwise. - **`contains` on a `types.Json` column** answers exact encrypted-JSONB containment: `contains(col, { roles: ['admin'] })` matches every row whose document contains that sub-object (jsonb `@>` semantics, no false positives; array containment is position-independent). It emits the `@>` operator with a `query_jsonb` needle. Applying `eq` / `gt` / `asc` **directly** to a `Json` column throws — use `selector(col, '$.path')` for equality/ordering **at a path** (next bullet). -- **`selector(col, '$.path')` — JSONPath selector-with-constraint on a `types.Json` column.** Returns comparison methods bound to a path: `await ops.selector(events.metadata, '$.age').gt(21)`. Methods: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Its unique power over `contains` is **ordering at a path** (`gt`/`gte`/`lt`/`lte`); `eq`/`ne` are also provided (equality at a path is equivalently `contains(col, { age: 21 })`). v1 supports dot-notation object paths (`$.a`, `$.a.b`); array-index/wildcard and empty/root paths are rejected with a clear `Error`. +- **`selector(col, '$.path')` — JSONPath selector-with-constraint on a `types.Json` column.** Returns comparison methods bound to a path: `await ops.selector(events.metadata, '$.age').gt(21)`. Methods: `eq`, `ne`, `gt`, `gte`, `lt`, `lte`. Its unique power over `contains` is **ordering at a path** (`gt`/`gte`/`lt`/`lte`); `eq`/`ne` are also provided (equality at a path is equivalently `contains(col, { age: 21 })`). v1 supports dot-notation object paths (`$.a`, `$.a.b`); array-index/wildcard and empty/root paths are rejected with a clear `Error`. **Interim behavior:** the comparison value is currently sent as a storage-encrypted document, so the (encrypted) value appears in the `WHERE` clause; a ciphertext-free form is tracked in `cipherstash/protectjs-ffi#137`. - **No plaintext-column fallback.** Every v3 operator requires an encrypted v3 column and throws `EncryptionOperatorError` otherwise. Use regular Drizzle operators for non-encrypted columns. - A `null` operand throws — use `isNull()` / `isNotNull()` for NULL checks. - `inArray` / `notInArray` reject an empty list, and encrypt the whole list in a single `encryptQuery` batch crossing. diff --git a/skills/stash-encryption/SKILL.md b/skills/stash-encryption/SKILL.md index 1a31c5e9a..8ef9c5d17 100644 --- a/skills/stash-encryption/SKILL.md +++ b/skills/stash-encryption/SKILL.md @@ -699,6 +699,10 @@ paths are rejected with a clear error. Applying `eq` / `gt` / `asc` **directly** to a `types.Json` column (rather than through `ops.selector`) still throws — the column declares no scalar capabilities of its own. +**Interim behavior:** the selector's comparison value is currently sent as a +storage-encrypted document, so the (encrypted) value appears in the `WHERE` +clause; a ciphertext-free form is tracked in `cipherstash/protectjs-ffi#137`. + ### Strongly-Typed Client: `EncryptionV3` `EncryptionV3` from `@cipherstash/stack/v3` returns a `TypedEncryptionClient` whose method signatures are derived from your schemas — wrong-typed plaintext is rejected at compile time, and query methods only accept queryable columns with `queryType` constrained to the column's capabilities: From 36ecb3b0f68cd69af836301b6718684b55d09294 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 15 Jul 2026 14:38:46 +1000 Subject: [PATCH 6/6] fix(stack-drizzle): address freshtonic + Toby review on v3 JSON selector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Must-fixes: - test:types was broken (operators.test-d.ts): adding the required `encrypt` member to OperandEncryptionClient made the `{ encryptQuery }`-only doubles unassignable. Add `encrypt` to both doubles — and to the `erased` double too, so it's rejected ONLY for the #622 encryptQuery-erasure reason (restoring the guard's teeth), not for a missing member. - Re-add the scalar-leaf-type guard dropped in the storage rewrite: reject non-scalar leaf values (object/array → use contains()) and ordering a boolean, up front as EncryptionOperatorError instead of a deferred opaque DB error. - Define + test + document `ne` absent-path semantics: a row lacking the path is now INCLUDED in `.ne(x)` (OR jsonb_path_query_first(...) IS NULL); eq/ordering keep SQL's exclude-on-missing. Integration test seeds a row with no `$.age`. CI gap (root cause the type break slipped through): the adapter packages' `test:types` was never wired into CI after the #627 split — only @cipherstash/stack and test-kit ran. Add stack-drizzle + stack-supabase type-test steps to tests.yml (both green today). Toby: path-validation errors now surface as EncryptionOperatorError with column context; add unit tests for the pure helpers (parseSelectorSegments, reconstructSelectorDocument) + the selector guards. Also from freshtonic: harden path parsing — reject `..`/malformed/empty-segment paths (no longer silently query a different path) and prototype-pollution keys (__proto__/prototype/constructor); surface the interim ciphertext-in-WHERE caveat in the skill capability-table row. Follow-ups (noted, not in this PR): share core's path helpers via adapter-kit; memoize the value-independent selector hash per (column, path). Refs #623 · protect-ffi gap cipherstash/protectjs-ffi#137 Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w --- .github/workflows/tests.yml | 11 ++ .../__tests__/v3/operators.test-d.ts | 6 + .../__tests__/v3/selector.test.ts | 112 ++++++++++++++++ .../json-selector.integration.test.ts | 31 +++++ packages/stack-drizzle/src/v3/operators.ts | 120 +++++++++++++++--- skills/stash-encryption/SKILL.md | 2 +- 6 files changed, 263 insertions(+), 19 deletions(-) create mode 100644 packages/stack-drizzle/__tests__/v3/selector.test.ts diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0a8c6143b..6bbc3cf3a 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -116,6 +116,17 @@ jobs: - name: Type tests (test-kit — enforces v3 domain coverage) run: pnpm --filter @cipherstash/test-kit run test:types + # The adapter packages carry their own `.test-d.ts` type-contract guards + # (the M1 client-surface / #622 erasure guards, the Supabase key-set + # gating). They moved here from `@cipherstash/stack` in the #627 split but + # were never wired into CI, so type-level regressions in them went + # undetected — run them explicitly. + - name: Type tests (stack-drizzle) + run: pnpm --filter @cipherstash/stack-drizzle run test:types + + - name: Type tests (stack-supabase) + run: pnpm --filter @cipherstash/stack-supabase run test:types + - name: Lint — no hardcoded package-manager runners run: pnpm run lint:runners diff --git a/packages/stack-drizzle/__tests__/v3/operators.test-d.ts b/packages/stack-drizzle/__tests__/v3/operators.test-d.ts index c2c09c1d6..7c04db648 100644 --- a/packages/stack-drizzle/__tests__/v3/operators.test-d.ts +++ b/packages/stack-drizzle/__tests__/v3/operators.test-d.ts @@ -53,6 +53,9 @@ describe('createEncryptionOperatorsV3 - client parameter (M1)', () => { _opts?: never, ): QueryOp & QueryOp => ({}) as never, + // Storage encryption — consumed only by the JSON selector RHS, but part of + // the operand-client contract, so a structural double must supply it too. + encrypt: (_value: never, _opts: never): QueryOp => ({}) as never, } expectTypeOf(createEncryptionOperatorsV3).toBeCallableWith(double) }) @@ -67,6 +70,9 @@ describe('createEncryptionOperatorsV3 - client parameter (M1)', () => { const erased = { encryptQuery: (_valueOrTerms: never, _opts?: never): QueryOp => ({}) as never, + // A correctly-typed `encrypt` so the ONLY reason this double is rejected is + // the `encryptQuery`-resolves-`unknown` erasure — not a missing member. + encrypt: (_value: never, _opts: never): QueryOp => ({}) as never, } // @ts-expect-error — `encryptQuery` resolving `unknown` does not satisfy the // factory's `ChainableOperation` client contract. diff --git a/packages/stack-drizzle/__tests__/v3/selector.test.ts b/packages/stack-drizzle/__tests__/v3/selector.test.ts new file mode 100644 index 000000000..e3c2296cf --- /dev/null +++ b/packages/stack-drizzle/__tests__/v3/selector.test.ts @@ -0,0 +1,112 @@ +import { integer, pgTable } from 'drizzle-orm/pg-core' +import { describe, expect, it } from 'vitest' +import { + createEncryptionOperatorsV3, + EncryptionOperatorError, + parseSelectorSegments, + reconstructSelectorDocument, +} from '../../src/v3/operators.js' +import { types } from '../../src/v3/types.js' + +describe('parseSelectorSegments', () => { + it('parses $-rooted, bare, and whitespace-padded dot paths', () => { + expect(parseSelectorSegments('$.a')).toEqual(['a']) + expect(parseSelectorSegments('$.a.b')).toEqual(['a', 'b']) + expect(parseSelectorSegments('a.b')).toEqual(['a', 'b']) + expect(parseSelectorSegments(' $.a.b ')).toEqual(['a', 'b']) + }) + + it('rejects array-index and wildcard syntax', () => { + expect(() => parseSelectorSegments('$.items[0]')).toThrow(/array\/wildcard/) + expect(() => parseSelectorSegments('$.items[*].name')).toThrow( + /array\/wildcard/, + ) + }) + + it('rejects the empty / root path', () => { + expect(() => parseSelectorSegments('$')).toThrow(/addresses no field/) + expect(() => parseSelectorSegments('$.')).toThrow(/addresses no field/) + expect(() => parseSelectorSegments('')).toThrow(/addresses no field/) + }) + + it('rejects malformed paths (.. / stray dots) rather than silently collapsing', () => { + expect(() => parseSelectorSegments('$..age')).toThrow(/malformed/) + expect(() => parseSelectorSegments('$.a..b')).toThrow(/malformed/) + expect(() => parseSelectorSegments('$.a.')).toThrow(/malformed/) + }) + + it('rejects prototype-pollution keys', () => { + for (const key of ['__proto__', 'prototype', 'constructor']) { + expect(() => parseSelectorSegments(`$.${key}`)).toThrow(/forbidden key/) + expect(() => parseSelectorSegments(`$.a.${key}`)).toThrow(/forbidden key/) + } + }) +}) + +describe('reconstructSelectorDocument', () => { + it('nests the value under the segments', () => { + expect(reconstructSelectorDocument(['a'], 30)).toEqual({ a: 30 }) + expect(reconstructSelectorDocument(['a', 'b'], 'x')).toEqual({ + a: { b: 'x' }, + }) + }) + + it('serializes correctly and creates own keys (no prototype pollution)', () => { + // parseSelectorSegments rejects __proto__ upstream, but the builder must be + // safe regardless: a __proto__ segment is an OWN key, not the prototype. + const doc = reconstructSelectorDocument(['__proto__', 'age'], 1) + expect(JSON.stringify(doc)).toBe('{"__proto__":{"age":1}}') + expect(Object.getPrototypeOf(doc)).toBeNull() + expect({}.age).toBeUndefined() // global prototype untouched + }) +}) + +describe('ops.selector — up-front guards (no encryption reached)', () => { + const table = pgTable('selector_guard', { + id: integer('id').primaryKey(), + doc: types.Json('doc'), + }) + // The encrypt methods throw if reached — every case below must reject first. + const failIfCalled = () => { + throw new Error('guard should reject before encrypting') + } + const ops = createEncryptionOperatorsV3({ + encryptQuery: failIfCalled, + encrypt: failIfCalled, + } as never) + + it('rejects a non-scalar leaf value (object / array) — use contains()', async () => { + await expect(ops.selector(table.doc, '$.a').eq({ x: 1 })).rejects.toThrow( + /scalar leaf.*contains\(\)/, + ) + await expect(ops.selector(table.doc, '$.a').eq([1, 2])).rejects.toThrow( + /scalar leaf/, + ) + }) + + it('rejects ordering a boolean leaf', async () => { + await expect(ops.selector(table.doc, '$.flag').gt(true)).rejects.toThrow( + /boolean leaf has no ordering/, + ) + }) + + it('allows equality on a boolean leaf (would reach encryption)', async () => { + // eq on a boolean is permitted by the guard; it fails only because the mock + // encrypt throws — proving the guard passed it through. + await expect(ops.selector(table.doc, '$.flag').eq(true)).rejects.toThrow( + /guard should reject before encrypting/, + ) + }) + + it('surfaces path errors as EncryptionOperatorError with context', async () => { + await expect( + ops.selector(table.doc, '$.items[0]').eq(1), + ).rejects.toBeInstanceOf(EncryptionOperatorError) + await expect(ops.selector(table.doc, '$').eq(1)).rejects.toThrow( + /addresses no field/, + ) + await expect(ops.selector(table.doc, '$.__proto__').eq(1)).rejects.toThrow( + /forbidden key/, + ) + }) +}) diff --git a/packages/stack-drizzle/integration/json-selector.integration.test.ts b/packages/stack-drizzle/integration/json-selector.integration.test.ts index 94f17b2c1..e22fd4bcd 100644 --- a/packages/stack-drizzle/integration/json-selector.integration.test.ts +++ b/packages/stack-drizzle/integration/json-selector.integration.test.ts @@ -44,6 +44,9 @@ const DOCS: Record = { ada: { user: 'ada@example.com', age: 30 }, grace: { user: 'grace@example.com', age: 20 }, zoe: { user: 'zoe@example.com', age: 40 }, + // No `$.age` — exercises absent-path semantics (excluded by eq/ordering, + // included by ne). + noage: { user: 'noage@example.com' }, } type SelectRow = { rowKey: string } @@ -130,6 +133,34 @@ describe('v3 drizzle JSON selector-with-constraint (live pg)', () => { ).toEqual([]) }, 30000) + it('equality excludes rows whose document lacks the path', async () => { + // `noage` has no `$.age`, so it is not equal to 30 → excluded. + expect( + await matching(await ops.selector(docTable.doc, '$.age').eq(30)), + ).toEqual(['ada']) + }, 30000) + + it('ne includes rows whose document lacks the path', async () => { + // ne(30): grace(20), zoe(40), AND noage (no `$.age`) — "not equal to 30" + // covers "has no age". Without the IS NULL arm, noage would be dropped. + expect( + await matching(await ops.selector(docTable.doc, '$.age').ne(30)), + ).toEqual(['grace', 'noage', 'zoe']) + }, 30000) + + it('ordering excludes rows whose document lacks the path', async () => { + // noage has no age → not > 0. + expect( + await matching(await ops.selector(docTable.doc, '$.age').gt(0)), + ).toEqual(['ada', 'grace', 'zoe']) + }, 30000) + + it('rejects a non-scalar leaf value before querying', async () => { + await expect( + ops.selector(docTable.doc, '$.age').eq({ nested: 1 }), + ).rejects.toThrow(/scalar leaf/) + }, 30000) + it('rejects array/wildcard selector paths (v1 supports object keys only)', async () => { await expect( ops.selector(docTable.doc, '$.items[0].name').eq('x'), diff --git a/packages/stack-drizzle/src/v3/operators.ts b/packages/stack-drizzle/src/v3/operators.ts index 1b9274457..abba645d0 100644 --- a/packages/stack-drizzle/src/v3/operators.ts +++ b/packages/stack-drizzle/src/v3/operators.ts @@ -77,24 +77,52 @@ type OperandEncryptionClient = { } /** - * Parse a dot-notation JSONPath into its object-key segments, rejecting - * array/wildcard syntax (v1 supports object keys only) and the empty/root path. - * `'$.a.b'` / `'a.b'` → `['a', 'b']`. Mirrors core `toJsonPath` normalization - * (which isn't exported publicly) so leading `$`/`.` are handled correctly. + * Object keys that are prototype-pollution vectors — rejected outright (mirrors + * core's `FORBIDDEN_KEYS`), so a selector can never address them. */ -function parseSelectorSegments(path: string): string[] { - const bare = path.replace(/^\$/, '').replace(/^\./, '') - if (/[[\]*]/.test(bare)) { +const FORBIDDEN_SEGMENTS: ReadonlySet = new Set([ + '__proto__', + 'prototype', + 'constructor', +]) + +/** + * Parse a dot-notation JSONPath into its object-key segments. Rejects, each with + * a clear message: array-index/wildcard syntax (v1 is object-keys-only), the + * empty/root path, malformed paths (`..`, stray/leading/trailing dots, so we + * never silently query a *different* path), and prototype-pollution keys. + * `'$.a.b'` / `' a.b '` → `['a', 'b']`. Mirrors core's JSONPath normalization + * (not exported publicly — see the follow-up to share it via adapter-kit). + * + * Exported for unit testing; NOT re-exported from the package index. + */ +export function parseSelectorSegments(path: string): string[] { + const trimmed = path.trim() + let body = trimmed.startsWith('$') ? trimmed.slice(1) : trimmed + if (body.startsWith('.')) body = body.slice(1) + if (body === '') { throw new Error( - `JSON selector path "${path}" uses array/wildcard syntax, which is not yet supported — use dot-notation object keys (e.g. "$.a.b").`, + `JSON selector path "${path}" addresses no field — use e.g. "$.a" or "$.a.b".`, ) } - const segments = bare.split('.').filter((segment) => segment.length > 0) - if (segments.length === 0) { + if (/[[\]*]/.test(body)) { throw new Error( - `JSON selector path "${path}" addresses no field — use e.g. "$.a" or "$.a.b".`, + `JSON selector path "${path}" uses array/wildcard syntax, which is not yet supported — use dot-notation object keys (e.g. "$.a.b").`, ) } + const segments = body.split('.') + for (const segment of segments) { + if (segment === '') { + throw new Error( + `JSON selector path "${path}" is malformed (empty segment / ".." / stray dot) — use dot-notation object keys (e.g. "$.a.b").`, + ) + } + if (FORBIDDEN_SEGMENTS.has(segment)) { + throw new Error( + `JSON selector path "${path}" addresses the forbidden key "${segment}".`, + ) + } + } return segments } @@ -103,12 +131,38 @@ function jsonPathOf(segments: string[]): string { return `$.${segments.join('.')}` } +/** + * A selector compares a single scalar LEAF. Returns a reason string when `value` + * is unsupported — a non-scalar (object/array → that's `contains`), or a boolean + * under an ordering operator (no ordering term) — else `null`. The caller raises + * it as an {@link EncryptionOperatorError} with column context, so a bad value is + * an actionable SDK error rather than a deferred, opaque DB failure. + */ +function unsupportedLeafReason( + value: unknown, + ordering: boolean, +): string | null { + const isScalar = + value instanceof Date || + typeof value === 'number' || + typeof value === 'bigint' || + typeof value === 'string' || + typeof value === 'boolean' + if (!isScalar) { + return `a selector compares a scalar leaf, but got ${Array.isArray(value) ? 'an array' : 'an object'} — use contains() for sub-object matching.` + } + if (ordering && typeof value === 'boolean') { + return 'a boolean leaf has no ordering — use eq/ne (or contains()).' + } + return null +} + /** * Nest `value` under the segments: `['a','b']` → `{ a: { b: value } }`. The * storage-needle document whose ste_vec entry at the path supplies the * ciphertext-bearing comparison entry. */ -function reconstructSelectorDocument( +export function reconstructSelectorDocument( segments: string[], value: unknown, ): Record { @@ -667,7 +721,28 @@ export function createEncryptionOperatorsV3( 'JSON selector (searchableJson)', ) requireNonNullOperand(ctx, value, operator) - const segments = parseSelectorSegments(path) + + // A selector compares a scalar leaf — reject non-scalars / non-orderable + // types up front with a clear error, not a deferred DB failure. + const ordering = op !== 'eq' && op !== 'ne' + const leafReason = unsupportedLeafReason(value, ordering) + if (leafReason) { + throw new EncryptionOperatorError( + `Operator "${operator}" cannot compare column "${ctx.columnName}": ${leafReason}`, + { columnName: ctx.columnName, tableName: ctx.tableName, operator }, + ) + } + + // Surface path-validation failures as EncryptionOperatorError with context. + let segments: string[] + try { + segments = parseSelectorSegments(path) + } catch (err) { + throw new EncryptionOperatorError( + `Operator "${operator}" on column "${ctx.columnName}": ${err instanceof Error ? err.message : String(err)}`, + { columnName: ctx.columnName, tableName: ctx.tableName, operator }, + ) + } // INTERIM (cipherstash/protectjs-ffi#137): the RHS is a STORAGE-encrypted // needle, not a ciphertext-free query term. protect-ffi can't yet mint an @@ -727,9 +802,16 @@ export function createEncryptionOperatorsV3( sql`${JSON.stringify(docResult.data)}::public.eql_v3_json`, selSql, ) - return op === 'eq' || op === 'ne' - ? v3Dialect.equality(op, leftEntry, rightEntry) - : v3Dialect.comparison(op, leftEntry, rightEntry) + if (op === 'eq') return v3Dialect.equality('eq', leftEntry, rightEntry) + if (op === 'ne') { + // Absent-path semantics: a row whose document lacks the path yields a NULL + // entry, and "not equal to X" should INCLUDE "has no X". Without the + // `IS NULL` arm, three-valued logic silently drops those rows. (`eq` and the + // ordering ops keep SQL's default: a missing path is not equal to / not + // greater than the value, so those rows are correctly excluded.) + return sql`(${v3Dialect.equality('ne', leftEntry, rightEntry)} OR ${leftEntry} IS NULL)` + } + return v3Dialect.comparison(op, leftEntry, rightEntry) } /** Comparison methods bound to a `col->'path'` selector, mirroring the scalar @@ -740,9 +822,11 @@ export function createEncryptionOperatorsV3( (value: unknown, opts?: EncryptionOperatorCallOpts) => selectorCompare(col, path, op, value, `selector(${path}).${op}`, opts) return { - /** `col->'path' = value` (encrypted equality at the selector). */ + /** `col->'path' = value` (encrypted equality at the selector). A row whose + * document lacks `path` is excluded (it is not equal to `value`). */ eq: at('eq'), - /** `col->'path' <> value`. */ + /** `col->'path' <> value`, INCLUDING rows whose document lacks `path` + * ("not equal to value" covers "has no value"). */ ne: at('ne'), /** `col->'path' > value` (encrypted ordering at the selector). */ gt: at('gt'), diff --git a/skills/stash-encryption/SKILL.md b/skills/stash-encryption/SKILL.md index 8ef9c5d17..2e9273889 100644 --- a/skills/stash-encryption/SKILL.md +++ b/skills/stash-encryption/SKILL.md @@ -632,7 +632,7 @@ Each factory in `types` maps 1:1 to a Postgres domain named `public.eql_v3_