diff --git a/.changeset/wasm-encrypt-query.md b/.changeset/wasm-encrypt-query.md new file mode 100644 index 000000000..1578b7658 --- /dev/null +++ b/.changeset/wasm-encrypt-query.md @@ -0,0 +1,18 @@ +--- +'@cipherstash/stack': minor +--- + +`@cipherstash/stack/wasm-inline` now exposes `encryptQuery` and +`encryptQueryBulk` on `WasmEncryptionClient` (#662) — searchable encryption +is reachable on Deno/edge runtimes. Previously the WASM entry exposed only +`encrypt`/`decrypt`/`isEncrypted`, so encrypted WHERE-clause search was +architecturally impossible on the edge even though the underlying protect-ffi +WASM build carries the capability. + +The new methods mint ciphertext-free EQL v3 query terms — equality, +free-text match, ORE range, and JSON containment/selector — with the same +index-type resolution as the native client (explicit `queryType`, or +inference from the column's configured indexes). Cast the term to the +column's `eql_v3.query_` type in SQL to reach the indexed operators. +Errors throw, consistent with the WASM surface's `encrypt`/`decrypt`; the +bulk form is position-stable (`null` values pass through as `null`). diff --git a/.github/workflows/integration-drizzle.yml b/.github/workflows/integration-drizzle.yml index 0b2a9b835..3db687917 100644 --- a/.github/workflows/integration-drizzle.yml +++ b/.github/workflows/integration-drizzle.yml @@ -26,6 +26,8 @@ on: - 'packages/stack/src/schema/**' - 'packages/schema/**' - 'packages/stack/integration/**' + # The WASM family suite (integration/wasm/**) exercises this entry: + - 'packages/stack/src/wasm-inline.ts' - 'packages/test-kit/**' - 'packages/cli/src/installer/**' - 'local/docker-compose.postgres.yml' @@ -46,6 +48,8 @@ on: - 'packages/stack/src/schema/**' - 'packages/schema/**' - 'packages/stack/integration/**' + # The WASM family suite (integration/wasm/**) exercises this entry: + - 'packages/stack/src/wasm-inline.ts' - 'packages/test-kit/**' - 'packages/cli/src/installer/**' - 'local/docker-compose.postgres.yml' @@ -125,11 +129,13 @@ jobs: # The Drizzle adapter suites (incl. the Clerk-federated lock-context one) # now live in @cipherstash/stack-drizzle (run below via its own # test:integration). This glob scopes what still lives in @cipherstash/stack: - # the adapter-agnostic `shared/` core suites and the model-path - # `identity/matrix-identity` suite. + # the adapter-agnostic `shared/` core suites, the model-path + # `identity/matrix-identity` suite, and the `wasm/` family suite (the + # `@cipherstash/stack/wasm-inline` adapter over the shared v3 matrix). CS_IT_SUITE: >- integration/shared/**/*.integration.test.ts, - integration/identity/**/*.integration.test.ts + integration/identity/**/*.integration.test.ts, + integration/wasm/**/*.integration.test.ts steps: - uses: actions/checkout@v6 @@ -167,7 +173,7 @@ jobs: # different package now). Its globalSetup calls the same EQL v3 install, but # `isInstalled` short-circuits against the DB the first invocation already # provisioned — a fast no-op check, not a second schema apply. - - name: Shared core + identity integration suites + - name: Shared core + identity + wasm integration suites run: pnpm --filter @cipherstash/stack run test:integration - name: Stop ${{ matrix.db }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 12f63ac01..fb3bc3701 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -294,10 +294,10 @@ jobs: - name: Build stack run: pnpm exec turbo run build --filter @cipherstash/stack - # roundtrip.test.ts skips itself (Deno.test.ignore) when any of the four - # CS_* env vars is missing — fine locally, but in CI a silent skip would - # let a rotated / cleared secret hide a real WASM regression behind a - # green job. Fail loudly instead. + # The e2e/wasm suites FAIL when any of the four CS_* env vars is + # missing (requireEnv throws — no skip gating), so a rotated / cleared + # secret can't hide a real WASM regression behind a green job. This + # preflight just fails faster, before the Deno module downloads. - name: Require CipherStash secrets uses: ./.github/actions/require-cs-secrets with: diff --git a/e2e/wasm/deno.lock b/e2e/wasm/deno.lock new file mode 100644 index 000000000..1da077419 --- /dev/null +++ b/e2e/wasm/deno.lock @@ -0,0 +1,18 @@ +{ + "version": "5", + "specifiers": { + "jsr:@std/assert@1": "1.0.19", + "jsr:@std/internal@^1.0.12": "1.0.14" + }, + "jsr": { + "@std/assert@1.0.19": { + "integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/internal@1.0.14": { + "integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7" + } + } +} diff --git a/e2e/wasm/query-types.test.ts b/e2e/wasm/query-types.test.ts new file mode 100644 index 000000000..c7429c84f --- /dev/null +++ b/e2e/wasm/query-types.test.ts @@ -0,0 +1,235 @@ +/** + * Query-type matrix for `@cipherstash/stack/wasm-inline`'s `encryptQuery` / + * `encryptQueryBulk` (#662) — the WASM twin of the per-query-type coverage + * the Drizzle/Supabase adapters have. + * + * Runs under Deno against real CipherStash credentials, one live term per + * query type the surface supports: + * + * | queryType | column domain | FFI index | + * |------------------|----------------------|------------| + * | equality | eql_v3_text_eq | unique | + * | freeTextSearch | eql_v3_text_search | match | + * | orderAndRange | eql_v3_integer_ord | ore | + * | searchableJson | eql_v3_json | ste_vec | + * | (string value) | → ste_vec_selector | | + * | (object value) | → ste_vec_term | | + * + * Wire shapes differ by kind — which is exactly what this suite pins: + * SCALAR terms are v3 envelopes (`{v: 3, i, …}`); a JSON CONTAINMENT + * needle is a strict `{sv: [query-entry]}` with no version field; a + * SELECTOR query returns the BARE selector-hash string (v3 has no + * encrypted-selector envelope — bind as the text argument of `->`/`->>`). + * All are CIPHERTEXT-FREE. The boundary bugs this suite's first runs + * caught (undefined fields rejected by serde; the bulk field is `queries`; + * the ore→ope swap; both JSON shapes) are exactly why each type needs a + * live crossing, not a mock. + * + * FAILS LOUDLY when any CS_* env var is missing, matching `roundtrip.test.ts`. + */ + +import { assertEquals, assertExists } from 'jsr:@std/assert@^1.0.0' +import { + Encryption, + encryptedTable, + types, +} from '@cipherstash/stack/wasm-inline' + +const REQUIRED_ENV = [ + 'CS_WORKSPACE_CRN', + 'CS_CLIENT_ACCESS_KEY', + 'CS_CLIENT_ID', + 'CS_CLIENT_KEY', +] as const + +function requireEnv(): Record<(typeof REQUIRED_ENV)[number], string> { + const values = {} as Record<(typeof REQUIRED_ENV)[number], string> + const missing: string[] = [] + for (const key of REQUIRED_ENV) { + const value = Deno.env.get(key) + if (value) values[key] = value + else missing.push(key) + } + if (missing.length > 0) { + // FAIL, don't skip: a silently-skipped credential suite reads as green + // coverage that never ran (same doctrine as the repo's integration + // harness — no skipIf credential gates). + throw new Error( + `Missing required env: ${missing.join(', ')}. This suite needs real ` + + 'CipherStash credentials — export the four CS_* variables (or put them ' + + 'in a repo-root .env; see AGENTS.md "Environment variables") or run ' + + 'via the CI job, which injects them.', + ) + } + return values +} + +const catalog = encryptedTable('wasm_query_matrix', { + email: types.TextEq('email'), // equality only + bio: types.TextSearch('bio'), // free-text (also carries eq + ore) + age: types.IntegerOrd('age'), // eq + order/range + prefs: types.Json('prefs'), // searchable JSON (ste_vec) +}) + +/** A v3 SCALAR query term: versioned envelope (`v: 3`), NO ciphertext. */ +function assertV3Term(term: unknown, label: string) { + assertExists(term, `${label}: encryptQuery returned null`) + const obj = term as Record + assertEquals(obj.v, 3, `${label}: term is not EQL v3`) + assertEquals('c' in obj, false, `${label}: term carries ciphertext`) +} + +/** A v3 JSON CONTAINMENT needle: strict `{sv: [query-entry]}` — no `v` + * envelope, no ciphertext (per `is_valid_ste_vec_query_payload`). */ +function assertContainmentNeedle(term: unknown, label: string) { + assertExists(term, `${label}: encryptQuery returned null`) + const obj = term as Record + assertEquals( + Array.isArray(obj.sv), + true, + `${label}: expected an {sv: [...]} containment needle`, + ) + assertEquals('c' in obj, false, `${label}: needle carries ciphertext`) + assertEquals('v' in obj, false, `${label}: needle carries a version envelope`) +} + +Deno.test({ + name: 'stack/wasm-inline: encryptQuery covers every v3 query type', + permissions: { + env: true, + net: true, + read: true, + sys: true, + // No FFI permission, same pin as roundtrip.test.ts: if protect-ffi ever + // silently fell back to a native binding under Deno, the call would + // reject — so a green run proves the WASM path minted every term. + ffi: false, + }, + async fn() { + const env = requireEnv() + const client = await Encryption({ + schemas: [catalog], + config: { + workspaceCrn: env.CS_WORKSPACE_CRN, + accessKey: env.CS_CLIENT_ACCESS_KEY, + clientId: env.CS_CLIENT_ID, + clientKey: env.CS_CLIENT_KEY, + }, + }) + + // equality → unique index + assertV3Term( + await client.encryptQuery('alice@example.com', { + table: catalog, + column: catalog.email, + queryType: 'equality', + }), + 'equality', + ) + + // freeTextSearch → match index + assertV3Term( + await client.encryptQuery('needle phrase', { + table: catalog, + column: catalog.bio, + queryType: 'freeTextSearch', + }), + 'freeTextSearch', + ) + + // orderAndRange → ore index (numeric) + assertV3Term( + await client.encryptQuery(42, { + table: catalog, + column: catalog.age, + queryType: 'orderAndRange', + }), + 'orderAndRange', + ) + + // searchableJson, string value → ste_vec_selector (JSONPath). By + // contract the selector "term" is a BARE selector-hash string — no + // envelope — bound as the text argument of `->` / `->>`. + const selector = await client.encryptQuery('$.theme', { + table: catalog, + column: catalog.prefs, + queryType: 'searchableJson', + }) + assertExists(selector, 'selector: encryptQuery returned null') + assertEquals( + typeof selector, + 'string', + 'selector: expected the bare selector-hash string', + ) + assertEquals( + (selector as unknown as string).length > 0, + true, + 'selector: empty selector hash', + ) + + // searchableJson, object value → ste_vec_term (containment needle: + // strict {sv: [...]}, no version envelope — per the eql_v3.query_jsonb + // wire contract) + assertContainmentNeedle( + await client.encryptQuery( + { theme: 'dark' }, + { + table: catalog, + column: catalog.prefs, + queryType: 'searchableJson', + }, + ), + 'searchableJson/containment', + ) + + // Omitted queryType → inference from the column's indexes (TextEq has + // exactly one: unique), mirroring the native client. + assertV3Term( + await client.encryptQuery('bob@example.com', { + table: catalog, + column: catalog.email, + }), + 'inference', + ) + + // Bulk: one round trip across mixed query types, position-stable with + // nulls passing through. + const bulk = await client.encryptQueryBulk([ + { + value: 'alice@example.com', + table: catalog, + column: catalog.email, + queryType: 'equality', + }, + { + value: null, + table: catalog, + column: catalog.email, + }, + { + value: 'needle', + table: catalog, + column: catalog.bio, + queryType: 'freeTextSearch', + }, + { + value: 7, + table: catalog, + column: catalog.age, + queryType: 'orderAndRange', + }, + { + value: { theme: 'dark' }, + table: catalog, + column: catalog.prefs, + queryType: 'searchableJson', + }, + ]) + assertEquals(bulk.length, 5) + assertV3Term(bulk[0], 'bulk/equality') + assertEquals(bulk[1], null, 'bulk: null value must yield null') + assertV3Term(bulk[2], 'bulk/freeTextSearch') + assertV3Term(bulk[3], 'bulk/orderAndRange') + assertContainmentNeedle(bulk[4], 'bulk/searchableJson') + }, +}) diff --git a/e2e/wasm/roundtrip.test.ts b/e2e/wasm/roundtrip.test.ts index 03e172b90..354561257 100644 --- a/e2e/wasm/roundtrip.test.ts +++ b/e2e/wasm/roundtrip.test.ts @@ -14,8 +14,8 @@ * 4. No FFI permission was granted to the Deno process, so the WASM path is * the *only* path that could have succeeded. * - * Skipped when any of the four CS_* env vars is missing — matches the skip - * pattern in `e2e/tests/*.e2e.test.ts`. + * FAILS LOUDLY when any CS_* env var is missing — a silently-skipped + * credential suite reads as green coverage that never ran. */ import { assertEquals, assertExists } from 'jsr:@std/assert@^1.0.0' @@ -37,21 +37,30 @@ const REQUIRED_ENV = [ 'CS_CLIENT_KEY', ] as const -function envOrSkip(): Record<(typeof REQUIRED_ENV)[number], string> | null { - const out: Record = {} - for (const name of REQUIRED_ENV) { - const v = Deno.env.get(name) - if (!v) return null - out[name] = v +function requireEnv(): Record<(typeof REQUIRED_ENV)[number], string> { + const values = {} as Record<(typeof REQUIRED_ENV)[number], string> + const missing: string[] = [] + for (const key of REQUIRED_ENV) { + const value = Deno.env.get(key) + if (value) values[key] = value + else missing.push(key) } - return out as Record<(typeof REQUIRED_ENV)[number], string> + if (missing.length > 0) { + // FAIL, don't skip: a silently-skipped credential suite reads as green + // coverage that never ran (same doctrine as the repo's integration + // harness — no skipIf credential gates). + throw new Error( + `Missing required env: ${missing.join(', ')}. This suite needs real ` + + 'CipherStash credentials — export the four CS_* variables (or put them ' + + 'in a repo-root .env; see AGENTS.md "Environment variables") or run ' + + 'via the CI job, which injects them.', + ) + } + return values } -const env = envOrSkip() - Deno.test({ name: 'stack/wasm-inline: EQL v3 encrypt → decrypt round-trip via WASM', - ignore: env === null, permissions: { env: true, net: true, @@ -63,6 +72,8 @@ Deno.test({ ffi: false, }, async fn() { + const env = requireEnv() + // Sanity: we really are in Deno, and WASM is available. assertExists(globalThis.WebAssembly, 'WebAssembly global missing') assertExists( @@ -82,10 +93,10 @@ Deno.test({ config: { // CRN is the single source of truth — the region the // AccessKeyStrategy needs is derived from it. - workspaceCrn: env!.CS_WORKSPACE_CRN, - accessKey: env!.CS_CLIENT_ACCESS_KEY, - clientId: env!.CS_CLIENT_ID, - clientKey: env!.CS_CLIENT_KEY, + workspaceCrn: env.CS_WORKSPACE_CRN, + accessKey: env.CS_CLIENT_ACCESS_KEY, + clientId: env.CS_CLIENT_ID, + clientKey: env.CS_CLIENT_KEY, }, }) @@ -102,7 +113,57 @@ Deno.test({ 'encrypt() did not return a recognised EQL payload', ) + // The storage payload must survive `JSON.stringify` as a v3 envelope — + // this is the exact wire crossing every SQL insert performs, and + // `isEncrypted` alone cannot pin it: the wasm boundary deserializes JS + // `Map`s just as happily as plain objects, so a payload that stringifies + // to `{}` would still round-trip through decrypt above. + const envelope = encrypted as Record + assertEquals( + envelope?.v, + 3, + `storage payload is not a v3 envelope via property access — typeof=${typeof encrypted}, ` + + `ctor=${(encrypted as object)?.constructor?.name}, ` + + `keys=[${Object.keys(envelope ?? {}).join(', ')}]`, + ) + const wire = JSON.parse(JSON.stringify(encrypted)) as Record< + string, + unknown + > + for (const key of ['v', 'i', 'c'] as const) { + assertExists( + wire[key], + `storage payload lost "${key}" across JSON.stringify — wire keys=[${Object.keys(wire).join(', ')}]`, + ) + } + assertEquals(String(wire.v), '3', 'wire envelope is not v3') + const decrypted = await client.decrypt(encrypted) assertEquals(decrypted, plaintext, 'round-trip plaintext mismatch') + + // 5. (#662) Searchable encryption is reachable on the edge: mint a v3 + // QUERY TERM for the column's free-text index. Terms are + // ciphertext-free needles — assert the wire shape, not decryption. + const term = (await client.encryptQuery(plaintext, { + column: users.email, + table: users, + queryType: 'freeTextSearch', + })) as Record | null + assertExists(term, 'encryptQuery() returned null for live plaintext') + assertEquals(term.v, 3, 'query term is not EQL v3') + assertEquals( + 'c' in term, + false, + 'query term unexpectedly carries ciphertext', + ) + + // Bulk form is position-stable, nulls pass through. + const bulk = await client.encryptQueryBulk([ + { value: plaintext, column: users.email, table: users }, + { value: null, column: users.email, table: users }, + ]) + assertEquals(bulk.length, 2) + assertExists(bulk[0]) + assertEquals(bulk[1], null) }, }) diff --git a/packages/stack/__tests__/helpers/stub-protect-ffi-wasm-inline.ts b/packages/stack/__tests__/helpers/stub-protect-ffi-wasm-inline.ts index e26d34df8..54896cec2 100644 --- a/packages/stack/__tests__/helpers/stub-protect-ffi-wasm-inline.ts +++ b/packages/stack/__tests__/helpers/stub-protect-ffi-wasm-inline.ts @@ -20,6 +20,18 @@ export const encrypt = (): never => { ) } +export const encryptQuery = (): never => { + throw new Error( + '[test stub]: protect-ffi/wasm-inline encryptQuery not implemented', + ) +} + +export const encryptQueryBulk = (): never => { + throw new Error( + '[test stub]: protect-ffi/wasm-inline encryptQueryBulk not implemented', + ) +} + export const isEncrypted = (): boolean => false export const newClient = (): never => { diff --git a/packages/stack/__tests__/wasm-inline-query.test.ts b/packages/stack/__tests__/wasm-inline-query.test.ts new file mode 100644 index 000000000..31568db77 --- /dev/null +++ b/packages/stack/__tests__/wasm-inline-query.test.ts @@ -0,0 +1,257 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// #662: the WASM entry omitted `encryptQuery` entirely, making searchable +// encryption architecturally impossible on Deno/edge. These tests pin the new +// surface: index-type resolution (a local port of the native client's +// `resolveIndexType` — keep behaviour in lockstep), the FFI opts shape, null +// handling, and bulk position-stability. The protect-ffi WASM module is +// mocked; live coverage runs in the Deno e2e. + +const ffi = vi.hoisted(() => ({ + newClient: vi.fn(async () => ({ handle: 'wasm-client' })), + encrypt: vi.fn(async () => ({ v: 3, i: {}, c: 'ct' })), + decrypt: vi.fn(async () => 'plain'), + isEncrypted: vi.fn(() => true), + encryptQuery: vi.fn(async () => ({ v: 3, i: { t: 'users', c: 'email' } })), + encryptQueryBulk: vi.fn( + async (_client: unknown, { queries }: { queries: unknown[] }) => + queries.map((_, n) => ({ v: 3, n })), + ), +})) +vi.mock('@cipherstash/protect-ffi/wasm-inline', () => ffi) +vi.mock('@cipherstash/auth/wasm-inline', () => ({ + AccessKeyStrategy: { + create: vi.fn(() => ({ + data: { getToken: async () => ({ token: 'test' }) }, + })), + }, + OidcFederationStrategy: {}, +})) + +import { encryptedTable, types } from '../src/eql/v3' +import { Encryption } from '../src/wasm-inline' + +const users = encryptedTable('users', { + // TextEq → unique index only + email: types.TextEq('email'), + // TextSearch → unique + ope + match + bio: types.TextSearch('bio'), + // IntegerOrd → ope only (the OPE ordering flavour, no unique) + age: types.IntegerOrd('age'), + // Json → ste_vec + prefs: types.Json('prefs'), +}) + +async function client() { + return Encryption({ + schemas: [users], + config: { + workspaceCrn: 'crn:test:ws', + accessKey: 'test-key', + clientId: 'id', + clientKey: 'key', + }, + }) +} + +describe('WasmEncryptionClient.encryptQuery', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('resolves the index type from the column when queryType is omitted', async () => { + const c = await client() + await c.encryptQuery('a@b.com', { table: users, column: users.email }) + + expect(ffi.encryptQuery).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + plaintext: 'a@b.com', + table: 'users', + column: 'email', + indexType: 'unique', + }), + ) + // WASM serde rejects explicitly-undefined fields — queryOp must be + // OMITTED for non-JSON query types, not passed as undefined. + expect( + 'queryOp' in + (ffi.encryptQuery.mock.calls[0][1] as Record), + ).toBe(false) + }) + + it('honours an explicit queryType and maps it to the FFI index', async () => { + const c = await client() + await c.encryptQuery('needle', { + table: users, + column: users.bio, + queryType: 'freeTextSearch', + }) + + expect(ffi.encryptQuery).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ indexType: 'match' }), + ) + }) + + it("swaps orderAndRange's static 'ore' for the v3 ord domain's 'ope'", async () => { + // The live Deno matrix caught this: v3 `_ord` domains carry `ope`, not + // `ore` — the shared resolver (now used verbatim instead of a local + // port) swaps to the ordering index the column actually configures. + const c = await client() + await c.encryptQuery(42, { + table: users, + column: users.age, + queryType: 'orderAndRange', + }) + expect(ffi.encryptQuery).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ indexType: 'ope' }), + ) + }) + + it('answers equality via the ordering index on order-capable columns without unique', async () => { + const c = await client() + await c.encryptQuery(42, { + table: users, + column: users.age, + queryType: 'equality', + }) + expect(ffi.encryptQuery).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ indexType: 'ope' }), + ) + }) + + it('rejects a queryType the column is not indexed for', async () => { + const c = await client() + await expect( + c.encryptQuery('a@b.com', { + table: users, + column: users.email, + queryType: 'freeTextSearch', + }), + ).rejects.toThrow(/not configured on column "email"/) + expect(ffi.encryptQuery).not.toHaveBeenCalled() + }) + + it('infers ste_vec queryOp from the plaintext shape (searchableJson)', async () => { + const c = await client() + await c.encryptQuery('$.user.email', { + table: users, + column: users.prefs, + queryType: 'searchableJson', + }) + expect(ffi.encryptQuery).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + indexType: 'ste_vec', + queryOp: 'ste_vec_selector', + }), + ) + + await c.encryptQuery( + { role: 'admin' }, + { table: users, column: users.prefs, queryType: 'searchableJson' }, + ) + expect(ffi.encryptQuery).toHaveBeenLastCalledWith( + expect.anything(), + expect.objectContaining({ + indexType: 'ste_vec', + queryOp: 'ste_vec_term', + }), + ) + }) + + it('returns null for null plaintext without calling the FFI', async () => { + const c = await client() + expect( + await c.encryptQuery(null, { table: users, column: users.email }), + ).toBeNull() + expect(ffi.encryptQuery).not.toHaveBeenCalled() + }) + + // The same pre-FFI guards the native client runs — an invalid value must + // fail with the NAMED error before any FFI/network crossing, not with an + // opaque serde failure (or a silently no-match term) from inside WASM. + it('rejects NaN with the named validation error before the FFI', async () => { + const c = await client() + await expect( + c.encryptQuery(Number.NaN, { + table: users, + column: users.age, + queryType: 'orderAndRange', + }), + ).rejects.toThrow('[encryption]: Cannot encrypt NaN value') + expect(ffi.encryptQuery).not.toHaveBeenCalled() + }) + + it('rejects a numeric value against a match index before the FFI', async () => { + const c = await client() + await expect( + c.encryptQuery(42, { + table: users, + column: users.bio, + queryType: 'freeTextSearch', + }), + ).rejects.toThrow(/Cannot use 'match' index with numeric value/) + expect(ffi.encryptQuery).not.toHaveBeenCalled() + }) + + it('applies the same validation on the bulk path', async () => { + const c = await client() + await expect( + c.encryptQueryBulk([ + { + value: Number.POSITIVE_INFINITY, + table: users, + column: users.age, + queryType: 'orderAndRange', + }, + ]), + ).rejects.toThrow('[encryption]: Cannot encrypt Infinity value') + expect(ffi.encryptQueryBulk).not.toHaveBeenCalled() + }) +}) + +describe('WasmEncryptionClient.encryptQueryBulk', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('is position-stable: null values yield null at the same index', async () => { + const c = await client() + const out = await c.encryptQueryBulk([ + { value: 'a@b.com', table: users, column: users.email }, + { value: null, table: users, column: users.email }, + { + value: 'needle', + table: users, + column: users.bio, + // TextSearch carries unique+ore+match; explicit queryType targets + // the free-text index (inference would pick unique by priority, + // matching the native client). + queryType: 'freeTextSearch' as const, + }, + ]) + + expect(out).toHaveLength(3) + expect(out[0]).toEqual({ v: 3, n: 0 }) + expect(out[1]).toBeNull() + expect(out[2]).toEqual({ v: 3, n: 1 }) + // Only the two live terms reached the FFI, with per-term resolution. + const { queries } = ffi.encryptQueryBulk.mock.calls[0][1] as { + queries: Array<{ indexType: string }> + } + expect(queries.map((t) => t.indexType)).toEqual(['unique', 'match']) + }) + + it('short-circuits an all-null batch', async () => { + const c = await client() + const out = await c.encryptQueryBulk([ + { value: null, table: users, column: users.email }, + ]) + expect(out).toEqual([null]) + expect(ffi.encryptQueryBulk).not.toHaveBeenCalled() + }) +}) diff --git a/packages/stack/integration/vitest.config.ts b/packages/stack/integration/vitest.config.ts index df1a71441..25e732b0e 100644 --- a/packages/stack/integration/vitest.config.ts +++ b/packages/stack/integration/vitest.config.ts @@ -65,7 +65,23 @@ for (const glob of SUITE_GLOBS) { } export default defineConfig({ - resolve: { alias: { ...sharedAlias, ...stackSourceAlias } }, + resolve: { + alias: { + ...sharedAlias, + ...stackSourceAlias, + // stackSourceAlias stubs the WASM-entry FFI/auth modules for UNIT + // tests. Integration suites (integration/wasm/**) exercise the real + // WASM path against live ZeroKMS — restore the genuine modules. + '@cipherstash/protect-ffi/wasm-inline': resolve( + __dirname, + '../node_modules/@cipherstash/protect-ffi/dist/wasm/protect_ffi_inline.js', + ), + '@cipherstash/auth/wasm-inline': resolve( + __dirname, + '../node_modules/@cipherstash/auth/wasm-inline.mjs', + ), + }, + }, test: { root: resolve(__dirname, '..'), // Unlike the adapter packages (fixed glob), stack's integration job scopes to diff --git a/packages/stack/integration/wasm/adapter.ts b/packages/stack/integration/wasm/adapter.ts new file mode 100644 index 000000000..cbf2b645e --- /dev/null +++ b/packages/stack/integration/wasm/adapter.ts @@ -0,0 +1,427 @@ +/** + * `IntegrationAdapter` over `@cipherstash/stack/wasm-inline` (#662) — runs the + * shared v3 family suite against the WASM entry, giving `encryptQuery` / + * `encryptQueryBulk` the same per-domain live row-matching coverage the + * Drizzle/Supabase adapters have. + * + * The WASM surface mints terms only (no operator builder, no model helpers), + * so this adapter IS the SQL layer: inserts encrypt per-field via + * `client.encrypt`, and every query op renders the documented raw-SQL shape — + * `eql_v3.(col, $n::jsonb::eql_v3.query_)` — that edge consumers + * hand-write. Passing here proves the whole recipe end to end: WASM-minted + * term → query-domain cast → indexed operator → correct row set. + * + * The SQL shapes deliberately mirror the Drizzle v3 dialect + * (`packages/stack-drizzle/src/v3/sql-dialect.ts`): `eq`/`neq`, comparison + * fns, parenthesised `gte AND lte` ranges, `contains` for bloom matching, and + * `ord_term` ordering. `in`/`notIn` decompose to OR-of-eq / AND-of-neq over + * one `encryptQueryBulk` batch — exercising the bulk path on every family. + */ + +import type { + IntegrationAdapter, + Plain, + PlainRow, + QueryOp, + QueryOpKind, + TableSpec, +} from '@cipherstash/test-kit' +import { databaseUrl } from '@cipherstash/test-kit' +import postgres from 'postgres' +import { type AnyV3Table, encryptedTable } from '@/eql/v3' +import type { BuildableV3QueryableColumn } from '@/types' +import { + Encryption as WasmEncryption, + type WasmEncryptionClient, + type WasmPlaintext, +} from '@/wasm-inline' + +const SUPPORTED_OPS: ReadonlySet = new Set([ + 'eq', + 'ne', + 'in', + 'notIn', + 'gt', + 'gte', + 'lt', + 'lte', + 'between', + 'notBetween', + 'matches', + 'order', + 'isNull', + 'isNotNull', +]) + +/** + * The WASM factory has no auto/dev-profile strategy (#663): it hard-requires + * explicit credentials, so — unlike the native adapters, where a + * `~/.cipherstash` profile satisfies the gate — this adapter needs the four + * `CS_*` variables themselves. Fail loud, per the harness doctrine. + */ +function requireWasmCreds() { + const keys = [ + 'CS_WORKSPACE_CRN', + 'CS_CLIENT_ACCESS_KEY', + 'CS_CLIENT_ID', + 'CS_CLIENT_KEY', + ] as const + const missing = keys.filter((k) => !process.env[k]) + if (missing.length > 0) { + throw new Error( + `WASM integration adapter: missing ${missing.join(', ')}. The ` + + '`@cipherstash/stack/wasm-inline` factory requires explicit ' + + 'credentials (no dev-profile fallback — see #663), so the CS_* ' + + 'variables must be set even when a ~/.cipherstash profile exists.', + ) + } + return { + workspaceCrn: process.env.CS_WORKSPACE_CRN as string, + accessKey: process.env.CS_CLIENT_ACCESS_KEY as string, + clientId: process.env.CS_CLIENT_ID as string, + clientKey: process.env.CS_CLIENT_KEY as string, + } +} + +/** + * `WasmPlaintext` has no `Date` arm (the WASM boundary serializes via serde, + * which doesn't consult `toJSON`) — send date/timestamp values as ISO + * strings, the same wire form the native SDK's `Date` handling produces. + */ +function toWasmPlaintext(value: Plain): WasmPlaintext { + return value instanceof Date ? value.toISOString() : value +} + +/** `public.eql_v3_text_eq` → `eql_v3.query_text_eq`; irregular: json → jsonb. */ +function queryDomain(eqlType: string): string { + const suffix = eqlType.replace(/^public\.eql_v3_/, '') + return suffix === 'json' ? 'eql_v3.query_jsonb' : `eql_v3.query_${suffix}` +} + +const QUERY_TYPE_BY_KIND = { + eq: 'equality', + ne: 'equality', + in: 'equality', + notIn: 'equality', + gt: 'orderAndRange', + gte: 'orderAndRange', + lt: 'orderAndRange', + lte: 'orderAndRange', + between: 'orderAndRange', + notBetween: 'orderAndRange', + matches: 'freeTextSearch', +} as const + +type ColumnEntry = { + column: BuildableV3QueryableColumn + eqlType: string +} + +export function makeWasmAdapter(): IntegrationAdapter { + const creds = requireWasmCreds() + + let sql: postgres.Sql + let tableName = '' + let client: WasmEncryptionClient + let tableSchema: AnyV3Table + let columns: Record = {} + + function col(slug: string): ColumnEntry { + const entry = columns[slug] + if (!entry) throw new Error(`Unknown column slug "${slug}"`) + return entry + } + + /** Mint one term and pair it with its query-domain cast. */ + async function term( + slug: string, + value: Plain, + kind: keyof typeof QUERY_TYPE_BY_KIND, + ): Promise<{ param: unknown; cast: string }> { + const { column, eqlType } = col(slug) + const encrypted = await client.encryptQuery(toWasmPlaintext(value), { + table: tableSchema, + column, + queryType: QUERY_TYPE_BY_KIND[kind], + }) + return { param: encrypted, cast: queryDomain(eqlType) } + } + + // Payload params are bound as RAW OBJECTS, never pre-stringified. The + // `$n::jsonb` casts make the server type those params as jsonb, and + // postgres.js serializes jsonb params with JSON.stringify — so a + // pre-stringified payload gets stringified AGAIN, arriving as a jsonb + // *string* scalar that fails every `jsonb_typeof(VALUE) = 'object'` + // domain CHECK. (Reproduced against postgres-eql with a hand-valid + // envelope; psql accepts what the double-encoded binding does not.) + async function selectKeys( + where: string, + params: readonly unknown[], + orderBy = 'row_key ASC', + ): Promise { + const rows = await sql.unsafe( + `SELECT row_key FROM ${tableName} WHERE ${where} ORDER BY ${orderBy}`, + params as never[], + ) + return rows.map((r) => (r as { row_key: string }).row_key) + } + + async function insertRow(assignments: Record) { + const keys = Object.keys(assignments) + const colList = keys.map((k) => `"${k}"`).join(', ') + const placeholders = keys + .map((k, i) => (k === 'row_key' ? `$${i + 1}` : `$${i + 1}::jsonb`)) + .join(', ') + // Raw objects, NOT pre-stringified — see the serializer note on + // `selectKeys`. postgres.js JSON-encodes them exactly once for the + // jsonb-typed placeholders. + const params = keys.map((k) => assignments[k]) + try { + await sql.unsafe( + `INSERT INTO ${tableName} (${colList}) VALUES (${placeholders})`, + params as never[], + ) + } catch (cause) { + // Echo what actually reached the driver: `assertWireEnvelope` has + // already vouched for the JS-side shape, so a domain-CHECK failure + // here implicates the SQL binding, and the exact params are the + // evidence. Ciphertext only — EQL payloads never contain plaintext. + throw new Error( + `INSERT into ${tableName} failed for row "${assignments['row_key']}". ` + + `Bound params (truncated): ${params.map((p) => JSON.stringify(p)?.slice(0, 160)).join(' | ')}`, + { cause }, + ) + } + } + + /** + * Pin the JS-side shape of a storage payload BEFORE it goes anywhere near + * SQL. The wasm boundary (serde-wasm-bindgen) can produce values that + * round-trip through the FFI fine but collapse under `JSON.stringify` — + * e.g. a JS `Map` stringifies to `{}` — and the resulting Postgres + * domain-CHECK error names the column's domain, not the cause. Failing + * here instead names the payload. + */ + function assertWireEnvelope(slug: string, payload: unknown): void { + const json = JSON.stringify(payload) + const wire = json === undefined ? undefined : JSON.parse(json) + const ok = + wire !== null && + typeof wire === 'object' && + !Array.isArray(wire) && + String(wire.v) === '3' && + 'i' in wire && + 'c' in wire + if (!ok) { + throw new Error( + `WASM encrypt for column "${slug}" did not produce a JSON-stringifiable v3 envelope. ` + + `typeof=${typeof payload}, ctor=${(payload as object)?.constructor?.name}, ` + + `isMap=${payload instanceof Map}, ` + + `ownKeys=[${payload && typeof payload === 'object' ? Object.keys(payload).join(', ') : ''}], ` + + `wire=${String(json).slice(0, 300)}`, + ) + } + } + + async function encryptRow(row: PlainRow): Promise> { + const assignments: Record = { row_key: row.rowKey } + // Field encrypts are independent ZeroKMS round-trips — run them + // concurrently rather than paying fields × RTT per row. + await Promise.all( + Object.entries(row.values).map(async ([slug, value]) => { + const encrypted = await client.encrypt(toWasmPlaintext(value), { + table: tableSchema, + column: col(slug).column, + }) + assertWireEnvelope(slug, encrypted) + assignments[slug] = encrypted + }), + ) + return assignments + } + + async function run(op: QueryOp): Promise { + switch (op.kind) { + case 'eq': + case 'ne': + case 'gt': + case 'gte': + case 'lt': + case 'lte': { + const fn = op.kind === 'ne' ? 'neq' : op.kind + const t = await term(op.column, op.value, op.kind) + return selectKeys( + `eql_v3.${fn}("${op.column}", $1::jsonb::${t.cast})`, + [t.param], + ) + } + case 'in': + case 'notIn': { + // Parity with the production operators (drizzle inArrayOp): an + // empty list would otherwise render `WHERE ()` — a syntax error, + // not empty-IN semantics. + if (op.values.length === 0) { + throw new Error(`${op.kind} requires a non-empty list of values`) + } + // One encryptQueryBulk crossing for the whole list — the bulk path + // gets exercised on every family this way. + const { column, eqlType } = col(op.column) + const encrypted = await client.encryptQueryBulk( + op.values.map((value) => ({ + value: toWasmPlaintext(value), + table: tableSchema, + column, + queryType: 'equality' as const, + })), + ) + const cast = queryDomain(eqlType) + const fn = op.kind === 'in' ? 'eq' : 'neq' + const joiner = op.kind === 'in' ? ' OR ' : ' AND ' + const clauses = encrypted.map( + (_, i) => `eql_v3.${fn}("${op.column}", $${i + 1}::jsonb::${cast})`, + ) + return selectKeys(`(${clauses.join(joiner)})`, encrypted) + } + case 'between': + case 'notBetween': { + const [lo, hi] = await Promise.all([ + term(op.column, op.lo, op.kind), + term(op.column, op.hi, op.kind), + ]) + // Parenthesised conjunction, mirroring the Drizzle dialect's + // load-bearing parentheses (NOT binds tighter than AND). + const range = `(eql_v3.gte("${op.column}", $1::jsonb::${lo.cast}) AND eql_v3.lte("${op.column}", $2::jsonb::${hi.cast}))` + return selectKeys(op.kind === 'between' ? range : `NOT ${range}`, [ + lo.param, + hi.param, + ]) + } + case 'matches': { + const t = await term(op.column, op.needle, 'matches') + return selectKeys( + `eql_v3.contains("${op.column}", $1::jsonb::${t.cast})`, + [t.param], + ) + } + case 'order': { + // OPE ordering (`ord_term`) — the block-ORE domains are deferred in + // the catalog, so `ord_term_ore` never applies here. The secondary + // `row_key ASC` mirrors the oracle's tie-break: domains with fewer + // samples than rows (date/timestamp have two) guarantee tied values, + // and without it the tied rows come back in arbitrary order. + return selectKeys( + `"${op.column}" IS NOT NULL`, + [], + `eql_v3.ord_term("${op.column}") ${op.direction.toUpperCase()}, row_key ASC`, + ) + } + case 'isNull': + return selectKeys(`"${op.column}" IS NULL`, []) + case 'isNotNull': + return selectKeys(`"${op.column}" IS NOT NULL`, []) + } + } + + return { + name: 'wasm', + supportedOps: SUPPORTED_OPS, + alwaysRejectedOps: new Set(), + + async setup() { + sql = postgres(databaseUrl(), { prepare: false }) + }, + + async teardown() { + if (tableName) await sql.unsafe(`DROP TABLE IF EXISTS ${tableName}`) + await sql.end() + }, + + async createTable(spec: TableSpec) { + tableName = spec.name + + const cols = Object.fromEntries( + spec.columns.map((c) => [c.slug, c.spec.builder(c.slug)]), + ) + tableSchema = encryptedTable(spec.name, cols as never) + columns = Object.fromEntries( + spec.columns.map((c) => [ + c.slug, + { + column: ( + tableSchema as unknown as Record + )[c.slug], + eqlType: c.eqlType, + }, + ]), + ) + + // DDL comes from the column builders, not a hand-written list, so a + // domain rename cannot silently desync the table from the schema. + const ddl = spec.columns + .map((c) => `"${c.slug}" ${c.eqlType}`) + .join(',\n ') + await sql.unsafe(`DROP TABLE IF EXISTS ${spec.name}`) + await sql.unsafe(` + CREATE TABLE ${spec.name} ( + row_key TEXT PRIMARY KEY, + ${ddl} + ) + `) + + // Rebuilt per family — the factory pins the schema set at construction. + client = await WasmEncryption({ + schemas: [tableSchema], + config: creds, + }) + }, + + // The WASM entry has no model helpers (encryptModel/bulkEncryptModels + // live on the Node client), so BOTH insert paths encrypt per field via + // `encrypt` — the surface an edge function actually has. The kit's + // single/bulk split still runs; it just exercises the same primitive. + async insertSingle(_spec: TableSpec, row: PlainRow) { + await insertRow(await encryptRow(row)) + }, + + async insertBulk(_spec: TableSpec, rows: readonly PlainRow[]) { + // Rows are independent (distinct row_key PKs, no ordering + // dependency) — encrypt and insert them concurrently. + await Promise.all( + rows.map(async (row) => insertRow(await encryptRow(row))), + ) + }, + + async run(_spec: TableSpec, op: QueryOp) { + return run(op) + }, + + // Discriminate capability rejections from infrastructure failures — + // this adapter's run() path is fully live (ZeroKMS + real SQL), so a + // bare catch would record a connection reset or a dropped table as a + // passing negative test. Same doctrine as the Supabase adapter's + // expectRejected. Two shapes are legitimate rejections here: + // - client-side: resolveIndexType / the pre-FFI validators throw when + // the queryType isn't configured on the column; + // - server-side (order ops only): domains without an ordering index + // have no eql_v3.ord_term overload, so Postgres rejects the call. + async expectRejected(_spec: TableSpec, op: QueryOp) { + try { + await run(op) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + const isCapabilityRejection = + /is not configured on column|has no indexes configured|\[encryption\]:/.test( + message, + ) || /ord_term/.test(message) + if (isCapabilityRejection) return + throw new Error( + `Expected ${op.kind} on "${op.column}" to be rejected by a capability error, but got an unrelated failure: ${message}`, + { cause: error }, + ) + } + throw new Error( + `Expected ${op.kind} on "${op.column}" to be rejected, but it ran.`, + ) + }, + } +} diff --git a/packages/stack/integration/wasm/families.integration.test.ts b/packages/stack/integration/wasm/families.integration.test.ts new file mode 100644 index 000000000..61ec59260 --- /dev/null +++ b/packages/stack/integration/wasm/families.integration.test.ts @@ -0,0 +1,14 @@ +/** + * The full v3 family matrix over the WASM entry (#662): every covered domain, + * every capability-derived positive and negative op, real ZeroKMS encryption + * and real rows in a real Postgres — the same suite the Drizzle and Supabase + * adapters run, driven through `@cipherstash/stack/wasm-inline`'s + * encrypt/encryptQuery/encryptQueryBulk plus the documented raw-SQL casts. + */ +import { FAMILY_NAMES } from '@cipherstash/test-kit' +import { runFamilySuite } from '@cipherstash/test-kit/suite' +import { makeWasmAdapter } from './adapter' + +for (const family of FAMILY_NAMES) { + runFamilySuite(family, makeWasmAdapter) +} diff --git a/packages/stack/src/wasm-inline.ts b/packages/stack/src/wasm-inline.ts index 4f3b9eab9..7ce39ee24 100644 --- a/packages/stack/src/wasm-inline.ts +++ b/packages/stack/src/wasm-inline.ts @@ -36,6 +36,17 @@ * table: users, * }) * const dec = await client.decrypt(enc) + * + * // Searchable encryption: mint a ciphertext-free QUERY TERM and cast it + * // to the column's `eql_v3.query_` type in SQL to hit the index. + * const term = await client.encryptQuery("alice@example.com", { + * column: users.email, + * table: users, + * queryType: "freeTextSearch", + * }) + * // e.g. postgres-js: + * // sql`SELECT * FROM users + * // WHERE eql_v3.contains(email, ${term}::jsonb::eql_v3.query_text_search)` * ``` * * For per-user, identity-bound encryption on the edge, build an @@ -67,9 +78,16 @@ import { import { decrypt as wasmDecrypt, encrypt as wasmEncrypt, + encryptQuery as wasmEncryptQuery, + encryptQueryBulk as wasmEncryptQueryBulk, isEncrypted as wasmIsEncrypted, newClient as wasmNewClient, } from '@cipherstash/protect-ffi/wasm-inline' +import { resolveIndexType } from '@/encryption/helpers/infer-index-type' +import { + assertValidNumericValue, + assertValueIndexCompatibility, +} from '@/encryption/helpers/validation' import { type AnyV3Table, buildEncryptConfig } from '@/eql/v3' import { type CastAs, @@ -77,7 +95,14 @@ import { encryptConfigSchema, toEqlCastAs, } from '@/schema' -import type { Encrypted, EncryptOptions } from '@/types' +import type { + BuildableV3QueryableColumn, + Encrypted, + EncryptedQuery, + EncryptOptions, + Plaintext, + QueryTypeName, +} from '@/types' // ----------------------------------------------------------------------- // Schema + type re-exports @@ -230,6 +255,49 @@ export type WasmEncryptionConfig = { config: WasmClientConfig } +/** + * Options for {@link WasmEncryptionClient.encryptQuery}. + * + * The column must be a QUERYABLE v3 column (authored via the `types.*` + * factories re-exported from this entry) — storage-only columns like + * `types.Text` with no indexes have nothing to query. + */ +export type WasmEncryptQueryOptions = { + /** The `encryptedTable(...)` the column belongs to. */ + table: EncryptOptions['table'] + /** The queryable v3 column the term targets, e.g. `users.email`. */ + column: BuildableV3QueryableColumn + /** + * Which of the column's indexes the term targets: + * + * - `'equality'` — exact match (`unique` index; `=` / `IN`) + * - `'freeTextSearch'` — fuzzy token match (`match` index; one-sided — + * a match may be a false positive, a non-match never is) + * - `'orderAndRange'` — comparisons and ranges (`ore` index; `<` `>` + * `BETWEEN` / `ORDER BY`) + * - `'searchableJson'` — encrypted JSON (`ste_vec` index): a **string** + * value is treated as a JSONPath selector (`'$.user.email'`), any other + * value as a containment needle (`{ role: 'admin' }`) + * + * Omit to infer from the column's configured indexes (priority: + * `unique > match > ore > ste_vec`, matching the native client) — + * unambiguous for single-index columns like `types.TextEq`, but be + * explicit for multi-index domains like `types.TextSearch` (which + * carries all three scalar indexes). + */ + queryType?: QueryTypeName +} + +/** + * One term for {@link WasmEncryptionClient.encryptQueryBulk} — the + * {@link WasmEncryptQueryOptions} plus the plaintext needle. A `null` + * value yields `null` at the same position in the result (nothing to + * search for). + */ +export type WasmQueryTerm = WasmEncryptQueryOptions & { + value: WasmPlaintext +} + /** * Internal token used to gate the {@link WasmEncryptionClient} * constructor. Symbols are unique by reference, so external code can't @@ -241,10 +309,11 @@ const INTERNAL_CONSTRUCT = Symbol('cs-wasm-client') /** * WASM encryption client. Returned by {@link Encryption}. * - * Wraps an opaque `wasmNewClient` handle and exposes a minimal - * `encrypt` / `decrypt` surface. Larger surface (bulk, query, model - * helpers) lives on the Node entry — port lazily as Deno / edge - * consumers demand it. + * Wraps an opaque `wasmNewClient` handle and exposes `encrypt`, `decrypt`, + * `isEncrypted`, and — since #662 made searchable encryption reachable on + * the edge — `encryptQuery` / `encryptQueryBulk` for minting v3 query + * terms. Remaining surface (bulk encrypt/decrypt, model helpers) lives on + * the Node entry — port lazily as Deno / edge consumers demand it. * * Construct via {@link Encryption} — the constructor is private to * prevent callers from wrapping arbitrary objects in this type. @@ -295,6 +364,144 @@ export class WasmEncryptionClient { isEncrypted(value: unknown): boolean { return wasmIsEncrypted(value as never) } + + /** + * Encrypt a QUERY TERM (search needle) for a queryable v3 column — + * equality, free-text match, ORE range, or JSON containment/selector. + * + * The returned term is **ciphertext-free**: it is matched against stored + * envelopes, never decrypted, so it is safe to log-scrub less aggressively + * than storage payloads (though it still derives from the plaintext). + * Interpolate it as a parameter and cast to the column's + * `eql_v3.query_` type to reach the indexed operators — the domain + * suffix mirrors the storage domain (`eql_v3_text_eq` → + * `eql_v3.query_text_eq`; irregular: `eql_v3_json` → `eql_v3.query_jsonb`). + * + * @example Equality (unique index) + * ```ts + * const term = await client.encryptQuery("alice@example.com", { + * table: users, column: users.email, queryType: "equality", + * }) + * // postgres-js: + * sql`SELECT * FROM users + * WHERE email = ${term}::jsonb::eql_v3.query_text_eq` + * ``` + * + * @example Free-text match (bloom index — one-sided, fuzzy) + * ```ts + * const term = await client.encryptQuery("needle", { + * table: users, column: users.bio, queryType: "freeTextSearch", + * }) + * sql`SELECT * FROM users + * WHERE eql_v3.contains(bio, ${term}::jsonb::eql_v3.query_text_search)` + * ``` + * + * @example Range / ORDER BY (ORE index) + * ```ts + * const term = await client.encryptQuery(42, { + * table: users, column: users.age, queryType: "orderAndRange", + * }) + * sql`SELECT * FROM users + * WHERE eql_v3.gte(age, ${term}::jsonb::eql_v3.query_integer_ord)` + * ``` + * + * @example Encrypted JSON — containment and JSONPath selector + * ```ts + * // Object value → containment needle (a v3 envelope): + * const contains = await client.encryptQuery({ role: "admin" }, { + * table: users, column: users.prefs, queryType: "searchableJson", + * }) + * sql`SELECT * FROM users + * WHERE prefs @> ${contains}::jsonb::eql_v3.query_jsonb` + * + * // String value → JSONPath selector. NOTE: v3 has no encrypted-selector + * // envelope — this returns the BARE selector-hash string, bound as the + * // text argument of -> / ->>: + * const selector = await client.encryptQuery("$.role", { + * table: users, column: users.prefs, queryType: "searchableJson", + * }) + * sql`SELECT prefs -> ${selector} FROM users` + * ``` + * + * @param plaintext - The search needle. `null`/`undefined` returns `null` + * without contacting ZeroKMS (nothing to search for), mirroring the + * native client. + * @param opts - Table, column, and (optionally) which index to target — + * see {@link WasmEncryptQueryOptions.queryType} for the inference rules. + * @returns The v3 query term, or `null` for null plaintext. + * @throws When the requested `queryType` isn't configured on the column, + * the column has no indexes at all, the value fails the same pre-flight + * validation the native client runs (NaN / Infinity / out-of-int64 + * bigint, or a numeric value against a `freeTextSearch` index), or + * encryption fails. Errors THROW, consistent with this surface's + * `encrypt`/`decrypt` (the native entry's `{ data } | { failure }` + * envelope lives on the Node client only). + */ + async encryptQuery( + plaintext: WasmPlaintext, + opts: WasmEncryptQueryOptions, + ): Promise { + if (plaintext === null || plaintext === undefined) return null + return (await wasmEncryptQuery( + // biome-ignore lint/plugin: the FFI handle is an opaque wasm-bindgen pointer with no JS-side type + this.client as never, + // biome-ignore lint/plugin: the term crosses the serde boundary, whose shape protect-ffi types as `any` + toFfiQueryTerm(plaintext, opts) as never, + )) as EncryptedQuery + } + + /** + * Batch form of {@link encryptQuery} — one ZeroKMS round trip for many + * terms, which is the shape query builders want (encrypt every needle in + * a WHERE clause together). + * + * Position-stable: the result array is index-aligned with `terms`, and a + * `null`/`undefined` value yields `null` at the same index (an all-null + * batch short-circuits without calling ZeroKMS). Terms may mix query + * types and columns freely. + * + * @example + * ```ts + * const [emailEq, bioMatch] = await client.encryptQueryBulk([ + * { value: "alice@example.com", table: users, column: users.email, + * queryType: "equality" }, + * { value: "needle", table: users, column: users.bio, + * queryType: "freeTextSearch" }, + * ]) + * ``` + * + * @param terms - The needles to encrypt; see {@link WasmQueryTerm}. + * @returns Index-aligned array of v3 query terms (or `null` per null value). + * @throws As {@link encryptQuery} — the first invalid term aborts the batch. + */ + async encryptQueryBulk( + terms: readonly WasmQueryTerm[], + ): Promise> { + const live: Array<{ term: WasmQueryTerm; at: number }> = [] + terms.forEach((term, at) => { + if (term.value !== null && term.value !== undefined) + live.push({ term, at }) + }) + const out: Array = terms.map(() => null) + if (live.length === 0) return out + + const ffiTerms = live.map(({ term }) => toFfiQueryTerm(term.value, term)) + // The FFI's batch field is `queries` (matching the native + // ffiEncryptQueryBulk call in packages/protect). + const encrypted = (await wasmEncryptQueryBulk( + // biome-ignore lint/plugin: the FFI handle is an opaque wasm-bindgen pointer with no JS-side type + this.client as never, + // biome-ignore lint/plugin: the batch crosses the serde boundary, whose shape protect-ffi types as `any` + { + queries: ffiTerms, + } as never, + )) as EncryptedQuery[] + encrypted.forEach((value, i) => { + const slot = live[i] + if (slot) out[slot.at] = value + }) + return out + } } /** @@ -419,6 +626,48 @@ export function getColumnName(col: EncryptOptions['column']): string { ) } +/** + * Build the FFI term for one query needle — the ONE place the single and + * bulk paths share, so the subtle parts can't drift between them. + * + * Runs the same pre-FFI guards as the native client's query operations + * (`assertValidNumericValue`, `assertValueIndexCompatibility`): NaN / + * Infinity / out-of-int64 bigint and numeric-on-match-index fail with the + * same named errors the Node entry raises, instead of an opaque serde + * failure — or a silently no-match term — from inside the WASM boundary. + * + * Index resolution is the SAME resolver the native client uses + * (`@/encryption/helpers` is type-only on protect-ffi, so it's + * WASM-bundle-safe): explicit queryType validated against the column's + * indexes; v3 ord domains' `ope` swapped in for `orderAndRange`; equality + * answered via the ordering index on order-capable columns without + * `unique`; inference priority unique > match > ore/ope > ste_vec. + * + * serde on the WASM side rejects explicitly-undefined fields ("invalid + * type: unit value, expected a string") — OMIT `queryOp` when absent (the + * native NAPI layer tolerates undefined; the WASM one does not). + * + * WasmPlaintext widens Plaintext with `bigint` (carried natively over the + * WASM boundary); the resolver only `typeof`-inspects the value for + * ste_vec op inference, so the assertion is safe. + */ +function toFfiQueryTerm(value: WasmPlaintext, opts: WasmEncryptQueryOptions) { + assertValidNumericValue(value) + const { indexType, queryOp } = resolveIndexType( + opts.column, + opts.queryType, + value as Plaintext, + ) + assertValueIndexCompatibility(value, indexType, getColumnName(opts.column)) + return { + plaintext: value, + table: opts.table.tableName, + column: getColumnName(opts.column), + indexType, + ...(queryOp ? { queryOp } : {}), + } +} + // Emit the `config.strategy` → `config.authStrategy` rename warning at most // once per process so repeated `Encryption()` calls don't spam the console. // Mirrors the identical latch on the Node entry (`@/encryption`); the two are diff --git a/packages/test-kit/src/adapter.ts b/packages/test-kit/src/adapter.ts index 52670479d..5a06fd546 100644 --- a/packages/test-kit/src/adapter.ts +++ b/packages/test-kit/src/adapter.ts @@ -10,7 +10,7 @@ import type { PlainRow, TableSpec } from './rows.ts' * operations while both suites read as "comprehensive". */ export interface IntegrationAdapter { - readonly name: 'drizzle' | 'supabase' + readonly name: 'drizzle' | 'supabase' | 'wasm' /** * Operations this adapter can express at all, independent of any domain. Both