diff --git a/.changeset/eql-v3-json-selector.md b/.changeset/eql-v3-json-selector.md new file mode 100644 index 000000000..866462644 --- /dev/null +++ b/.changeset/eql-v3-json-selector.md @@ -0,0 +1,26 @@ +--- +'@cipherstash/stack-drizzle': minor +'stash': patch +--- + +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 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. + +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/.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 new file mode 100644 index 000000000..e22fd4bcd --- /dev/null +++ b/packages/stack-drizzle/integration/json-selector.integration.test.ts @@ -0,0 +1,169 @@ +/** + * 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.jsonb_path_query_first(col, ''), eql_v3.jsonb_path_query_first(''::eql_v3_json, ''))`. + * + * 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' +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 }, + // No `$.age` — exercises absent-path semantics (excluded by eq/ordering, + // included by ne). + noage: { user: 'noage@example.com' }, +} + +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('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'), + ).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..abba645d0 100644 --- a/packages/stack-drizzle/src/v3/operators.ts +++ b/packages/stack-drizzle/src/v3/operators.ts @@ -66,6 +66,121 @@ type OperandEncryptionClient = { opts: never, ): ChainableOperation encryptQuery(terms: never): ChainableOperation + /** + * 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 +} + +/** + * Object keys that are prototype-pollution vectors — rejected outright (mirrors + * core's `FORBIDDEN_KEYS`), so a selector can never address them. + */ +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}" addresses no field — use e.g. "$.a" or "$.a.b".`, + ) + } + if (/[[\]*]/.test(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").`, + ) + } + 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 +} + +/** `$`-rooted JSONPath for `encryptQuery`'s selector needle. */ +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. + */ +export function reconstructSelectorDocument( + segments: string[], + value: unknown, +): 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 = Object.create(null) + cursor[segment] = next + cursor = next + } + }) + return root } /** @@ -571,6 +686,159 @@ 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 (`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 (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`). + */ + 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) + + // 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 + // 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, + ), + 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) + } + if (docResult.failure) { + throw operandFailure(ctx, operator, docResult.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 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, + ) + 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 + * 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). A row whose + * document lacks `path` is excluded (it is not equal to `value`). */ + eq: at('eq'), + /** `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'), + /** `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 +942,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..aac0ae0b1 100644 --- a/packages/stack-drizzle/src/v3/sql-dialect.ts +++ b/packages/stack-drizzle/src/v3/sql-dialect.ts @@ -64,6 +64,22 @@ export const v3Dialect = { return sql`${left} OPERATOR(public.@>) ${enc}` }, + /** + * 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. + * + * 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. + */ + selectorEntry(source: SQL, selector: SQL): SQL { + return sql`${fn('jsonb_path_query_first')}(${source}, ${selector})` + }, + 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), diff --git a/skills/stash-drizzle/SKILL.md b/skills/stash-drizzle/SKILL.md index 2b6dc44ab..7278e8ae9 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`. **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 a90b25b7d..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_ **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. + +**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`