diff --git a/.changeset/wasm-encrypt-query.md b/.changeset/wasm-encrypt-query.md index 1578b7658..59bb64823 100644 --- a/.changeset/wasm-encrypt-query.md +++ b/.changeset/wasm-encrypt-query.md @@ -14,5 +14,6 @@ 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`). +Both return `{ data } | { failure }`, consistent with the WASM surface's +`encrypt`/`decrypt`; the bulk form is position-stable (`null` values pass +through as `null`). diff --git a/.changeset/wasm-inline-bulk-ops.md b/.changeset/wasm-inline-bulk-ops.md new file mode 100644 index 000000000..5a1304023 --- /dev/null +++ b/.changeset/wasm-inline-bulk-ops.md @@ -0,0 +1,47 @@ +--- +'@cipherstash/stack': minor +'stash': patch +--- + +**Breaking (`@cipherstash/stack/wasm-inline`):** every fallible method now returns a `Result` — `{ data } | { failure }` — instead of throwing. And `bulkEncrypt` / `bulkDecrypt` are added, so a list of encrypted rows costs **one** ZeroKMS round trip instead of one per row. + +### Result alignment + +`encrypt`, `decrypt`, `encryptQuery` and `encryptQueryBulk` previously threw on failure, and returned bare values on success. They now return `{ data } | { failure }`, with `failure.type` drawn from `EncryptionErrorTypes` (`EncryptionError` for encrypt-side operations, `DecryptionError` for decrypt-side) and `failure.code` carrying the FFI error code where there is one. + +```typescript +// before +const encrypted = await client.encrypt(plaintext, { table: users, column: users.email }) + +// after +const result = await client.encrypt(plaintext, { table: users, column: users.email }) +if (result.failure) throw new Error(result.failure.message) +const encrypted = result.data +``` + +This is the contract the native entry has always honoured, and the one `AGENTS.md` states outright: *"Operations return `{ data }` or `{ failure }`. Preserve this shape and error `type` values in `EncryptionErrorTypes`."* The WASM entry never followed it. That was drift rather than a design decision — nothing about WASM prevents it (`@byteslice/result` is already bundled into `dist/wasm-inline.js`), and it meant edge code had to be written in a different shape from every other surface, with failures that were easy to miss. + +Fixed now because it is a breaking change and 1.0.0 has not shipped: `@cipherstash/stack@latest` is still `0.19.0`, so this surface has only ever been published under the `rc` tag. After GA it would have had to wait for a major. + +`isEncrypted` is unchanged — a pure predicate with nothing to fail at, exactly as on the native entry. + +### Bulk operations + +```typescript +// Write: several columns across many rows, one round trip +const encrypted = await client.bulkEncrypt([ + { plaintext: "alice@example.com", table: users, column: users.email }, + { plaintext: "hello", table: users, column: users.bio }, +]) + +// Read: a whole page in one call +const emails = await client.bulkDecrypt(rows.map((r) => r.email)) +``` + +The WASM entry previously exposed no bulk operations at all, so rendering an N-row list on Deno, Cloudflare Workers, or Supabase Edge Functions meant N sequential ZeroKMS calls. Combined with the WASM cold start, that made list endpoints impractical on the edge. + +Both are index-aligned with their input, and `null` / `undefined` entries yield `null` at the same index without reaching ZeroKMS (an all-null batch makes no call at all). Because each entry names its own table and column, a single `bulkEncrypt` can cover several columns across many rows — which is what makes the saving real, since a single-column batch would still cost one round trip per column. + +`bulkDecrypt` builds on the fallible FFI primitive, so when items fail the `failure.message` names **every** failing index with its reason, rather than surfacing the first and discarding the rest. + +The model helpers (`encryptModel` / `decryptModel` and their bulk forms) remain Node-only: the WASM entry has no single-model operation to build them on, so those need their own port. diff --git a/e2e/wasm/query-types.test.ts b/e2e/wasm/query-types.test.ts index c7429c84f..c10b6be8d 100644 --- a/e2e/wasm/query-types.test.ts +++ b/e2e/wasm/query-types.test.ts @@ -72,6 +72,26 @@ const catalog = encryptedTable('wasm_query_matrix', { }) /** A v3 SCALAR query term: versioned envelope (`v: 3`), NO ciphertext. */ +/** + * Unwrap a `{ data } | { failure }` Result (#741 — every fallible method on + * this entry returns one). Asserting on the envelope instead of its payload + * silently passes: `term.v` is `undefined` on a Result, so a shape check reads + * as "not v3" rather than "you forgot to unwrap". + */ +function unwrap( + result: + | { data: T; failure?: never } + | { data?: never; failure: { message: string } }, + label: string, +): T { + assertEquals( + result.failure, + undefined, + `${label}: ${result.failure?.message}`, + ) + return result.data as T +} + function assertV3Term(term: unknown, label: string) { assertExists(term, `${label}: encryptQuery returned null`) const obj = term as Record @@ -119,42 +139,54 @@ Deno.test({ // equality → unique index assertV3Term( - await client.encryptQuery('alice@example.com', { - table: catalog, - column: catalog.email, - queryType: 'equality', - }), + unwrap( + await client.encryptQuery('alice@example.com', { + table: catalog, + column: catalog.email, + queryType: 'equality', + }), + 'equality', + ), 'equality', ) // freeTextSearch → match index assertV3Term( - await client.encryptQuery('needle phrase', { - table: catalog, - column: catalog.bio, - queryType: 'freeTextSearch', - }), + unwrap( + await client.encryptQuery('needle phrase', { + table: catalog, + column: catalog.bio, + queryType: 'freeTextSearch', + }), + 'freeTextSearch', + ), 'freeTextSearch', ) // orderAndRange → ore index (numeric) assertV3Term( - await client.encryptQuery(42, { - table: catalog, - column: catalog.age, - queryType: 'orderAndRange', - }), + unwrap( + await client.encryptQuery(42, { + table: catalog, + column: catalog.age, + queryType: 'orderAndRange', + }), + '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', - }) + const selector = unwrap( + await client.encryptQuery('$.theme', { + table: catalog, + column: catalog.prefs, + queryType: 'searchableJson', + }), + 'selector', + ) assertExists(selector, 'selector: encryptQuery returned null') assertEquals( typeof selector, @@ -171,13 +203,16 @@ Deno.test({ // 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', - }, + unwrap( + await client.encryptQuery( + { theme: 'dark' }, + { + table: catalog, + column: catalog.prefs, + queryType: 'searchableJson', + }, + ), + 'searchableJson/containment', ), 'searchableJson/containment', ) @@ -185,16 +220,19 @@ Deno.test({ // 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, - }), + unwrap( + await client.encryptQuery('bob@example.com', { + table: catalog, + column: catalog.email, + }), + 'inference', + ), 'inference', ) // Bulk: one round trip across mixed query types, position-stable with // nulls passing through. - const bulk = await client.encryptQueryBulk([ + const bulkResult = await client.encryptQueryBulk([ { value: 'alice@example.com', table: catalog, @@ -225,6 +263,7 @@ Deno.test({ queryType: 'searchableJson', }, ]) + const bulk = unwrap(bulkResult, 'bulk') assertEquals(bulk.length, 5) assertV3Term(bulk[0], 'bulk/equality') assertEquals(bulk[1], null, 'bulk: null value must yield null') diff --git a/e2e/wasm/roundtrip.test.ts b/e2e/wasm/roundtrip.test.ts index 354561257..d7b319a37 100644 --- a/e2e/wasm/roundtrip.test.ts +++ b/e2e/wasm/roundtrip.test.ts @@ -102,10 +102,19 @@ Deno.test({ const plaintext = `wasm-v3-smoke-${crypto.randomUUID()}@example.com` - const encrypted = await client.encrypt(plaintext, { + // Every fallible method returns `{ data } | { failure }` (#741). Unwrap + // at each boundary — passing an envelope on would fail in ways that look + // like an encryption bug rather than a plumbing one. + const encryptResult = await client.encrypt(plaintext, { column: users.email, table: users, }) + assertEquals( + encryptResult.failure, + undefined, + `encrypt() failed: ${encryptResult.failure?.message}`, + ) + const encrypted = encryptResult.data assertEquals( isEncrypted(encrypted), @@ -138,17 +147,28 @@ Deno.test({ } assertEquals(String(wire.v), '3', 'wire envelope is not v3') - const decrypted = await client.decrypt(encrypted) - assertEquals(decrypted, plaintext, 'round-trip plaintext mismatch') + const decryptResult = await client.decrypt(encrypted) + assertEquals( + decryptResult.failure, + undefined, + `decrypt() failed: ${decryptResult.failure?.message}`, + ) + assertEquals(decryptResult.data, 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, { + const termResult = await client.encryptQuery(plaintext, { column: users.email, table: users, queryType: 'freeTextSearch', - })) as Record | null + }) + assertEquals( + termResult.failure, + undefined, + `encryptQuery() failed: ${termResult.failure?.message}`, + ) + const term = termResult.data as Record | null assertExists(term, 'encryptQuery() returned null for live plaintext') assertEquals(term.v, 3, 'query term is not EQL v3') assertEquals( @@ -158,12 +178,48 @@ Deno.test({ ) // Bulk form is position-stable, nulls pass through. - const bulk = await client.encryptQueryBulk([ + const bulkResult = await client.encryptQueryBulk([ { value: plaintext, column: users.email, table: users }, { value: null, column: users.email, table: users }, ]) + assertEquals( + bulkResult.failure, + undefined, + `encryptQueryBulk() failed: ${bulkResult.failure?.message}`, + ) + const bulk = bulkResult.data assertEquals(bulk.length, 2) assertExists(bulk[0]) assertEquals(bulk[1], null) + + // 6. (#741) The value-level bulk ops — the whole reason a list read on the + // edge is one ZeroKMS round trip instead of N. Nothing else in the repo + // exercises these against real ZeroKMS. + const second = `wasm-v3-bulk-${crypto.randomUUID()}@example.com` + const bulkEncrypted = await client.bulkEncrypt([ + { plaintext, column: users.email, table: users }, + { plaintext: null, column: users.email, table: users }, + { plaintext: second, column: users.email, table: users }, + ]) + assertEquals( + bulkEncrypted.failure, + undefined, + `bulkEncrypt() failed: ${bulkEncrypted.failure?.message}`, + ) + const payloads = bulkEncrypted.data + assertEquals(payloads.length, 3, 'bulkEncrypt is not index-aligned') + assertEquals(payloads[1], null, 'null plaintext did not yield null') + assertExists(payloads[0]) + assertExists(payloads[2]) + assertEquals(isEncrypted(payloads[0]), true, 'bulkEncrypt[0] not a payload') + + const bulkDecrypted = await client.bulkDecrypt(payloads) + assertEquals( + bulkDecrypted.failure, + undefined, + `bulkDecrypt() failed: ${bulkDecrypted.failure?.message}`, + ) + // Round-trips at the ORIGINAL indices, with the null hole preserved. + assertEquals(bulkDecrypted.data, [plaintext, null, second]) }, }) diff --git a/examples/supabase-worker/README.md b/examples/supabase-worker/README.md index 99701b722..62c5b99f6 100644 --- a/examples/supabase-worker/README.md +++ b/examples/supabase-worker/README.md @@ -59,7 +59,7 @@ There are none. The `@cipherstash/stack/wasm-inline` subpath embeds the protect- ## What this verifies - Protect's WASM build works inside Supabase Edge Functions. -- The full `@cipherstash/stack/wasm-inline` developer surface (`Encryption`, `encryptedTable`, `encryptedColumn`, …) is usable from an Edge Function with no native dependencies. +- The full `@cipherstash/stack/wasm-inline` developer surface (`Encryption`, `encryptedTable`, the `types.*` EQL v3 domains, …) is usable from an Edge Function with no native dependencies. - A CipherStash service-to-service `AccessKeyStrategy` is the right credential shape for serverless / edge environments. Automated coverage of the same code path lives at `e2e/wasm/roundtrip.test.ts` and runs in CI on every PR — this example is the runnable runbook version. diff --git a/examples/supabase-worker/supabase/functions/cipherstash-roundtrip/index.ts b/examples/supabase-worker/supabase/functions/cipherstash-roundtrip/index.ts index 603e0939a..ada5f2d07 100644 --- a/examples/supabase-worker/supabase/functions/cipherstash-roundtrip/index.ts +++ b/examples/supabase-worker/supabase/functions/cipherstash-roundtrip/index.ts @@ -15,13 +15,17 @@ import { Encryption, - encryptedColumn, encryptedTable, isEncrypted, -} from 'npm:@cipherstash/stack@^0.18.0/wasm-inline' + types, +} from 'npm:@cipherstash/stack@^1.0.0-rc.3/wasm-inline' +// EQL v3: the WASM entry exports the v3 authoring surface only (`types.*`), +// not the v2 chainable builders — `encryptedColumn('email').equality()` is +// rejected by the factory outright. `TextEq` is the v3 equality-capable text +// domain, i.e. the direct replacement. const users = encryptedTable('users', { - email: encryptedColumn('email').equality(), + email: types.TextEq('email'), }) Deno.serve(async (_req: Request) => { @@ -61,11 +65,28 @@ Deno.serve(async (_req: Request) => { }) const plaintext = 'alice@example.com' - const encrypted = await client.encrypt(plaintext, { + // Every fallible method returns `{ data } | { failure }` — the same + // contract as the native entry (see AGENTS.md). + const encryptResult = await client.encrypt(plaintext, { column: users.email, table: users, }) - const decrypted = await client.decrypt(encrypted) + if (encryptResult.failure) { + return Response.json( + { ok: false, error: encryptResult.failure.message }, + { status: 500 }, + ) + } + const encrypted = encryptResult.data + + const decryptResult = await client.decrypt(encrypted) + if (decryptResult.failure) { + return Response.json( + { ok: false, error: decryptResult.failure.message }, + { status: 500 }, + ) + } + const decrypted = decryptResult.data return Response.json( { diff --git a/packages/stack/__tests__/helpers/expect-result.ts b/packages/stack/__tests__/helpers/expect-result.ts new file mode 100644 index 000000000..f654bc8eb --- /dev/null +++ b/packages/stack/__tests__/helpers/expect-result.ts @@ -0,0 +1,25 @@ +import type { WasmResult } from '../../src/wasm-inline' + +/** + * Narrow a `WasmResult` to its success arm in tests. + * + * `WasmResult` is a `{ data } | { failure }` union, so `result.data` does + * not type-check until it is narrowed. Every assertion on a successful payload + * would otherwise need its own `if (r.failure) throw` preamble. + * + * Typed against `WasmResult` specifically, NOT `@byteslice/result`'s `Result`: + * inference across the two unions collapses `S` to `S | undefined`, so every + * downstream property access reads as possibly-undefined. + * + * Throws (rather than using `expect`) so it stays usable outside an assertion + * context and reports the failure's own message, which is far more useful than + * a bare "expected data to be defined". + */ +export function expectData(result: WasmResult): S { + if (result.failure) { + throw new Error( + `expected { data } but got { failure } (${result.failure.type}): ${result.failure.message}`, + ) + } + return result.data as S +} 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 54896cec2..8544dc43e 100644 --- a/packages/stack/__tests__/helpers/stub-protect-ffi-wasm-inline.ts +++ b/packages/stack/__tests__/helpers/stub-protect-ffi-wasm-inline.ts @@ -14,12 +14,24 @@ export const decrypt = (): never => { ) } +export const decryptBulkFallible = (): never => { + throw new Error( + '[test stub]: protect-ffi/wasm-inline decryptBulkFallible not implemented', + ) +} + export const encrypt = (): never => { throw new Error( '[test stub]: protect-ffi/wasm-inline encrypt not implemented', ) } +export const encryptBulk = (): never => { + throw new Error( + '[test stub]: protect-ffi/wasm-inline encryptBulk not implemented', + ) +} + export const encryptQuery = (): never => { throw new Error( '[test stub]: protect-ffi/wasm-inline encryptQuery not implemented', diff --git a/packages/stack/__tests__/wasm-inline-bulk.test.ts b/packages/stack/__tests__/wasm-inline-bulk.test.ts new file mode 100644 index 000000000..1f2ef6377 --- /dev/null +++ b/packages/stack/__tests__/wasm-inline-bulk.test.ts @@ -0,0 +1,358 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// #737: the WASM entry exposed no bulk operations, so an N-row list on the +// edge cost N ZeroKMS round trips. These tests pin the new surface: the FFI +// payload shape (`{ plaintext, table, column }` / `{ ciphertext }` — the +// protect-ffi `EncryptPayload` has no `id`, unlike the native entry's), that +// exactly ONE FFI call is made per batch (the whole point), position +// stability across nulls, and the per-index failure reporting that +// `decryptBulkFallible` makes possible. protect-ffi 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: {} })), + encryptQueryBulk: vi.fn( + async (_client: unknown, { queries }: { queries: unknown[] }) => + queries.map((_, n) => ({ v: 3, n })), + ), + encryptBulk: vi.fn( + async (_client: unknown, { plaintexts }: { plaintexts: unknown[] }) => + plaintexts.map((_, n) => ({ v: 3, i: {}, c: `ct-${n}` })), + ), + decryptBulkFallible: vi.fn( + async (_client: unknown, { ciphertexts }: { ciphertexts: unknown[] }) => + ciphertexts.map((_, n) => ({ data: `plain-${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' +import { expectData } from './helpers/expect-result' + +const users = encryptedTable('users', { + email: types.TextEq('email'), + bio: types.TextSearch('bio'), +}) + +// A column whose declared DB name differs from the property, so the payload +// assertion proves `getColumnName` is applied rather than the property key. +const accounts = encryptedTable('accounts', { + emailAddress: types.TextEq('email_address'), +}) + +async function client() { + return Encryption({ + schemas: [users, accounts], + config: { + workspaceCrn: 'crn:test:ws', + accessKey: 'test-key', + clientId: 'id', + clientKey: 'key', + }, + }) +} + +const ct = (c: string) => ({ v: 3, i: { t: 'users', c: 'email' }, c }) as never + +describe('WasmEncryptionClient.bulkEncrypt', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('sends one FFI call for the whole batch', async () => { + const c = await client() + await c.bulkEncrypt([ + { plaintext: 'a@b.com', table: users, column: users.email }, + { plaintext: 'hello', table: users, column: users.bio }, + { plaintext: 'c@d.com', table: users, column: users.email }, + ]) + + // The entire reason this method exists: 3 values, 1 round trip. + expect(ffi.encryptBulk).toHaveBeenCalledTimes(1) + expect(ffi.encrypt).not.toHaveBeenCalled() + }) + + it('builds the FFI payload as { plaintext, table, column } with no id', async () => { + const c = await client() + await c.bulkEncrypt([ + { plaintext: 'a@b.com', table: users, column: users.email }, + { plaintext: 'hello', table: users, column: users.bio }, + ]) + + const [, opts] = ffi.encryptBulk.mock.calls[0] + expect(opts.plaintexts).toEqual([ + { plaintext: 'a@b.com', table: 'users', column: 'email' }, + { plaintext: 'hello', table: 'users', column: 'bio' }, + ]) + // protect-ffi's EncryptPayload has no `id` — the native entry's is + // dropped at the boundary, so sending one would be dead weight. + for (const p of opts.plaintexts as Array>) { + expect(p).not.toHaveProperty('id') + } + }) + + it('mixes tables and columns in a single batch', async () => { + const c = await client() + await c.bulkEncrypt([ + { plaintext: 'a@b.com', table: users, column: users.email }, + { + plaintext: 'x@y.com', + table: accounts, + column: accounts.emailAddress, + }, + ]) + + const [, opts] = ffi.encryptBulk.mock.calls[0] + expect(opts.plaintexts).toEqual([ + { plaintext: 'a@b.com', table: 'users', column: 'email' }, + // The DECLARED column name, not the property key. + { plaintext: 'x@y.com', table: 'accounts', column: 'email_address' }, + ]) + }) + + it('is position-stable across null and undefined plaintexts', async () => { + const c = await client() + const out = await c.bulkEncrypt([ + { plaintext: null, table: users, column: users.email }, + { plaintext: 'a@b.com', table: users, column: users.email }, + { plaintext: undefined, table: users, column: users.email }, + { plaintext: 'c@d.com', table: users, column: users.email }, + ]) + + const values = expectData(out) + expect(values).toHaveLength(4) + expect(values[0]).toBeNull() + expect(values[2]).toBeNull() + // Live values keep their ORIGINAL indices, not their compacted ones. + expect(values[1]).toEqual({ v: 3, i: {}, c: 'ct-0' }) + expect(values[3]).toEqual({ v: 3, i: {}, c: 'ct-1' }) + + // Nulls never reach ZeroKMS. + const [, opts] = ffi.encryptBulk.mock.calls[0] + expect(opts.plaintexts).toHaveLength(2) + }) + + it('short-circuits an all-null batch without calling the FFI', async () => { + const c = await client() + const out = await c.bulkEncrypt([ + { plaintext: null, table: users, column: users.email }, + { plaintext: undefined, table: users, column: users.email }, + ]) + + expect(out).toEqual({ data: [null, null] }) + expect(ffi.encryptBulk).not.toHaveBeenCalled() + }) + + it('returns an empty array for an empty batch, with no FFI call', async () => { + const c = await client() + expect(await c.bulkEncrypt([])).toEqual({ data: [] }) + expect(ffi.encryptBulk).not.toHaveBeenCalled() + }) +}) + +describe('WasmEncryptionClient.bulkDecrypt', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('sends one FFI call for the whole batch', async () => { + const c = await client() + await c.bulkDecrypt([ct('a'), ct('b'), ct('c')]) + + expect(ffi.decryptBulkFallible).toHaveBeenCalledTimes(1) + expect(ffi.decrypt).not.toHaveBeenCalled() + }) + + it('builds the FFI payload as { ciphertext }', async () => { + const c = await client() + const a = ct('a') + const b = ct('b') + await c.bulkDecrypt([a, b]) + + const [, opts] = ffi.decryptBulkFallible.mock.calls[0] + expect(opts.ciphertexts).toEqual([{ ciphertext: a }, { ciphertext: b }]) + }) + + it('is position-stable across null and undefined ciphertexts', async () => { + const c = await client() + const out = await c.bulkDecrypt([null, ct('a'), undefined, ct('b')]) + + expect(out).toEqual({ data: [null, 'plain-0', null, 'plain-1'] }) + const [, opts] = ffi.decryptBulkFallible.mock.calls[0] + expect(opts.ciphertexts).toHaveLength(2) + }) + + it('short-circuits an all-null batch without calling the FFI', async () => { + const c = await client() + expect(await c.bulkDecrypt([null, undefined])).toEqual({ + data: [null, null], + }) + expect(ffi.decryptBulkFallible).not.toHaveBeenCalled() + }) + + it('reports EVERY failing index, not just the first', async () => { + ffi.decryptBulkFallible.mockResolvedValueOnce([ + { data: 'ok' }, + { error: 'boom-one' }, + { error: 'boom-two' }, + ] as never) + + const c = await client() + // Indices are into the INPUT array, so the leading null shifts them: + // input 1/2/3 map to live 0/1/2, and the two failures are inputs 2 and 3. + await expect( + c.bulkDecrypt([null, ct('a'), ct('b'), ct('c')]), + ).resolves.toMatchObject({ + failure: { + type: 'DecryptionError', + message: expect.stringMatching(/failed for 2 of 3 payload\(s\)/), + }, + }) + + ffi.decryptBulkFallible.mockResolvedValueOnce([ + { data: 'ok' }, + { error: 'boom-one' }, + { error: 'boom-two' }, + ] as never) + const res = await c.bulkDecrypt([null, ct('a'), ct('b'), ct('c')]) + + expect(res.failure?.message).toContain('[2]: boom-one') + expect(res.failure?.message).toContain('[3]: boom-two') + }) + + it('succeeds when every item decrypts', async () => { + const c = await client() + await expect(c.bulkDecrypt([ct('a'), ct('b')])).resolves.toEqual({ + data: ['plain-0', 'plain-1'], + }) + }) +}) + +// Results are matched to inputs BY POSITION — the FFI payloads carry no +// correlation id. A short response would otherwise leave trailing slots null, +// which a caller cannot tell apart from "this row had no value". +// The live-filters test `!== null && !== undefined` deliberately, NOT +// truthiness. If any regressed to a truthy check, `0` / `''` / `false` would +// be treated as absent: real values silently persisted as NULL, with the whole +// suite still green. This is the highest-consequence regression the batch code +// can have, so it is pinned per method. +describe('falsy-but-live values still reach ZeroKMS', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('bulkEncrypt sends 0, empty string and false', async () => { + const c = await client() + const out = await c.bulkEncrypt([ + { plaintext: 0, table: users, column: users.email }, + { plaintext: '', table: users, column: users.email }, + { plaintext: false, table: users, column: users.email }, + ]) + + const [, opts] = ffi.encryptBulk.mock.calls[0] + const sent = opts.plaintexts as Array<{ plaintext: unknown }> + expect(sent.map((p) => p.plaintext)).toEqual([0, '', false]) + // …and each comes back at its own index, not as a null hole. + expect(expectData(out)).toEqual([ + { v: 3, i: {}, c: 'ct-0' }, + { v: 3, i: {}, c: 'ct-1' }, + { v: 3, i: {}, c: 'ct-2' }, + ]) + }) + + it('encryptQueryBulk sends 0, empty string and false', async () => { + const c = await client() + await c.encryptQueryBulk([ + { value: 0, table: users, column: users.email }, + { value: '', table: users, column: users.email }, + { value: false, table: users, column: users.email }, + ]) + expect(ffi.encryptQueryBulk).toHaveBeenCalledTimes(1) + const [, opts] = ffi.encryptQueryBulk.mock.calls[0] + expect(opts.queries).toHaveLength(3) + }) +}) + +// A sparse input (e.g. a partially-filled `new Array(n)`) must still yield a +// dense, index-aligned result. `Array.prototype.map` SKIPS holes, so building +// the output with `map` left `undefined` holes rather than the documented +// `null` — while lengths still matched, so the batch-length guard could not +// see it. +it('bulkDecrypt returns null (not a hole) for a sparse input slot', async () => { + const c = await client() + const sparse: Array | undefined> = new Array(3) + sparse[0] = ct('a') + sparse[2] = ct('b') + + const out = expectData(await c.bulkDecrypt(sparse)) + expect(out).toHaveLength(3) + expect(out[1]).toBeNull() + expect(1 in out, 'index 1 must be a real null, not a hole').toBe(true) +}) + +// `encryptQueryBulk` gained the same length guard as the other two, but the +// mismatch describe below only covered bulkEncrypt/bulkDecrypt — dropping the +// guard here would have passed the whole suite. +it('encryptQueryBulk fails on a short FFI response', async () => { + ffi.encryptQueryBulk.mockResolvedValueOnce([{ v: 3, n: 0 }]) + + const c = await client() + await expect( + c.encryptQueryBulk([ + { value: 'a', table: users, column: users.email }, + { value: 'b', table: users, column: users.email }, + ]), + ).resolves.toMatchObject({ + failure: { + message: expect.stringMatching(/sent 2 payload\(s\).*received 1 back/s), + }, + }) +}) + +describe('bulk result/input length mismatch', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('bulkEncrypt fails rather than returning a partially-null batch', async () => { + ffi.encryptBulk.mockResolvedValueOnce([{ v: 3, i: {}, c: 'only-one' }]) + + const c = await client() + await expect( + c.bulkEncrypt([ + { plaintext: 'a', table: users, column: users.email }, + { plaintext: 'b', table: users, column: users.email }, + ]), + ).resolves.toMatchObject({ + failure: { + message: expect.stringMatching(/sent 2 payload\(s\).*received 1 back/s), + }, + }) + }) + + it('bulkDecrypt fails rather than returning a partially-null batch', async () => { + ffi.decryptBulkFallible.mockResolvedValueOnce([ + { data: 'only-one' }, + ] as never) + + const c = await client() + await expect(c.bulkDecrypt([ct('a'), ct('b')])).resolves.toMatchObject({ + failure: { + message: expect.stringMatching(/sent 2 payload\(s\).*received 1 back/s), + }, + }) + }) +}) diff --git a/packages/stack/__tests__/wasm-inline-bundle-isolation.test.ts b/packages/stack/__tests__/wasm-inline-bundle-isolation.test.ts new file mode 100644 index 000000000..2e8a65e27 --- /dev/null +++ b/packages/stack/__tests__/wasm-inline-bundle-isolation.test.ts @@ -0,0 +1,117 @@ +import { execFileSync } from 'node:child_process' +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' + +// `@cipherstash/stack/wasm-inline` exists for exactly one reason: runtimes +// where the native `@cipherstash/protect-ffi` NAPI binding is unavailable +// (Deno, Bun, Cloudflare Workers, Supabase Edge). A single value-import that +// transitively reaches the NATIVE package puts a bare +// `@cipherstash/protect-ffi` specifier into the bundle and breaks it there: +// +// - Workers / Edge resolve the non-`node` condition to `dist/wasm/…`, which +// exports no `ProtectError` → missing-named-export at build/link time, and +// top-level-imports a raw `.wasm` asset (the loading mode this entry avoids). +// - Deno's `node` condition resolves it to the NAPI loader — the native +// dependency this entry avoids. `e2e/wasm/deno.json` maps only the +// `/wasm-inline` subpaths, so it is unresolvable there at all. +// +// This is not hypothetical: importing `@/encryption/helpers/error-code` (a +// value-import of the native `ProtectError` class, for an `instanceof` narrow) +// did exactly this during #741 and was caught only in review. Unit tests can't +// see it — they mock the `/wasm-inline` subpath and run under Node, where the +// native root resolves fine — so the assertion has to be on the built artifact. +// +// Mirrors `packages/prisma-next/test/bundling-isolation.test.ts`. + +const packageRoot = path.resolve(fileURLToPath(import.meta.url), '../..') +const distDir = path.join(packageRoot, 'dist') +const srcDir = path.join(packageRoot, 'src') + +function newestMtime(dir: string): number { + let newest = 0 + const stack = [dir] + while (stack.length > 0) { + const current = stack.pop() + if (!current) continue + for (const entry of readdirSync(current, { withFileTypes: true })) { + const full = path.join(current, entry.name) + if (entry.isDirectory()) stack.push(full) + else newest = Math.max(newest, statSync(full).mtimeMs) + } + } + return newest +} + +function distIsFresh(): boolean { + if (!existsSync(distDir)) return false + const tsupConfigMtime = statSync( + path.join(packageRoot, 'tsup.config.ts'), + ).mtimeMs + const distMtime = newestMtime(distDir) + return distMtime >= newestMtime(srcDir) && distMtime >= tsupConfigMtime +} + +// Same freshness gate as `cjs-require.test.ts`: assert against the CURRENT +// source, never a stale artifact that would pass for the wrong reason. +if (!distIsFresh()) { + execFileSync('pnpm', ['run', 'build'], { + cwd: packageRoot, + stdio: 'inherit', + }) +} + +const bundle = readFileSync(path.join(distDir, 'wasm-inline.js'), 'utf-8') + +/** Every module specifier the built bundle imports from. */ +function importedSpecifiers(source: string): string[] { + const found = new Set() + for (const m of source.matchAll(/\bfrom\s*["']([^"']+)["']/g)) { + if (m[1]) found.add(m[1]) + } + for (const m of source.matchAll(/\bimport\s*\(\s*["']([^"']+)["']\s*\)/g)) { + if (m[1]) found.add(m[1]) + } + return [...found] +} + +describe('dist/wasm-inline.js stays free of the native FFI', () => { + it('imports the /wasm-inline subpath, never the native protect-ffi root', () => { + const specifiers = importedSpecifiers(bundle) + const protectFfi = specifiers.filter((s) => + s.startsWith('@cipherstash/protect-ffi'), + ) + + expect( + protectFfi, + 'the WASM entry must reach protect-ffi only through /wasm-inline', + ).toEqual(['@cipherstash/protect-ffi/wasm-inline']) + }) + + it('reaches @cipherstash/auth only through its /wasm-inline subpath too', () => { + // Same failure mode, same reasoning — the auth package also ships a native + // entry, and the WASM one is what this bundle is built against. + const auth = importedSpecifiers(bundle).filter((s) => + s.startsWith('@cipherstash/auth'), + ) + for (const specifier of auth) { + expect(specifier, 'auth import must be the wasm-inline subpath').toMatch( + /\/wasm-inline$/, + ) + } + }) + + it('names every external it does import, so additions are a conscious change', () => { + // A snapshot-style allowlist: anything new here is either intentional (add + // it) or the kind of transitive native leak this file exists to catch. + const externals = importedSpecifiers(bundle) + .filter((s) => !s.startsWith('.') && !s.startsWith('/')) + .sort() + + expect(externals).toEqual([ + '@cipherstash/auth/wasm-inline', + '@cipherstash/protect-ffi/wasm-inline', + ]) + }) +}) diff --git a/packages/stack/__tests__/wasm-inline-query.test.ts b/packages/stack/__tests__/wasm-inline-query.test.ts index bde15b972..90e041b47 100644 --- a/packages/stack/__tests__/wasm-inline-query.test.ts +++ b/packages/stack/__tests__/wasm-inline-query.test.ts @@ -30,6 +30,7 @@ vi.mock('@cipherstash/auth/wasm-inline', () => ({ import { encryptedTable, types } from '../src/eql/v3' import { Encryption } from '../src/wasm-inline' +import { expectData } from './helpers/expect-result' const users = encryptedTable('users', { // TextEq → unique index only @@ -131,7 +132,12 @@ describe('WasmEncryptionClient.encryptQuery', () => { column: users.email, queryType: 'freeTextSearch', }), - ).rejects.toThrow(/not configured on column "email"/) + ).resolves.toMatchObject({ + failure: { + type: 'EncryptionError', + message: expect.stringMatching(/not configured on column "email"/), + }, + }) expect(ffi.encryptQuery).not.toHaveBeenCalled() }) @@ -167,7 +173,7 @@ describe('WasmEncryptionClient.encryptQuery', () => { const c = await client() expect( await c.encryptQuery(null, { table: users, column: users.email }), - ).toBeNull() + ).toEqual({ data: null }) expect(ffi.encryptQuery).not.toHaveBeenCalled() }) @@ -182,7 +188,9 @@ describe('WasmEncryptionClient.encryptQuery', () => { column: users.age, queryType: 'orderAndRange', }), - ).rejects.toThrow('[encryption]: Cannot encrypt NaN value') + ).resolves.toMatchObject({ + failure: { message: '[encryption]: Cannot encrypt NaN value' }, + }) expect(ffi.encryptQuery).not.toHaveBeenCalled() }) @@ -194,7 +202,13 @@ describe('WasmEncryptionClient.encryptQuery', () => { column: users.bio, queryType: 'freeTextSearch', }), - ).rejects.toThrow(/Cannot use 'match' index with numeric value/) + ).resolves.toMatchObject({ + failure: { + message: expect.stringMatching( + /Cannot use 'match' index with numeric value/, + ), + }, + }) expect(ffi.encryptQuery).not.toHaveBeenCalled() }) @@ -209,7 +223,9 @@ describe('WasmEncryptionClient.encryptQuery', () => { queryType: 'orderAndRange', }, ]), - ).rejects.toThrow('[encryption]: Cannot encrypt Infinity value') + ).resolves.toMatchObject({ + failure: { message: '[encryption]: Cannot encrypt Infinity value' }, + }) expect(ffi.encryptQueryBulk).not.toHaveBeenCalled() }) }) @@ -235,10 +251,11 @@ describe('WasmEncryptionClient.encryptQueryBulk', () => { }, ]) - expect(out).toHaveLength(3) - expect(out[0]).toEqual({ v: 3, n: 0 }) - expect(out[1]).toBeNull() - expect(out[2]).toEqual({ v: 3, n: 1 }) + const terms = expectData(out) + expect(terms).toHaveLength(3) + expect(terms[0]).toEqual({ v: 3, n: 0 }) + expect(terms[1]).toBeNull() + expect(terms[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 }> @@ -251,7 +268,7 @@ describe('WasmEncryptionClient.encryptQueryBulk', () => { const out = await c.encryptQueryBulk([ { value: null, table: users, column: users.email }, ]) - expect(out).toEqual([null]) + expect(out).toEqual({ data: [null] }) expect(ffi.encryptQueryBulk).not.toHaveBeenCalled() }) }) diff --git a/packages/stack/__tests__/wasm-inline-result-contract.test.ts b/packages/stack/__tests__/wasm-inline-result-contract.test.ts new file mode 100644 index 000000000..34d24b594 --- /dev/null +++ b/packages/stack/__tests__/wasm-inline-result-contract.test.ts @@ -0,0 +1,170 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +// The WASM entry THREW on every fallible method until #741, diverging from the +// repo-wide contract in AGENTS.md ("Operations return `{ data }` or +// `{ failure }`. Preserve this shape and error `type` values in +// `EncryptionErrorTypes`."). These tests pin the aligned surface so it cannot +// drift back: every fallible method returns a Result on BOTH paths, with a +// `failure.type` drawn from `EncryptionErrorTypes` and encrypt/decrypt +// classified distinctly. `isEncrypted` stays a bare boolean — a pure predicate +// with nothing to fail at, as on the native entry. + +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: {} })), + encryptQueryBulk: vi.fn(async () => [{ v: 3, i: {} }]), + encryptBulk: vi.fn(async () => [{ v: 3, i: {}, c: 'ct' }]), + decryptBulkFallible: vi.fn(async () => [{ data: 'plain' }]), +})) +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', { email: types.TextEq('email') }) + +async function client() { + return Encryption({ + schemas: [users], + config: { + workspaceCrn: 'crn:test:ws', + accessKey: 'test-key', + clientId: 'id', + clientKey: 'key', + }, + }) +} + +const ct = () => ({ v: 3, i: { t: 'users', c: 'email' }, c: 'x' }) as never + +describe('wasm-inline Result contract — success path', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('every fallible method resolves { data } and never a bare value', async () => { + const c = await client() + const opts = { table: users, column: users.email } + + const results = [ + await c.encrypt('a@b.com', opts), + await c.decrypt(ct()), + await c.encryptQuery('a@b.com', opts), + await c.encryptQueryBulk([{ value: 'a@b.com', ...opts }]), + await c.bulkEncrypt([{ plaintext: 'a@b.com', ...opts }]), + await c.bulkDecrypt([ct()]), + ] + + for (const r of results) { + expect(r).toHaveProperty('data') + expect(r.failure).toBeUndefined() + } + }) + + it('isEncrypted stays a bare boolean — nothing to fail at', async () => { + const c = await client() + expect(c.isEncrypted({})).toBe(true) + }) +}) + +describe('wasm-inline Result contract — failure path', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + // Encrypt-side and decrypt-side failures must be distinguishable by `type`, + // which is the whole point of carrying EncryptionErrorTypes rather than a + // bare message. + it.each([ + ['encrypt', 'encrypt', 'EncryptionError'], + ['encryptQuery', 'encryptQuery', 'EncryptionError'], + ['bulkEncrypt', 'encryptBulk', 'EncryptionError'], + ['decrypt', 'decrypt', 'DecryptionError'], + ['bulkDecrypt', 'decryptBulkFallible', 'DecryptionError'], + ])('%s surfaces a %s FFI throw as { failure } typed %s', async (method, ffiName, expectedType) => { + ;(ffi as Record>)[ + ffiName + ].mockRejectedValueOnce(new Error('ffi exploded')) + + const c = await client() + const opts = { table: users, column: users.email } + const call: Record Promise> = { + encrypt: () => c.encrypt('a@b.com', opts), + encryptQuery: () => c.encryptQuery('a@b.com', opts), + bulkEncrypt: () => c.bulkEncrypt([{ plaintext: 'a@b.com', ...opts }]), + decrypt: () => c.decrypt(ct()), + bulkDecrypt: () => c.bulkDecrypt([ct()]), + } + + // Resolves — it must NOT reject. That is the regression this guards. + const result = (await call[method]()) as { + data?: unknown + failure?: { type: string; message: string } + } + + expect(result.data).toBeUndefined() + expect(result.failure).toMatchObject({ + type: expectedType, + message: 'ffi exploded', + }) + }) + + // Non-Error rejections must keep their detail. `withResult`'s default + // `ensureError` would replace them with `new Error('Something went wrong')`, + // discarding the value (@byteslice/result@0.2.0, `dist/result.js:27`) — so + // this entry passes the `onException` hook, which takes precedence. + // + // This is not hypothetical on WASM: wasm-bindgen rejects with the raw + // `JsValue` from Rust (`throw takeFromExternrefTable0(...)`), and the WASM + // build exports no `ProtectError` class, so a real FFI failure can arrive as + // a bare string or object. Losing it would be worse than the throwing + // behaviour this entry had before, which propagated the raw value. + it.each([ + ['a string', 'boom from rust', 'boom from rust'], + [ + 'an object', + { code: 'EQL_X', detail: 'bad domain' }, + '{"code":"EQL_X","detail":"bad domain"}', + ], + ])('preserves the detail of %s rejection', async (_label, thrown, expected) => { + ffi.encrypt.mockRejectedValueOnce(thrown) + + const c = await client() + const result = await c.encrypt('a@b.com', { + table: users, + column: users.email, + }) + + expect(result.failure?.type).toBe('EncryptionError') + expect(result.failure?.message).toBe(expected) + expect(result.failure?.message).not.toBe('Something went wrong') + }) + + it('falls back to String() for a value JSON cannot serialize', async () => { + // A cycle makes JSON.stringify throw; the catch must still yield a + // well-formed failure rather than propagating a second error. + const cyclic: Record = { a: 1 } + cyclic.self = cyclic + ffi.encrypt.mockRejectedValueOnce(cyclic) + + const c = await client() + const result = await c.encrypt('a@b.com', { + table: users, + column: users.email, + }) + + expect(result.failure?.type).toBe('EncryptionError') + expect(result.failure?.message).toBe('[object Object]') + }) +}) diff --git a/packages/stack/integration/wasm/adapter.ts b/packages/stack/integration/wasm/adapter.ts index 7caaef6b3..845fbec80 100644 --- a/packages/stack/integration/wasm/adapter.ts +++ b/packages/stack/integration/wasm/adapter.ts @@ -34,6 +34,7 @@ import { Encryption as WasmEncryption, type WasmEncryptionClient, type WasmPlaintext, + type WasmResult, } from '@/wasm-inline' const SUPPORTED_OPS: ReadonlySet = new Set([ @@ -92,6 +93,21 @@ function toWasmPlaintext(value: Plain): WasmPlaintext { return value instanceof Date ? value.toISOString() : value } +/** + * Unwrap a `Result` from the WASM client, throwing on failure. + * + * The client returns `{ data } | { failure }` on every fallible method (the + * repo-wide contract — see `AGENTS.md`). This harness wants a failure to abort + * the run loudly with the SDK's own message, so it unwraps at the call site + * rather than threading Results through the query builders. + */ +function unwrap(result: WasmResult, op: string): T { + if (result.failure) { + throw new Error(`[wasm adapter]: ${op} failed — ${result.failure.message}`) + } + return result.data as T +} + /** `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_/, '') @@ -139,11 +155,14 @@ export function makeWasmAdapter(): IntegrationAdapter { 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], - }) + const encrypted = unwrap( + await client.encryptQuery(toWasmPlaintext(value), { + table: tableSchema, + column, + queryType: QUERY_TYPE_BY_KIND[kind], + }), + 'encryptQuery', + ) return { param: encrypted, cast: queryDomain(eqlType) } } @@ -225,18 +244,36 @@ export function makeWasmAdapter(): IntegrationAdapter { 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), { + + // ONE ZeroKMS round trip for the whole row, not one per field. This was + // `Promise.all` over per-field `client.encrypt` — concurrency hides + // latency but not request count, so a 100-row × 5-field family was 500 + // requests. `bulkEncrypt`'s per-item `{ plaintext, table, column }` + // routing exists precisely for this shape (#737). + // + // It also makes this harness the live coverage for the bulk path: the + // unit tests mock the FFI, so without this nothing in the repo exercises + // `bulkEncrypt` against real ZeroKMS. + const entries = Object.entries(row.values) + const encrypted = unwrap( + await client.bulkEncrypt( + entries.map(([slug, value]) => ({ + plaintext: toWasmPlaintext(value), table: tableSchema, column: col(slug).column, - }) - assertWireEnvelope(slug, encrypted) - assignments[slug] = encrypted - }), + })), + ), + 'bulkEncrypt', ) + + entries.forEach(([slug], i) => { + const payload = encrypted[i] + // A null here means the fixture value was null/undefined — the column + // is genuinely absent, not an encryption failure. + if (payload === null || payload === undefined) return + assertWireEnvelope(slug, payload) + assignments[slug] = payload + }) return assignments } @@ -266,13 +303,16 @@ export function makeWasmAdapter(): IntegrationAdapter { // 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 encrypted = unwrap( + await client.encryptQueryBulk( + op.values.map((value) => ({ + value: toWasmPlaintext(value), + table: tableSchema, + column, + queryType: 'equality' as const, + })), + ), + 'encryptQueryBulk', ) const cast = queryDomain(eqlType) const fn = op.kind === 'in' ? 'eq' : 'neq' diff --git a/packages/stack/src/wasm-inline.ts b/packages/stack/src/wasm-inline.ts index 8f545ccf4..a617a4b81 100644 --- a/packages/stack/src/wasm-inline.ts +++ b/packages/stack/src/wasm-inline.ts @@ -31,11 +31,16 @@ * }, * }) * + * // Every fallible method returns `{ data } | { failure }` — the same + * // contract as the native entry. Unwrap before use. * const enc = await client.encrypt("alice@example.com", { * column: users.email, * table: users, * }) - * const dec = await client.decrypt(enc) + * if (enc.failure) throw new Error(enc.failure.message) + * + * const dec = await client.decrypt(enc.data) + * if (dec.failure) throw new Error(dec.failure.message) * * // 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. @@ -44,9 +49,10 @@ * table: users, * queryType: "freeTextSearch", * }) - * // e.g. postgres-js: + * if (term.failure) throw new Error(term.failure.message) + * // e.g. postgres-js — bind `term.data`, NOT the envelope: * // sql`SELECT * FROM users - * // WHERE eql_v3.matches(email, ${term}::jsonb::eql_v3.query_text_search)` + * // WHERE eql_v3.matches(email, ${term.data}::jsonb::eql_v3.query_text_search)` * ``` * * For per-user, identity-bound encryption on the edge, build an @@ -71,13 +77,16 @@ * re-exported from this entry. */ +import { withResult } from '@byteslice/result' import { AccessKeyStrategy, type OidcFederationStrategy, } from '@cipherstash/auth/wasm-inline' import { decrypt as wasmDecrypt, + decryptBulkFallible as wasmDecryptBulkFallible, encrypt as wasmEncrypt, + encryptBulk as wasmEncryptBulk, encryptQuery as wasmEncryptQuery, encryptQueryBulk as wasmEncryptQueryBulk, isEncrypted as wasmIsEncrypted, @@ -89,6 +98,7 @@ import { assertValueIndexCompatibility, } from '@/encryption/helpers/validation' import { type AnyV3Table, buildEncryptConfig } from '@/eql/v3' +import { type EncryptionError, EncryptionErrorTypes } from '@/errors' import { type CastAs, type EncryptConfig, @@ -124,6 +134,13 @@ export { // path was never announced or documented for v2, and the edge targets v3. EQL v2 // remains fully available on the native `@cipherstash/stack` entry. export * from '@/eql/v3' +// The failure vocabulary every method on this entry now returns. Exported here +// so an edge consumer can discriminate `result.failure.type` from the SAME +// single import they got the client from — without reaching for the +// Node-oriented `@cipherstash/stack/errors` subpath (undocumented on edge) or +// depending on `@byteslice/result` — which is bundled INTO this file, so it is +// not a package an edge consumer can import at all. +export { type EncryptionError, EncryptionErrorTypes } from '@/errors' export type { Encrypted } from '@/types' /** Re-exported convenience predicate — same as the raw protect-ffi one. */ @@ -298,6 +315,244 @@ export type WasmQueryTerm = WasmEncryptQueryOptions & { value: WasmPlaintext } +/** + * One storage value in a {@link WasmEncryptionClient.bulkEncrypt} batch. + * + * Each entry carries its OWN table and column, rather than the batch taking a + * single `EncryptOptions` the way {@link WasmEncryptionClient.encrypt} does. + * That mirrors {@link WasmQueryTerm} — and it is what makes the round-trip + * saving worth having: rendering a page of rows means encrypting several + * columns across many rows, and a single-column batch would still cost one + * ZeroKMS call per column. The FFI's `EncryptPayload` is per-item + * (`{ plaintext, table, column }`), so mixing is free at the boundary. + * + * (The native entry's `bulkEncrypt` takes one column for the whole batch and + * wraps values in `{ id, plaintext }` envelopes. This surface does neither — + * see {@link WasmEncryptionClient.bulkEncrypt} for why.) + */ +export type WasmBulkPlaintext = { + /** + * The value to encrypt. `null`/`undefined` yields `null` at this index + * without reaching ZeroKMS. + * + * `undefined` is admitted explicitly — unlike {@link WasmQueryTerm.value}, + * which is `WasmPlaintext` alone. Both are guarded identically at runtime, + * but the shapes fed to them differ: a query needle is written by hand, + * whereas a bulk batch is mapped straight off database rows, where an + * absent column is `undefined`. Typing it out would force `?? null` at + * every call site to satisfy a check the runtime does anyway. + */ + plaintext: WasmPlaintext | undefined + table: EncryptOptions['table'] + column: EncryptOptions['column'] +} + +/** + * Map a thrown error into the repo-wide `{ type, message, code? }` failure + * shape — the second half of `withResult`, shared by every method on this + * client so the failure surface is identical across them. + * + * `AGENTS.md` makes this a contract, not a style choice: *"Operations return + * `{ data }` or `{ failure }`. Preserve this shape and error `type` values in + * `EncryptionErrorTypes`."* This entry did not follow it until #741 — see + * {@link WasmEncryptionClient} for why that mattered and what changed. + */ +/** + * The `{ data } | { failure }` envelope every fallible method here returns. + * + * Structurally identical to `@byteslice/result`'s `Result` + * — which is what `withResult` actually produces — but declared LOCALLY on + * purpose, for the same reason {@link WasmPlaintext} re-declares protect-ffi's + * `JsPlaintext`: `@byteslice/result` is bundled into `dist/wasm-inline.js` + * (tsup `noExternal`), so it is not a package an edge consumer can resolve. + * Re-exporting its type put `import { Result } from '@byteslice/result'` at the + * top of the emitted `.d.ts`, which a Deno consumer cannot resolve at all — the + * e2e import map maps only the three `/wasm-inline` subpaths. + * + * Declaring it here keeps the published types self-contained. + */ +export type WasmResult = + | { data: T; failure?: never } + | { data?: never; failure: EncryptionError } + +/** + * Read an FFI error code STRUCTURALLY. + * + * Deliberately not `@/encryption/helpers/error-code`: that narrows with + * `instanceof` against the native `ProtectError`, which is a runtime VALUE + * import of `@cipherstash/protect-ffi`. protect-ffi is not in tsup's + * `noExternal`, so importing it here put a bare `@cipherstash/protect-ffi` + * specifier into `dist/wasm-inline.js` — the native NAPI entry, in the one + * bundle that exists to avoid it. On Workers / Edge the non-`node` condition + * resolves that specifier to a module exporting no `ProtectError` at all. + * + * A structural read is also the only thing that could ever work here: the WASM + * build ships no error class, so `instanceof` never matches on this path + * regardless. A `code` string is all there is to find. + */ +function readErrorCode(error: unknown): EncryptionError['code'] { + if (typeof error !== 'object' || error === null) return undefined + const { code } = error as { code?: unknown } + return typeof code === 'string' + ? (code as EncryptionError['code']) + : undefined +} + +function toFailure( + type: EncryptionError['type'], +): (error: unknown) => EncryptionError { + return (error: unknown) => ({ + type, + message: error instanceof Error ? error.message : String(error), + code: readErrorCode(error), + }) +} + +/** + * Coerce a rejection into an `Error` WITHOUT losing what it said. + * + * `withResult`'s built-in `ensureError` replaces any non-`Error` throw with + * `new Error('Something went wrong')`, discarding the original value entirely + * (@byteslice/result@0.2.0, `dist/result.js:27`). It is only the fallback, + * though — `withResult` prefers the `onException` hook: + * + * ```js + * const error = hooks?.onException?.(ex) ?? ensureError(ex) + * ``` + * + * Supplying it matters more here than on the native entry. wasm-bindgen + * rejects with the raw `JsValue` the Rust side produced (`throw + * takeFromExternrefTable0(...)` in the generated glue), and the WASM build + * exports no `ProtectError` class, so a genuine FFI failure can arrive as a + * plain string or object rather than an `Error`. Without this hook its message + * would be replaced by boilerplate — strictly worse than the throwing + * behaviour this entry had before, which at least propagated the raw value. + */ +function toError(ex: unknown): Error { + if (ex instanceof Error) return ex + if (typeof ex === 'string') return new Error(ex) + + // Objects are the other shape wasm-bindgen hands back, so serialize rather + // than settle for "[object Object]". `JSON.stringify` returns undefined for + // a symbol/function and throws on a cycle. + let message: string + try { + message = JSON.stringify(ex) ?? safeString(ex) + } catch { + message = safeString(ex) + } + + // Carry a structural `code` onto the synthesized Error so `toFailure` can + // still surface it. Without this the conversion loses it: `withResult` runs + // `onException` FIRST, so the mapper only ever sees this fresh Error, and + // `failure.code` could never be populated on this entry at all. + const error = new Error(message) as Error & { code?: string } + const code = readErrorCode(ex) + if (code) error.code = code + return error +} + +/** + * `String(value)` that cannot itself throw. + * + * A null-prototype object (`Object.create(null)`) has no `toString`, so + * `String(ex)` raises `TypeError: Cannot convert object to primitive value`. + * That matters here because `withResult` invokes `onException` bare inside its + * catch — a throw from this function escapes the Result contract entirely and + * REJECTS the call, so edge code written as `if (result.failure)` would crash + * on an unhandled rejection. `Object.prototype.toString` works on anything. + */ +function safeString(value: unknown): string { + try { + return String(value) + } catch { + return Object.prototype.toString.call(value) + } +} + +/** + * `withResult` bound to this entry's conventions: the repo-wide failure shape + * ({@link toFailure}) plus the {@link toError} hook. + * + * One seam, so a method added later cannot silently omit either. Omitting them + * would not fail a build — it would quietly degrade failure messages, which is + * precisely the bug this helper exists to prevent. + */ +function wasmResult( + operation: () => Promise, + type: EncryptionError['type'], +): Promise> { + return withResult(operation, toFailure(type), { onException: toError }) +} + +/** + * Guard the positional contract the bulk methods rely on. + * + * Both `bulkEncrypt` and `bulkDecrypt` match FFI results to inputs by INDEX — + * the payloads carry no correlation id (protect-ffi's `EncryptPayload` / + * `BulkDecryptPayload` have no `id` field). If the FFI ever returned a + * different count, the surplus input slots would keep their initial `null`, + * which a caller cannot distinguish from "this row genuinely had no value". + * That is a silent-wrong-data failure, so it throws instead. + */ +/** + * The positional-batch bookkeeping every bulk method here shares: compact the + * live entries, remember where each came from, short-circuit an all-dead + * batch, send one FFI call, assert the response length, and hand back each + * result paired with the INPUT index it belongs to. + * + * Hand-rolled in three places before — the very code the length guard exists + * because it is easy to get subtly wrong. A fourth batch method (the model + * helpers, when they land) could have omitted {@link assertBatchLength} + * without failing a build; routing through here makes that impossible. + * + * `out` is built with `Array.from`, NOT `items.map(() => null)`: `map` SKIPS + * holes in a sparse input, so `bulkDecrypt([a, , b])` would leave index 1 an + * `undefined` hole rather than the documented `null`, while lengths still + * matched. (`forEach` skipping holes is correct and wanted — a hole is a dead + * slot.) + */ +async function runBatch( + op: string, + items: readonly In[], + isLive: (item: In) => boolean, + send: (live: In[]) => Promise, +): Promise<{ + out: Array + placed: Array<{ result: Res; at: number }> +}> { + const live: Array<{ item: In; at: number }> = [] + items.forEach((item, at) => { + if (isLive(item)) live.push({ item, at }) + }) + + const out: Array = Array.from( + { length: items.length }, + () => null, + ) + if (live.length === 0) return { out, placed: [] } + + const results = await send(live.map(({ item }) => item)) + assertBatchLength(op, results.length, live.length) + + // Lengths are equal past the assert, so every index pairs. + const placed = results.map((result, i) => ({ + result, + at: (live[i] as { at: number }).at, + })) + return { out, placed } +} + +function assertBatchLength(op: string, received: number, sent: number): void { + if (received !== sent) { + throw new Error( + `[encryption]: ${op} sent ${sent} payload(s) to ZeroKMS but received ${received} back. ` + + 'Results are matched to inputs by position, so a count mismatch would silently return null ' + + 'for the unmatched entries — refusing rather than returning data that looks complete.', + ) + } +} + /** * Internal token used to gate the {@link WasmEncryptionClient} * constructor. Symbols are unique by reference, so external code can't @@ -310,10 +565,35 @@ const INTERNAL_CONSTRUCT = Symbol('cs-wasm-client') * WASM encryption client. Returned by {@link Encryption}. * * 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. + * `isEncrypted`, `encryptQuery` / `encryptQueryBulk` for minting v3 query + * terms (#662, which made searchable encryption reachable on the edge), and + * `bulkEncrypt` / `bulkDecrypt` for single-round-trip list reads and writes + * (#737). + * + * ## Every fallible method returns a Result + * + * `{ data } | { failure }`, with `failure.type` drawn from + * `EncryptionErrorTypes` — the same contract the native entry honours, and + * the one `AGENTS.md` states outright: *"Operations return `{ data }` or + * `{ failure }`. Preserve this shape and error `type` values in + * `EncryptionErrorTypes`."* + * + * This entry THREW until #741. That was drift, not a design decision: nothing + * about WASM prevents it (`@byteslice/result` is already bundled into + * `dist/wasm-inline.js` — see `tsup.config.ts` `noExternal`), and the + * divergence just meant edge code had to be written in a different shape from + * every other surface, with failures that were easy to miss. Aligned before + * 1.0 so it never had to be a breaking change afterwards. + * + * `isEncrypted` is the one exception, and stays a plain `boolean`: it is a + * pure predicate with nothing to fail at, exactly as on the native entry. + * + * Still Node-only: the MODEL helpers (`encryptModel` / `decryptModel` and + * their bulk forms). Those are a separate port — this entry has no + * single-model operation to build a bulk one on top of, so adding + * `bulkEncryptModels` alone would be incoherent. Port lazily as Deno / edge + * consumers demand it; the value-level bulk primitives above are what the + * round-trip cost actually hangs on. * * Construct via {@link Encryption} — the constructor is private to * prevent callers from wrapping arbitrary objects in this type. @@ -340,25 +620,33 @@ export class WasmEncryptionClient { async encrypt( plaintext: WasmPlaintext, opts: EncryptOptions, - ): Promise { - const ffiOpts = { - plaintext, - table: opts.table.tableName, - column: getColumnName(opts.column), - } - return (await wasmEncrypt( - this.client as never, - ffiOpts as never, - )) as Encrypted + ): Promise> { + return wasmResult(async () => { + const ffiOpts = { + plaintext, + table: opts.table.tableName, + column: getColumnName(opts.column), + } + return (await wasmEncrypt( + // 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 opts cross the serde boundary, whose shape protect-ffi types as `any` + ffiOpts as never, + )) as Encrypted + }, EncryptionErrorTypes.EncryptionError) } - async decrypt(encrypted: Encrypted): Promise { - return (await wasmDecrypt( - this.client as never, - { - ciphertext: encrypted, - } as never, - )) as WasmPlaintext + async decrypt(encrypted: Encrypted): Promise> { + return wasmResult( + async () => + (await wasmDecrypt( + // 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 opts cross the serde boundary, whose shape protect-ffi types as `any` + { ciphertext: encrypted } as never, + )) as WasmPlaintext, + EncryptionErrorTypes.DecryptionError, + ) } isEncrypted(value: unknown): boolean { @@ -382,9 +670,10 @@ export class WasmEncryptionClient { * const term = await client.encryptQuery("alice@example.com", { * table: users, column: users.email, queryType: "equality", * }) - * // postgres-js: + * if (term.failure) throw new Error(term.failure.message) + * // postgres-js — bind the unwrapped term, not the Result: * sql`SELECT * FROM users - * WHERE email = ${term}::jsonb::eql_v3.query_text_eq` + * WHERE email = ${term.data}::jsonb::eql_v3.query_text_eq` * ``` * * @example Free-text match (bloom index — one-sided, fuzzy) @@ -392,8 +681,9 @@ export class WasmEncryptionClient { * const term = await client.encryptQuery("needle", { * table: users, column: users.bio, queryType: "freeTextSearch", * }) + * if (term.failure) throw new Error(term.failure.message) * sql`SELECT * FROM users - * WHERE eql_v3.matches(bio, ${term}::jsonb::eql_v3.query_text_search)` + * WHERE eql_v3.matches(bio, ${term.data}::jsonb::eql_v3.query_text_search)` * ``` * * @example Range / ORDER BY (ORE index) @@ -401,8 +691,9 @@ export class WasmEncryptionClient { * const term = await client.encryptQuery(42, { * table: users, column: users.age, queryType: "orderAndRange", * }) + * if (term.failure) throw new Error(term.failure.message) * sql`SELECT * FROM users - * WHERE eql_v3.gte(age, ${term}::jsonb::eql_v3.query_integer_ord)` + * WHERE eql_v3.gte(age, ${term.data}::jsonb::eql_v3.query_integer_ord)` * ``` * * @example Encrypted JSON — containment and JSONPath selector @@ -411,8 +702,9 @@ export class WasmEncryptionClient { * const contains = await client.encryptQuery({ role: "admin" }, { * table: users, column: users.prefs, queryType: "searchableJson", * }) + * if (contains.failure) throw new Error(contains.failure.message) * sql`SELECT * FROM users - * WHERE prefs @> ${contains}::jsonb::eql_v3.query_json` + * WHERE prefs @> ${contains.data}::jsonb::eql_v3.query_json` * * // String value → JSONPath selector. NOTE: v3 has no encrypted-selector * // envelope — this returns the BARE selector-hash string, bound as the @@ -420,7 +712,8 @@ export class WasmEncryptionClient { * const selector = await client.encryptQuery("$.role", { * table: users, column: users.prefs, queryType: "searchableJson", * }) - * sql`SELECT prefs -> ${selector} FROM users` + * if (selector.failure) throw new Error(selector.failure.message) + * sql`SELECT prefs -> ${selector.data} FROM users` * ``` * * @param plaintext - The search needle. `null`/`undefined` returns `null` @@ -428,26 +721,26 @@ export class WasmEncryptionClient { * 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). + * @returns `{ data }` with the v3 query term (or `null` for null + * plaintext), or `{ failure }` 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. */ 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 + ): Promise> { + return wasmResult(async () => { + 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 + }, EncryptionErrorTypes.EncryptionError) } /** @@ -462,45 +755,207 @@ export class WasmEncryptionClient { * * @example * ```ts - * const [emailEq, bioMatch] = await client.encryptQueryBulk([ + * const terms = await client.encryptQueryBulk([ * { value: "alice@example.com", table: users, column: users.email, * queryType: "equality" }, * { value: "needle", table: users, column: users.bio, * queryType: "freeTextSearch" }, * ]) + * if (terms.failure) throw new Error(terms.failure.message) + * const [emailEq, bioMatch] = terms.data * ``` * * @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. + * @returns `{ data }` with an index-aligned array of v3 query terms (`null` + * per null value), or `{ failure }` — the first invalid term aborts the + * batch, as {@link encryptQuery}. */ 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 + ): Promise>> { + return wasmResult(async () => { + const { out, placed } = await runBatch( + 'encryptQueryBulk', + terms, + (term) => term.value !== null && term.value !== undefined, + async (live) => + // The FFI's batch field is `queries` (matching the native + // ffiEncryptQueryBulk call in packages/protect). + (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: live.map((term) => toFfiQueryTerm(term.value, term)), + } as never, + )) as EncryptedQuery[], + ) + for (const { result, at } of placed) out[at] = result + return out + }, EncryptionErrorTypes.EncryptionError) + } + + /** + * Encrypt many storage values in ONE ZeroKMS round trip. + * + * Without this, an edge function writing N rows pays N round trips — the + * property `AGENTS.md` calls out ("prefer bulk operations to exercise + * ZeroKMS bulk speed") was unreachable from the WASM entry entirely (#737). + * + * Position-stable: the result is index-aligned with `items`, and a + * `null`/`undefined` plaintext yields `null` at the same index without + * being sent to ZeroKMS (an all-null batch short-circuits entirely). + * Entries may mix tables and columns freely — see {@link WasmBulkPlaintext}. + * + * ## How this differs from the native `bulkEncrypt` + * + * The failure shape is the SAME — `{ data } | { failure }`, per + * `AGENTS.md` — but the batch shape differs: the Node entry takes one + * column for the whole batch and wraps values in `{ id, plaintext }` + * envelopes, where this takes per-item routing and plain values. + * + * That divergence is about capability, not convention. Per-item routing is + * what makes the round-trip saving real (one call covers several columns + * across many rows), and the `id` bookkeeping buys nothing once positions + * are stable — the FFI's `EncryptPayload` has no `id` field, so the native + * one is dropped at the boundary anyway. + * + * @example Encrypting a page of rows in one call + * ```ts + * const rows = [{ email: "a@b.com", bio: "hi" }, { email: "c@d.com", bio: "yo" }] + * const encrypted = await client.bulkEncrypt( + * rows.flatMap((r) => [ + * { plaintext: r.email, table: users, column: users.email }, + * { plaintext: r.bio, table: users, column: users.bio }, + * ]), + * ) + * if (encrypted.failure) throw new Error(encrypted.failure.message) + * // encrypted.data[0] = email of row 0, [1] = bio of row 0, … + * ``` + * + * @param items - Values to encrypt, each with its own table and column. + * @returns `{ data }` with an index-aligned array of storage payloads + * (`null` per null input), or `{ failure }`. The batch is all-or-nothing: + * ZeroKMS rejects the call as a whole, so there is no per-item error to + * report (unlike {@link bulkDecrypt}, whose FFI primitive IS fallible). + */ + async bulkEncrypt( + items: readonly WasmBulkPlaintext[], + ): Promise>> { + return wasmResult(async () => { + const { out, placed } = await runBatch( + 'bulkEncrypt', + items, + (item) => item.plaintext !== null && item.plaintext !== undefined, + async (live) => + (await wasmEncryptBulk( + // 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` + { + plaintexts: live.map((item) => ({ + plaintext: item.plaintext, + table: item.table.tableName, + column: getColumnName(item.column), + })), + } as never, + )) as Encrypted[], + ) + for (const { result, at } of placed) out[at] = result + return out + }, EncryptionErrorTypes.EncryptionError) + } + + /** + * Decrypt many stored payloads in ONE ZeroKMS round trip. + * + * This is the half that matters most on the edge: rendering a list of N + * encrypted rows cost N round trips before this existed, which is what made + * list endpoints impractical on Deno / Workers (#737). + * + * Position-stable, same contract as {@link bulkEncrypt}: index-aligned with + * `ciphertexts`, `null`/`undefined` passes through as `null` without + * reaching ZeroKMS, and an all-null batch short-circuits. + * + * ## Partial failure + * + * The underlying primitive is `decryptBulkFallible`, which reports success + * or failure PER ITEM — one undecryptable row does not fail the call at the + * FFI level. Any failure still collapses the whole call into a single + * `{ failure }`, but its message names EVERY failed index and reason rather + * than surfacing the first and discarding the rest. So a caller debugging + * one bad row in a page of 50 learns which row, and that the other 49 were + * fine. + * + * (A per-item `Result[]` would preserve the partial success too, and is + * worth considering if callers ask for it — but it is a different return + * type from every other method here, so it is not the default.) + * + * @example + * ```ts + * const rows = await sql`SELECT email FROM users LIMIT 50` + * const emails = await client.bulkDecrypt(rows.map((r) => r.email)) + * if (emails.failure) throw new Error(emails.failure.message) + * // one ZeroKMS call, not 50 + * ``` + * + * @param ciphertexts - Stored payloads; `null`/`undefined` entries allowed. + * @returns `{ data }` with an index-aligned array of plaintexts (`null` per + * null input), or `{ failure }` naming each failing index and its reason. + */ + async bulkDecrypt( + ciphertexts: readonly (Encrypted | null | undefined)[], + ): Promise>> { + return wasmResult(async () => { + type FallibleItem = + | { data: WasmPlaintext } + | { error: string; code?: string } + + const { out, placed } = await runBatch< + Encrypted, + FallibleItem, + WasmPlaintext + >( + 'bulkDecrypt', + ciphertexts as readonly Encrypted[], + (ciphertext) => ciphertext !== null && ciphertext !== undefined, + async (live) => + (await wasmDecryptBulkFallible( + // 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` + { + ciphertexts: live.map((ciphertext) => ({ ciphertext })), + } as never, + )) as FallibleItem[], + ) + + // Collect every failure before raising: the FFI already did the work for + // the rows that succeeded, so reporting only the first would discard + // information the caller paid for. The throw is caught by `withResult` + // and surfaces as a single `{ failure }` naming every bad index. + // + // Each line carries the per-item `code` the FFI supplies. It cannot go + // on `failure.code` — a batch has no single code, and inventing one from + // the first failure would be wrong — but dropping it entirely would lose + // the only machine-meaningful part of a row's error. + const failures: string[] = [] + for (const { result, at } of placed) { + if ('error' in result) { + const code = result.code ? ` (${result.code})` : '' + failures.push(` [${at}]${code}: ${result.error}`) + continue + } + out[at] = result.data + } + + if (failures.length > 0) { + throw new Error( + `bulkDecrypt failed for ${failures.length} of ${placed.length} payload(s) (indices are into the input array):\n${failures.join('\n')}`, + ) + } + return out + }, EncryptionErrorTypes.DecryptionError) } } diff --git a/skills/stash-encryption/SKILL.md b/skills/stash-encryption/SKILL.md index 3faa25aee..6c013d165 100644 --- a/skills/stash-encryption/SKILL.md +++ b/skills/stash-encryption/SKILL.md @@ -189,6 +189,7 @@ The SDK never logs plaintext data. | `@cipherstash/stack/types` | All TypeScript types | | `@cipherstash/stack-drizzle/v3` | Drizzle ORM integration for EQL v3 schemas (see the `stash-drizzle` skill) | | `@cipherstash/stack-supabase` | `encryptedSupabaseV3` wrapper for Supabase (see the `stash-supabase` skill) | +| `@cipherstash/stack/wasm-inline` | The **edge** entry — Deno, Bun, Cloudflare Workers, Supabase Edge Functions. Its own `Encryption` factory plus the v3 authoring surface, `EncryptionErrorTypes`, and the WASM build of protect-ffi inlined into the bundle. No native binding, so no bundler externalisation needed. | | `@cipherstash/stack/dynamodb` | `encryptedDynamoDB` — **still requires the legacy v2 schema surface**; see "Legacy: EQL v2" below | | `@cipherstash/stack/schema`, `@cipherstash/stack/client`, `@cipherstash/stack/encryption` | Legacy v2 schema builders and client surface — see "Legacy: EQL v2" below | @@ -422,6 +423,43 @@ for (const item of decrypted.data) { } ``` +**On the WASM entry (`@cipherstash/stack/wasm-inline`), the batch shape differs** — do not copy the shape above onto the edge. The `{ data } | { failure }` Result is the same, but there are no `{ id, plaintext }` envelopes: each entry carries its own table and column, and the payload is a plain index-aligned array. + +> [!IMPORTANT] +> The `client` below is a **different client** from the one used everywhere else in this skill. The edge entry has its own `Encryption` factory — the native `EncryptionV3` client's `bulkEncrypt` takes `(plaintexts, { table, column })` and will fail at runtime if given the per-item shape below. Construct the WASM client explicitly: + +```typescript +// Deno / Workers / Supabase Edge Functions — note the import path +import { Encryption, encryptedTable, types } from "@cipherstash/stack/wasm-inline" + +const users = encryptedTable("users", { email: types.TextEq("email") }) + +const client = await Encryption({ + schemas: [users], + config: { + workspaceCrn: Deno.env.get("CS_WORKSPACE_CRN")!, + accessKey: Deno.env.get("CS_CLIENT_ACCESS_KEY")!, + clientId: Deno.env.get("CS_CLIENT_ID")!, + clientKey: Deno.env.get("CS_CLIENT_KEY")!, + }, +}) +``` + +```typescript +const encrypted = await client.bulkEncrypt([ + { plaintext: "alice@example.com", table: users, column: users.email }, + { plaintext: "hello", table: users, column: users.bio }, +]) +if (encrypted.failure) throw new Error(encrypted.failure.message) +// encrypted.data = [EncryptedPayload, EncryptedPayload] — same order as the input + +const decrypted = await client.bulkDecrypt(rows.map((r) => r.email)) +if (decrypted.failure) throw new Error(decrypted.failure.message) +// one ZeroKMS round trip for the whole list, not one per row +``` + +`null` / `undefined` entries yield `null` at the same index without reaching ZeroKMS. Because each entry names its own column, one call can cover several columns across many rows. When items fail to decrypt, `failure.message` names every failing index. The model helpers (`encryptModel` / `bulkEncryptModels` / …) are **not** available on the WASM entry. + ## Searchable Encryption Encrypt query terms with `encryptQuery` so you can search encrypted data in PostgreSQL. On the typed client, `encryptQuery` only accepts queryable columns (storage-only columns are rejected at compile time) and constrains `queryType` to the column's capabilities.