Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .changeset/eql-v3-json-selector.md
Original file line number Diff line number Diff line change
@@ -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->'<selector>' <op> <value>` 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).
11 changes: 11 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions packages/stack-drizzle/__tests__/v3/operators.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ describe('createEncryptionOperatorsV3 - client parameter (M1)', () => {
_opts?: never,
): QueryOp<EncryptedQueryResult> & QueryOp<EncryptedQueryResult[]> =>
({}) 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<unknown> => ({}) as never,
}
expectTypeOf(createEncryptionOperatorsV3).toBeCallableWith(double)
})
Expand All @@ -67,6 +70,9 @@ describe('createEncryptionOperatorsV3 - client parameter (M1)', () => {
const erased = {
encryptQuery: (_valueOrTerms: never, _opts?: never): QueryOp<unknown> =>
({}) 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<unknown> => ({}) as never,
}
// @ts-expect-error — `encryptQuery` resolving `unknown` does not satisfy the
// factory's `ChainableOperation<EncryptedQueryResult>` client contract.
Expand Down
112 changes: 112 additions & 0 deletions packages/stack-drizzle/__tests__/v3/selector.test.ts
Original file line number Diff line number Diff line change
@@ -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/,
)
})
})
169 changes: 169 additions & 0 deletions packages/stack-drizzle/integration/json-selector.integration.test.ts
Original file line number Diff line number Diff line change
@@ -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.<op>(eql_v3.jsonb_path_query_first(col, '<sel>'), eql_v3.jsonb_path_query_first('<needle>'::eql_v3_json, '<sel>'))`.
*
* 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<string, JsonDocument> = {
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<ReturnType<typeof EncryptionV3>>
let ops: ReturnType<typeof createEncryptionOperatorsV3>
let db: ReturnType<typeof drizzle>

async function matching(condition: SQL): Promise<string[]> {
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
)
`)
Comment thread
coderdan marked this conversation as resolved.

const rows = Object.entries(DOCS).map(([rowKey, doc]) => ({
rowKey,
testRunId: RUN,
doc,
}))
const encrypted = unwrapResult(
await client.bulkEncryptModels(rows, schema),
) as Array<Record<string, unknown>>
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)
})
Loading
Loading