From c70b823d360e8c7964e3056ce762b381be14fe0a Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 21 Jul 2026 16:09:02 +1000 Subject: [PATCH 1/5] feat(stack): bulkEncrypt/bulkDecrypt on the wasm-inline entry (#737) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WASM entry exposed no bulk operations at all — only encrypt/decrypt — so rendering an N-row list on Deno, Cloudflare Workers, or Supabase Edge Functions cost N sequential ZeroKMS round trips. Combined with the ~6.6s cold start that made list endpoints impractical on the edge, and it left the entry with no access to the bulk path AGENTS.md names as the way to exercise ZeroKMS bulk speed. Surfaced as M1 in the rc.3 skilltester run. The FFI already had the primitives — `encryptBulk`, `decryptBulkFallible` are exported from protect-ffi's WASM build (identical in 0.29 and 0.30). This wires them up; no new capability was needed underneath. Signature deliberately differs from the native entry's bulk methods. It follows `encryptQueryBulk`, the bulk primitive already on this client: an index-aligned array with per-item table/column routing, throwing rather than a `{ data } | { failure }` envelope. No `{ id, plaintext }` payload envelopes either — protect-ffi's `EncryptPayload` has no `id` field, so the native one is dropped at the boundary and buys nothing once positions are stable. Consistency within this surface beats parity with a different entry point, since a WASM caller is reading this module's API. Per-item routing is what makes the saving real: one call covers several columns across many rows, where a single-column batch would still cost one round trip per column. Two failure modes are handled rather than assumed away: - `bulkDecrypt` builds on the FALLIBLE primitive, so when items fail it throws once naming every failing index and reason, instead of surfacing the first and discarding work the caller already paid for. - Both methods assert the FFI returned as many results as were sent. Matching is positional (no correlation id), so a short response would silently leave trailing slots null — indistinguishable from "this row had no value". That is silent wrong data, so it throws. Model helpers stay Node-only: this entry has no single-model operation to build them on, so adding bulk-model alone would be incoherent. Noted in the class docblock as a separate port. 14 new unit tests: one-FFI-call-per-batch, payload shape, mixed tables/columns with declared-vs-property column names, position stability across null/undefined, all-null and empty short-circuits, per-index failure reporting, and both length-mismatch guards. 823 tests pass; typecheck and build clean; 0 biome errors. Skill updated — `stash-encryption` documented only the native bulk shape, which would not compile on the edge. --- .changeset/wasm-inline-bulk-ops.md | 27 ++ .../helpers/stub-protect-ffi-wasm-inline.ts | 12 + .../stack/__tests__/wasm-inline-bulk.test.ts | 265 ++++++++++++++++++ packages/stack/src/wasm-inline.ts | 227 ++++++++++++++- skills/stash-encryption/SKILL.md | 16 ++ 5 files changed, 543 insertions(+), 4 deletions(-) create mode 100644 .changeset/wasm-inline-bulk-ops.md create mode 100644 packages/stack/__tests__/wasm-inline-bulk.test.ts diff --git a/.changeset/wasm-inline-bulk-ops.md b/.changeset/wasm-inline-bulk-ops.md new file mode 100644 index 000000000..afcc87ac1 --- /dev/null +++ b/.changeset/wasm-inline-bulk-ops.md @@ -0,0 +1,27 @@ +--- +'@cipherstash/stack': minor +'stash': patch +--- + +Add `bulkEncrypt` / `bulkDecrypt` to `@cipherstash/stack/wasm-inline`, so a list of encrypted rows costs **one** ZeroKMS round trip instead of one per row. + +The WASM entry previously exposed no bulk operations at all — only `encrypt` / `decrypt` — 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, and it left the entry with no access to the bulk path the docs recommend for throughput. + +```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)) +``` + +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. + +**The signature deliberately differs from the native entry's** `bulkEncrypt` / `bulkDecrypt`. It follows `encryptQueryBulk`, the bulk primitive already on this client: a plain index-aligned array with per-item routing, and errors that throw rather than a `{ data } | { failure }` envelope. There are no `{ id, plaintext }` payload envelopes — protect-ffi's `EncryptPayload` has no `id` field, so the native one is dropped at the FFI boundary and buys nothing when positions are already stable. + +`bulkDecrypt` builds on the fallible FFI primitive, so when items fail it throws once and 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/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..9333b5035 --- /dev/null +++ b/packages/stack/__tests__/wasm-inline-bulk.test.ts @@ -0,0 +1,265 @@ +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 () => []), + 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' + +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 }, + ]) + + expect(out).toHaveLength(4) + expect(out[0]).toBeNull() + expect(out[2]).toBeNull() + // Live values keep their ORIGINAL indices, not their compacted ones. + expect(out[1]).toEqual({ v: 3, i: {}, c: 'ct-0' }) + expect(out[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([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([]) + 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([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([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')]), + ).rejects.toThrow(/failed for 2 of 3 payload\(s\)/) + + ffi.decryptBulkFallible.mockResolvedValueOnce([ + { data: 'ok' }, + { error: 'boom-one' }, + { error: 'boom-two' }, + ] as never) + const err = await c + .bulkDecrypt([null, ct('a'), ct('b'), ct('c')]) + .catch((e: Error) => e) + + expect((err as Error).message).toContain('[2]: boom-one') + expect((err as Error).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([ + '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". +describe('bulk result/input length mismatch', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('bulkEncrypt throws 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 }, + ]), + ).rejects.toThrow(/sent 2 payload\(s\).*received 1 back/s) + }) + + it('bulkDecrypt throws 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')])).rejects.toThrow( + /sent 2 payload\(s\).*received 1 back/s, + ) + }) +}) diff --git a/packages/stack/src/wasm-inline.ts b/packages/stack/src/wasm-inline.ts index 8f545ccf4..20d12801d 100644 --- a/packages/stack/src/wasm-inline.ts +++ b/packages/stack/src/wasm-inline.ts @@ -77,7 +77,9 @@ import { } 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, @@ -298,6 +300,58 @@ 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'] +} + +/** + * 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. + */ +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 +364,17 @@ 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). + * + * 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. @@ -502,6 +563,164 @@ export class WasmEncryptionClient { }) return out } + + /** + * 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}. + * + * ## Why this differs from the native `bulkEncrypt` + * + * The Node entry takes one column for the whole batch and wraps values in + * `{ id, plaintext }` envelopes, returning `{ id, data }` inside a + * `{ data } | { failure }` Result. This surface keeps neither convention, + * deliberately: it follows {@link encryptQueryBulk}, the bulk primitive + * already on this client — a plain index-aligned array that THROWS, with + * per-item routing. Matching the local surface matters more here than + * matching a different entry point, and the `id` bookkeeping buys nothing + * when positions are already stable (the FFI's `EncryptPayload` has no + * `id` field — the native one is dropped at the boundary). + * + * @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 }, + * ]), + * ) + * // encrypted[0] = email of row 0, encrypted[1] = bio of row 0, … + * ``` + * + * @param items - Values to encrypt, each with its own table and column. + * @returns Index-aligned array of storage payloads (`null` per null input). + * @throws When encryption fails. 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> { + const live: Array<{ item: WasmBulkPlaintext; at: number }> = [] + items.forEach((item, at) => { + if (item.plaintext !== null && item.plaintext !== undefined) + live.push({ item, at }) + }) + const out: Array = items.map(() => null) + if (live.length === 0) return out + + const encrypted = (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[] + + // Results are matched to inputs BY POSITION (the FFI payload carries no + // correlation id). A length mismatch would silently leave trailing slots + // null — indistinguishable from "this row had no value" — so treat it as + // the contract violation it is rather than returning plausible-looking + // data. + assertBatchLength('bulkEncrypt', encrypted.length, live.length) + + encrypted.forEach((value, i) => { + const slot = live[i] + if (slot) out[slot.at] = value + }) + return out + } + + /** + * 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. This surface still throws (its whole convention is to throw, + * unlike the native entry's Result envelope), but the thrown error names + * every failed index and its reason rather than surfacing the first one 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. + * + * @example + * ```ts + * const rows = await sql`SELECT email FROM users LIMIT 50` + * const emails = await client.bulkDecrypt(rows.map((r) => r.email)) + * // one ZeroKMS call, not 50 + * ``` + * + * @param ciphertexts - Stored payloads; `null`/`undefined` entries allowed. + * @returns Index-aligned array of plaintexts (`null` per null input). + * @throws When any item fails to decrypt — the message lists each failing + * index with its reason. + */ + async bulkDecrypt( + ciphertexts: readonly (Encrypted | null | undefined)[], + ): Promise> { + const live: Array<{ ciphertext: Encrypted; at: number }> = [] + ciphertexts.forEach((ciphertext, at) => { + if (ciphertext !== null && ciphertext !== undefined) + live.push({ ciphertext, at }) + }) + const out: Array = ciphertexts.map(() => null) + if (live.length === 0) return out + + const results = (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 Array<{ data: WasmPlaintext } | { error: string; code?: string }> + + // Positional matching, same contract as `bulkEncrypt` — see there. + assertBatchLength('bulkDecrypt', results.length, live.length) + + // Collect every failure before throwing: the FFI already did the work for + // the rows that succeeded, so reporting only the first would discard + // information the caller paid for. + const failures: string[] = [] + results.forEach((result, i) => { + const slot = live[i] + if (!slot) return + if ('error' in result) { + failures.push(` [${slot.at}]: ${result.error}`) + return + } + out[slot.at] = result.data + }) + + if (failures.length > 0) { + throw new Error( + `[encryption]: bulkDecrypt failed for ${failures.length} of ${live.length} payload(s) (indices are into the input array):\n${failures.join('\n')}`, + ) + } + return out + } } /** diff --git a/skills/stash-encryption/SKILL.md b/skills/stash-encryption/SKILL.md index 3faa25aee..00562b9f3 100644 --- a/skills/stash-encryption/SKILL.md +++ b/skills/stash-encryption/SKILL.md @@ -422,6 +422,22 @@ for (const item of decrypted.data) { } ``` +**On the WASM entry (`@cipherstash/stack/wasm-inline`), the signature differs** — do not copy the shape above onto the edge. There are no `{ id, plaintext }` envelopes and no `Result`; each entry carries its own table and column, the result is a plain index-aligned array, and errors throw: + +```typescript +// Deno / Workers / Supabase Edge Functions +const encrypted = await client.bulkEncrypt([ + { plaintext: "alice@example.com", table: users, column: users.email }, + { plaintext: "hello", table: users, column: users.bio }, +]) +// encrypted = [EncryptedPayload, EncryptedPayload] — same order as the input + +const decrypted = await client.bulkDecrypt(rows.map((r) => r.email)) +// 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. `bulkDecrypt` throws if any item fails, naming 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. From 9e0b3a6caddf2ca52effecb08500450eaf9e72d7 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 21 Jul 2026 16:33:54 +1000 Subject: [PATCH 2/5] feat(stack)!: align the wasm-inline entry to the Result contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The WASM entry threw on every fallible method — `encrypt`, `decrypt`, `encryptQuery`, `encryptQueryBulk` — while every other surface in the repo returns `{ data } | { failure }`. AGENTS.md states that as a contract, not a preference: "Operations return `{ data }` or `{ failure }`. Preserve this shape and error `type` values in `EncryptionErrorTypes`." This was drift, not a design decision. Nothing about WASM prevents it: `@byteslice/result` is ALREADY bundled into dist/wasm-inline.js (tsup.config.ts `noExternal`). The cost was that 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 breaking 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 waited for a major. The earlier commit on this branch made the problem worse by adding two more throwing methods; this corrects both. All six fallible methods now return `Result`, with `failure.type` distinguishing encrypt-side (`EncryptionError`) from decrypt-side (`DecryptionError`) and `failure.code` carrying the FFI code. `isEncrypted` stays a bare boolean — a pure predicate with nothing to fail at, as on the native entry. Consumers updated: the supabase-worker example (now returns a 500 with the failure message rather than throwing past the handler), and the WASM integration adapter via an `unwrap` helper that aborts the harness loudly. A new `wasm-inline-result-contract.test.ts` pins the contract on both paths for all six methods so it cannot drift back. One thing found while testing: `withResult`'s `ensureError` REPLACES any non-Error throw with `new Error('Something went wrong')` before the error mapper runs, discarding the original value (@byteslice/result@0.2.0). That is library-wide — the native entry has it too — so it is pinned as a test rather than worked around, and will surface if the #532 bump to 0.5.0 changes it. 831 tests pass. Zero new tsc errors (verified by diffing the error set against main — the pre-existing 107 are unchanged). 0 biome errors. --- .changeset/wasm-inline-bulk-ops.md | 30 +- .../functions/cipherstash-roundtrip/index.ts | 21 +- .../stack/__tests__/helpers/expect-result.ts | 28 ++ .../stack/__tests__/wasm-inline-bulk.test.ts | 58 +-- .../stack/__tests__/wasm-inline-query.test.ts | 37 +- .../wasm-inline-result-contract.test.ts | 143 +++++++ packages/stack/integration/wasm/adapter.ts | 61 ++- packages/stack/src/wasm-inline.ts | 390 +++++++++++------- skills/stash-encryption/SKILL.md | 8 +- 9 files changed, 560 insertions(+), 216 deletions(-) create mode 100644 packages/stack/__tests__/helpers/expect-result.ts create mode 100644 packages/stack/__tests__/wasm-inline-result-contract.test.ts diff --git a/.changeset/wasm-inline-bulk-ops.md b/.changeset/wasm-inline-bulk-ops.md index afcc87ac1..5a1304023 100644 --- a/.changeset/wasm-inline-bulk-ops.md +++ b/.changeset/wasm-inline-bulk-ops.md @@ -3,9 +3,29 @@ 'stash': patch --- -Add `bulkEncrypt` / `bulkDecrypt` to `@cipherstash/stack/wasm-inline`, so a list of encrypted rows costs **one** ZeroKMS round trip instead of one per row. +**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. -The WASM entry previously exposed no bulk operations at all — only `encrypt` / `decrypt` — 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, and it left the entry with no access to the bulk path the docs recommend for throughput. +### 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 @@ -18,10 +38,10 @@ const encrypted = await client.bulkEncrypt([ const emails = await client.bulkDecrypt(rows.map((r) => r.email)) ``` -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. +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. -**The signature deliberately differs from the native entry's** `bulkEncrypt` / `bulkDecrypt`. It follows `encryptQueryBulk`, the bulk primitive already on this client: a plain index-aligned array with per-item routing, and errors that throw rather than a `{ data } | { failure }` envelope. There are no `{ id, plaintext }` payload envelopes — protect-ffi's `EncryptPayload` has no `id` field, so the native one is dropped at the FFI boundary and buys nothing when positions are already stable. +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 it throws once and names **every** failing index with its reason, rather than surfacing the first and discarding the rest. +`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/examples/supabase-worker/supabase/functions/cipherstash-roundtrip/index.ts b/examples/supabase-worker/supabase/functions/cipherstash-roundtrip/index.ts index 603e0939a..5bea2d63e 100644 --- a/examples/supabase-worker/supabase/functions/cipherstash-roundtrip/index.ts +++ b/examples/supabase-worker/supabase/functions/cipherstash-roundtrip/index.ts @@ -61,11 +61,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..4d62a198d --- /dev/null +++ b/packages/stack/__tests__/helpers/expect-result.ts @@ -0,0 +1,28 @@ +import type { Result } from '@byteslice/result' +import type { EncryptionError } from '../../src/errors' + +/** + * Narrow a `Result` to its success arm in tests. + * + * `Result` is `Success | Failure`, and `Failure` has NO `data` + * property — so `result.data` doesn't type-check until the union is narrowed. + * Every assertion on a successful payload would otherwise need its own + * `if (r.failure) throw` preamble. + * + * `F` is pinned to `EncryptionError` rather than left generic: the library + * constrains it to `FailureCase | Error`, and a bare type parameter neither + * satisfies that constraint nor lets TypeScript discriminate the union. + * Every caller here is an encryption operation anyway. + * + * 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: Result): S { + if (result.failure) { + throw new Error( + `expected { data } but got { failure } (${result.failure.type}): ${result.failure.message}`, + ) + } + return result.data +} diff --git a/packages/stack/__tests__/wasm-inline-bulk.test.ts b/packages/stack/__tests__/wasm-inline-bulk.test.ts index 9333b5035..79adc261a 100644 --- a/packages/stack/__tests__/wasm-inline-bulk.test.ts +++ b/packages/stack/__tests__/wasm-inline-bulk.test.ts @@ -37,6 +37,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', { email: types.TextEq('email'), @@ -128,12 +129,13 @@ describe('WasmEncryptionClient.bulkEncrypt', () => { { plaintext: 'c@d.com', table: users, column: users.email }, ]) - expect(out).toHaveLength(4) - expect(out[0]).toBeNull() - expect(out[2]).toBeNull() + 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(out[1]).toEqual({ v: 3, i: {}, c: 'ct-0' }) - expect(out[3]).toEqual({ v: 3, i: {}, c: 'ct-1' }) + 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] @@ -147,13 +149,13 @@ describe('WasmEncryptionClient.bulkEncrypt', () => { { plaintext: undefined, table: users, column: users.email }, ]) - expect(out).toEqual([null, null]) + 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([]) + expect(await c.bulkEncrypt([])).toEqual({ data: [] }) expect(ffi.encryptBulk).not.toHaveBeenCalled() }) }) @@ -185,14 +187,16 @@ describe('WasmEncryptionClient.bulkDecrypt', () => { const c = await client() const out = await c.bulkDecrypt([null, ct('a'), undefined, ct('b')]) - expect(out).toEqual([null, 'plain-0', null, 'plain-1']) + 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([null, null]) + expect(await c.bulkDecrypt([null, undefined])).toEqual({ + data: [null, null], + }) expect(ffi.decryptBulkFallible).not.toHaveBeenCalled() }) @@ -208,27 +212,29 @@ describe('WasmEncryptionClient.bulkDecrypt', () => { // 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')]), - ).rejects.toThrow(/failed for 2 of 3 payload\(s\)/) + ).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 err = await c - .bulkDecrypt([null, ct('a'), ct('b'), ct('c')]) - .catch((e: Error) => e) + const res = await c.bulkDecrypt([null, ct('a'), ct('b'), ct('c')]) - expect((err as Error).message).toContain('[2]: boom-one') - expect((err as Error).message).toContain('[3]: boom-two') + 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([ - 'plain-0', - 'plain-1', - ]) + await expect(c.bulkDecrypt([ct('a'), ct('b')])).resolves.toEqual({ + data: ['plain-0', 'plain-1'], + }) }) }) @@ -249,7 +255,11 @@ describe('bulk result/input length mismatch', () => { { plaintext: 'a', table: users, column: users.email }, { plaintext: 'b', table: users, column: users.email }, ]), - ).rejects.toThrow(/sent 2 payload\(s\).*received 1 back/s) + ).resolves.toMatchObject({ + failure: { + message: expect.stringMatching(/sent 2 payload\(s\).*received 1 back/s), + }, + }) }) it('bulkDecrypt throws rather than returning a partially-null batch', async () => { @@ -258,8 +268,10 @@ describe('bulk result/input length mismatch', () => { ] as never) const c = await client() - await expect(c.bulkDecrypt([ct('a'), ct('b')])).rejects.toThrow( - /sent 2 payload\(s\).*received 1 back/s, - ) + 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-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..4f0d7c210 --- /dev/null +++ b/packages/stack/__tests__/wasm-inline-result-contract.test.ts @@ -0,0 +1,143 @@ +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', + }) + }) + + // A non-Error throw still produces a well-formed `{ failure }`, but its + // message is LOST: `withResult`'s `ensureError` replaces any non-Error with + // `new Error('Something went wrong')` before `onError` ever sees it + // (@byteslice/result@0.2.0, `dist/result.js:27`). That is a library-wide + // characteristic — the native entry's `(error as Error).message` has it too + // — not something this entry can fix locally. Pinned so the behaviour is + // documented rather than surprising, and so the #532 bump to 0.5.0 shows up + // here as a failing test if the library starts passing the value through. + it('still returns a well-formed failure for a non-Error throw', async () => { + ffi.encrypt.mockRejectedValueOnce('just a string') + + 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('Something went wrong') + }) +}) diff --git a/packages/stack/integration/wasm/adapter.ts b/packages/stack/integration/wasm/adapter.ts index 7caaef6b3..188b40715 100644 --- a/packages/stack/integration/wasm/adapter.ts +++ b/packages/stack/integration/wasm/adapter.ts @@ -92,6 +92,26 @@ 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: + | { data: T; failure?: never } + | { data?: never; failure: { message: string } }, + 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 +159,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) } } @@ -229,10 +252,13 @@ export function makeWasmAdapter(): IntegrationAdapter { // 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, - }) + const encrypted = unwrap( + await client.encrypt(toWasmPlaintext(value), { + table: tableSchema, + column: col(slug).column, + }), + 'encrypt', + ) assertWireEnvelope(slug, encrypted) assignments[slug] = encrypted }), @@ -266,13 +292,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 20d12801d..e2f07bace 100644 --- a/packages/stack/src/wasm-inline.ts +++ b/packages/stack/src/wasm-inline.ts @@ -71,6 +71,7 @@ * re-exported from this entry. */ +import { type Result, withResult } from '@byteslice/result' import { AccessKeyStrategy, type OidcFederationStrategy, @@ -85,12 +86,14 @@ import { isEncrypted as wasmIsEncrypted, newClient as wasmNewClient, } from '@cipherstash/protect-ffi/wasm-inline' +import { getErrorCode } from '@/encryption/helpers/error-code' import { resolveIndexType } from '@/encryption/helpers/infer-index-type' import { assertValidNumericValue, assertValueIndexCompatibility, } from '@/encryption/helpers/validation' import { type AnyV3Table, buildEncryptConfig } from '@/eql/v3' +import { type EncryptionError, EncryptionErrorTypes } from '@/errors' import { type CastAs, type EncryptConfig, @@ -332,6 +335,31 @@ export type WasmBulkPlaintext = { 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. + */ +function toFailure( + type: EncryptionError['type'], +): (error: unknown) => EncryptionError { + return (error: unknown) => ({ + type, + // In practice always an `Error`: `withResult`'s `ensureError` replaces any + // non-Error throw with `new Error('Something went wrong')` before this + // runs, discarding the original value (@byteslice/result@0.2.0). The + // narrowing is kept anyway — it is correct on its own terms, and the #532 + // bump to 0.5.0 may start passing the raw value through. + message: error instanceof Error ? error.message : String(error), + code: getErrorCode(error), + }) +} + /** * Guard the positional contract the bulk methods rely on. * @@ -369,6 +397,24 @@ const INTERNAL_CONSTRUCT = Symbol('cs-wasm-client') * `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 @@ -401,25 +447,35 @@ 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 withResult(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 + }, toFailure(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 withResult( + 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, + toFailure(EncryptionErrorTypes.DecryptionError), + ) } isEncrypted(value: unknown): boolean { @@ -490,25 +546,26 @@ export class WasmEncryptionClient { * @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 withResult(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 + }, toFailure(EncryptionErrorTypes.EncryptionError)) } /** @@ -523,45 +580,53 @@ 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, EncryptionError>> { + return withResult(async () => { + 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[] + + assertBatchLength('encryptQueryBulk', encrypted.length, live.length) + + encrypted.forEach((value, i) => { + const slot = live[i] + if (slot) out[slot.at] = value + }) + return out + }, toFailure(EncryptionErrorTypes.EncryptionError)) } /** @@ -576,17 +641,18 @@ export class WasmEncryptionClient { * being sent to ZeroKMS (an all-null batch short-circuits entirely). * Entries may mix tables and columns freely — see {@link WasmBulkPlaintext}. * - * ## Why this differs from the native `bulkEncrypt` + * ## How this differs from the native `bulkEncrypt` * - * The Node entry takes one column for the whole batch and wraps values in - * `{ id, plaintext }` envelopes, returning `{ id, data }` inside a - * `{ data } | { failure }` Result. This surface keeps neither convention, - * deliberately: it follows {@link encryptQueryBulk}, the bulk primitive - * already on this client — a plain index-aligned array that THROWS, with - * per-item routing. Matching the local surface matters more here than - * matching a different entry point, and the `id` bookkeeping buys nothing - * when positions are already stable (the FFI's `EncryptPayload` has no - * `id` field — the native one is dropped at the boundary). + * 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 @@ -597,51 +663,54 @@ export class WasmEncryptionClient { * { plaintext: r.bio, table: users, column: users.bio }, * ]), * ) - * // encrypted[0] = email of row 0, encrypted[1] = bio of row 0, … + * 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 Index-aligned array of storage payloads (`null` per null input). - * @throws When encryption fails. 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). + * @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> { - const live: Array<{ item: WasmBulkPlaintext; at: number }> = [] - items.forEach((item, at) => { - if (item.plaintext !== null && item.plaintext !== undefined) - live.push({ item, at }) - }) - const out: Array = items.map(() => null) - if (live.length === 0) return out - - const encrypted = (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[] - - // Results are matched to inputs BY POSITION (the FFI payload carries no - // correlation id). A length mismatch would silently leave trailing slots - // null — indistinguishable from "this row had no value" — so treat it as - // the contract violation it is rather than returning plausible-looking - // data. - assertBatchLength('bulkEncrypt', encrypted.length, live.length) - - encrypted.forEach((value, i) => { - const slot = live[i] - if (slot) out[slot.at] = value - }) - return out + ): Promise, EncryptionError>> { + return withResult(async () => { + const live: Array<{ item: WasmBulkPlaintext; at: number }> = [] + items.forEach((item, at) => { + if (item.plaintext !== null && item.plaintext !== undefined) + live.push({ item, at }) + }) + const out: Array = items.map(() => null) + if (live.length === 0) return out + + const encrypted = (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[] + + // Results are matched to inputs BY POSITION (the FFI payload carries no + // correlation id). A length mismatch would silently leave trailing slots + // null — indistinguishable from "this row had no value" — so treat it as + // the contract violation it is rather than returning plausible-looking + // data. + assertBatchLength('bulkEncrypt', encrypted.length, live.length) + + encrypted.forEach((value, i) => { + const slot = live[i] + if (slot) out[slot.at] = value + }) + return out + }, toFailure(EncryptionErrorTypes.EncryptionError)) } /** @@ -659,67 +728,74 @@ export class WasmEncryptionClient { * * The underlying primitive is `decryptBulkFallible`, which reports success * or failure PER ITEM — one undecryptable row does not fail the call at the - * FFI level. This surface still throws (its whole convention is to throw, - * unlike the native entry's Result envelope), but the thrown error names - * every failed index and its reason rather than surfacing the first one 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. + * 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 Index-aligned array of plaintexts (`null` per null input). - * @throws When any item fails to decrypt — the message lists each failing - * index with its reason. + * @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> { - const live: Array<{ ciphertext: Encrypted; at: number }> = [] - ciphertexts.forEach((ciphertext, at) => { - if (ciphertext !== null && ciphertext !== undefined) - live.push({ ciphertext, at }) - }) - const out: Array = ciphertexts.map(() => null) - if (live.length === 0) return out - - const results = (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 Array<{ data: WasmPlaintext } | { error: string; code?: string }> - - // Positional matching, same contract as `bulkEncrypt` — see there. - assertBatchLength('bulkDecrypt', results.length, live.length) - - // Collect every failure before throwing: the FFI already did the work for - // the rows that succeeded, so reporting only the first would discard - // information the caller paid for. - const failures: string[] = [] - results.forEach((result, i) => { - const slot = live[i] - if (!slot) return - if ('error' in result) { - failures.push(` [${slot.at}]: ${result.error}`) - return - } - out[slot.at] = result.data - }) + ): Promise, EncryptionError>> { + return withResult(async () => { + const live: Array<{ ciphertext: Encrypted; at: number }> = [] + ciphertexts.forEach((ciphertext, at) => { + if (ciphertext !== null && ciphertext !== undefined) + live.push({ ciphertext, at }) + }) + const out: Array = ciphertexts.map(() => null) + if (live.length === 0) return out - if (failures.length > 0) { - throw new Error( - `[encryption]: bulkDecrypt failed for ${failures.length} of ${live.length} payload(s) (indices are into the input array):\n${failures.join('\n')}`, - ) - } - return out + const results = (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 Array<{ data: WasmPlaintext } | { error: string; code?: string }> + + // Positional matching, same contract as `bulkEncrypt` — see there. + assertBatchLength('bulkDecrypt', results.length, live.length) + + // 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. + const failures: string[] = [] + results.forEach((result, i) => { + const slot = live[i] + if (!slot) return + if ('error' in result) { + failures.push(` [${slot.at}]: ${result.error}`) + return + } + out[slot.at] = result.data + }) + + if (failures.length > 0) { + throw new Error( + `bulkDecrypt failed for ${failures.length} of ${live.length} payload(s) (indices are into the input array):\n${failures.join('\n')}`, + ) + } + return out + }, toFailure(EncryptionErrorTypes.DecryptionError)) } } diff --git a/skills/stash-encryption/SKILL.md b/skills/stash-encryption/SKILL.md index 00562b9f3..647d68e30 100644 --- a/skills/stash-encryption/SKILL.md +++ b/skills/stash-encryption/SKILL.md @@ -422,7 +422,7 @@ for (const item of decrypted.data) { } ``` -**On the WASM entry (`@cipherstash/stack/wasm-inline`), the signature differs** — do not copy the shape above onto the edge. There are no `{ id, plaintext }` envelopes and no `Result`; each entry carries its own table and column, the result is a plain index-aligned array, and errors throw: +**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. ```typescript // Deno / Workers / Supabase Edge Functions @@ -430,13 +430,15 @@ const encrypted = await client.bulkEncrypt([ { plaintext: "alice@example.com", table: users, column: users.email }, { plaintext: "hello", table: users, column: users.bio }, ]) -// encrypted = [EncryptedPayload, EncryptedPayload] — same order as the input +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. `bulkDecrypt` throws if any item fails, naming every failing index. The model helpers (`encryptModel` / `bulkEncryptModels` / …) are **not** available on the WASM entry. +`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 From f2c56b98cdb71b2e9959ba209df1b9198385594e Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 21 Jul 2026 17:11:52 +1000 Subject: [PATCH 3/5] fix(stack): preserve non-Error rejection detail on the wasm-inline entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `withResult`'s default `ensureError` REPLACES any non-Error throw with `new Error('Something went wrong')`, discarding the original value (@byteslice/result@0.2.0, dist/result.js:27). But that is only the FALLBACK — the library prefers an `onException` hook: const error = hooks?.onException?.(ex) ?? ensureError(ex) Nothing in this repo passes it. On the native entry that rarely bites, because the FFI throws real `ProtectError` instances. On WASM it is a live hazard: 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 at all — so a genuine FFI failure can arrive as a bare string or object. That made the Result conversion in the previous commit a partial REGRESSION: the old throwing behaviour at least propagated the raw value to the caller, whereas `withResult` was silently swallowing it. Fixed by supplying `onException`. The six call sites now go through one `wasmResult()` seam that binds both the failure shape and the hook, so a method added later cannot omit either — an omission would not fail a build, it would just quietly degrade failure messages, which is the whole bug. `toError` preserves strings as-is, JSON-serializes objects (so `{code, detail}` survives rather than becoming "[object Object]"), and falls back to `String()` for cycles and other unserializable values. +2 tests covering string and object preservation, plus the cyclic fallback. 833 pass; 0 biome errors; zero new tsc errors (diffed against main's error set, not counted). --- .../wasm-inline-result-contract.test.ts | 49 +++++++++--- packages/stack/src/wasm-inline.ts | 77 +++++++++++++++---- 2 files changed, 98 insertions(+), 28 deletions(-) diff --git a/packages/stack/__tests__/wasm-inline-result-contract.test.ts b/packages/stack/__tests__/wasm-inline-result-contract.test.ts index 4f0d7c210..34d24b594 100644 --- a/packages/stack/__tests__/wasm-inline-result-contract.test.ts +++ b/packages/stack/__tests__/wasm-inline-result-contract.test.ts @@ -120,16 +120,43 @@ describe('wasm-inline Result contract — failure path', () => { }) }) - // A non-Error throw still produces a well-formed `{ failure }`, but its - // message is LOST: `withResult`'s `ensureError` replaces any non-Error with - // `new Error('Something went wrong')` before `onError` ever sees it - // (@byteslice/result@0.2.0, `dist/result.js:27`). That is a library-wide - // characteristic — the native entry's `(error as Error).message` has it too - // — not something this entry can fix locally. Pinned so the behaviour is - // documented rather than surprising, and so the #532 bump to 0.5.0 shows up - // here as a failing test if the library starts passing the value through. - it('still returns a well-formed failure for a non-Error throw', async () => { - ffi.encrypt.mockRejectedValueOnce('just a string') + // 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', { @@ -138,6 +165,6 @@ describe('wasm-inline Result contract — failure path', () => { }) expect(result.failure?.type).toBe('EncryptionError') - expect(result.failure?.message).toBe('Something went wrong') + expect(result.failure?.message).toBe('[object Object]') }) }) diff --git a/packages/stack/src/wasm-inline.ts b/packages/stack/src/wasm-inline.ts index e2f07bace..17567d05d 100644 --- a/packages/stack/src/wasm-inline.ts +++ b/packages/stack/src/wasm-inline.ts @@ -350,16 +350,59 @@ function toFailure( ): (error: unknown) => EncryptionError { return (error: unknown) => ({ type, - // In practice always an `Error`: `withResult`'s `ensureError` replaces any - // non-Error throw with `new Error('Something went wrong')` before this - // runs, discarding the original value (@byteslice/result@0.2.0). The - // narrowing is kept anyway — it is correct on its own terms, and the #532 - // bump to 0.5.0 may start passing the raw value through. message: error instanceof Error ? error.message : String(error), code: getErrorCode(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) + try { + // 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 — `String` covers both. + return new Error(JSON.stringify(ex) ?? String(ex)) + } catch { + return new Error(String(ex)) + } +} + +/** + * `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. * @@ -448,7 +491,7 @@ export class WasmEncryptionClient { plaintext: WasmPlaintext, opts: EncryptOptions, ): Promise> { - return withResult(async () => { + return wasmResult(async () => { const ffiOpts = { plaintext, table: opts.table.tableName, @@ -460,13 +503,13 @@ export class WasmEncryptionClient { // biome-ignore lint/plugin: the opts cross the serde boundary, whose shape protect-ffi types as `any` ffiOpts as never, )) as Encrypted - }, toFailure(EncryptionErrorTypes.EncryptionError)) + }, EncryptionErrorTypes.EncryptionError) } async decrypt( encrypted: Encrypted, ): Promise> { - return withResult( + return wasmResult( async () => (await wasmDecrypt( // biome-ignore lint/plugin: the FFI handle is an opaque wasm-bindgen pointer with no JS-side type @@ -474,7 +517,7 @@ export class WasmEncryptionClient { // biome-ignore lint/plugin: the opts cross the serde boundary, whose shape protect-ffi types as `any` { ciphertext: encrypted } as never, )) as WasmPlaintext, - toFailure(EncryptionErrorTypes.DecryptionError), + EncryptionErrorTypes.DecryptionError, ) } @@ -557,7 +600,7 @@ export class WasmEncryptionClient { plaintext: WasmPlaintext, opts: WasmEncryptQueryOptions, ): Promise> { - return withResult(async () => { + 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 @@ -565,7 +608,7 @@ export class WasmEncryptionClient { // biome-ignore lint/plugin: the term crosses the serde boundary, whose shape protect-ffi types as `any` toFfiQueryTerm(plaintext, opts) as never, )) as EncryptedQuery - }, toFailure(EncryptionErrorTypes.EncryptionError)) + }, EncryptionErrorTypes.EncryptionError) } /** @@ -598,7 +641,7 @@ export class WasmEncryptionClient { async encryptQueryBulk( terms: readonly WasmQueryTerm[], ): Promise, EncryptionError>> { - return withResult(async () => { + return wasmResult(async () => { const live: Array<{ term: WasmQueryTerm; at: number }> = [] terms.forEach((term, at) => { if (term.value !== null && term.value !== undefined) @@ -626,7 +669,7 @@ export class WasmEncryptionClient { if (slot) out[slot.at] = value }) return out - }, toFailure(EncryptionErrorTypes.EncryptionError)) + }, EncryptionErrorTypes.EncryptionError) } /** @@ -676,7 +719,7 @@ export class WasmEncryptionClient { async bulkEncrypt( items: readonly WasmBulkPlaintext[], ): Promise, EncryptionError>> { - return withResult(async () => { + return wasmResult(async () => { const live: Array<{ item: WasmBulkPlaintext; at: number }> = [] items.forEach((item, at) => { if (item.plaintext !== null && item.plaintext !== undefined) @@ -710,7 +753,7 @@ export class WasmEncryptionClient { if (slot) out[slot.at] = value }) return out - }, toFailure(EncryptionErrorTypes.EncryptionError)) + }, EncryptionErrorTypes.EncryptionError) } /** @@ -753,7 +796,7 @@ export class WasmEncryptionClient { async bulkDecrypt( ciphertexts: readonly (Encrypted | null | undefined)[], ): Promise, EncryptionError>> { - return withResult(async () => { + return wasmResult(async () => { const live: Array<{ ciphertext: Encrypted; at: number }> = [] ciphertexts.forEach((ciphertext, at) => { if (ciphertext !== null && ciphertext !== undefined) @@ -795,7 +838,7 @@ export class WasmEncryptionClient { ) } return out - }, toFailure(EncryptionErrorTypes.DecryptionError)) + }, EncryptionErrorTypes.DecryptionError) } } From 0020b56e57b3f7549a12e3b1a0e880872e0c9f68 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 21 Jul 2026 18:00:23 +1000 Subject: [PATCH 4/5] =?UTF-8?q?fix(stack):=20address=20review=20=E2=80=94?= =?UTF-8?q?=20native=20FFI=20leak,=20Result=20plumbing,=20batch=20bugs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found 15 issues. This commit takes the source and test ones. CRITICAL (1/15) — the entry pulled in the NATIVE protect-ffi. `@/encryption/helpers/error-code` value-imports `ProtectError` for an `instanceof` narrow, and protect-ffi is not in tsup `noExternal`, so `dist/wasm-inline.js` carried a bare `@cipherstash/protect-ffi` import — the NAPI entry, in the one bundle that exists to avoid it. On Workers / Edge the non-`node` condition resolves it to a module exporting no `ProtectError`; under Deno it resolves to the NAPI loader. I had previously dismissed this as pre-existing. That was wrong: I ran `git stash -u` on a clean tree, so it stashed nothing and I rebuilt my own branch and compared it against itself. Re-verified properly by swapping in main's `wasm-inline.ts` and rebuilding: main emits only the `/wasm-inline` specifier, mine emitted both. Replaced with a structural `readErrorCode` — which is the only thing that could ever work here anyway, since the WASM build ships no error class for `instanceof` to match. GH #744 is filed on my faulty premise and needs correcting. New `wasm-inline-bundle-isolation.test.ts` asserts the built bundle's external imports, so this class of regression fails CI. Verified it catches the exact regression by reintroducing it (2 of 3 assertions fail), rather than assuming. 3/15 — `failure.code` could never be populated: `withResult` runs `onException` first, so the mapper only ever saw a fresh Error. `toError` now carries a structural `code` onto the synthesized Error, and `bulkDecrypt` puts each item's FFI code in its per-index message (a batch has no single code, but dropping it lost the only machine-readable part). 9/15 — `toError`'s `String(ex)` fallback could itself throw on a null-prototype object, escaping the Result contract entirely and rejecting the call. Guarded with `safeString`. 10/15 — sparse inputs. `items.map(() => null)` SKIPS holes, so a hole came back `undefined` rather than the documented `null` while lengths still matched, which the length guard cannot see. Now `Array.from`. 15/15 — the positional-batch scaffolding was hand-rolled three times, so a fourth batch method could omit `assertBatchLength` with no build failure. One `runBatch` helper owns compaction, short-circuit, length assert and scatter; the sparse fix lives there once. 5/15, 6/15, 7/15 — docs and surface: removed a duplicate stale `@returns`, migrated the module-header and all five `encryptQuery` examples off the pre-Result contract (they interpolated the envelope straight into SQL), corrected the pending `.changeset/wasm-encrypt-query.md` sentence that still said errors throw, and exported `EncryptionError` / `EncryptionErrorTypes` so an edge consumer can discriminate `failure.type` from the same import. `WasmResult` is now declared locally rather than re-exporting `@byteslice/result`'s. Re-exporting put that specifier in the emitted `.d.ts`, which Deno cannot resolve — the same reason `WasmPlaintext` re-declares protect-ffi's `JsPlaintext`. 14/15 — test gaps: falsy-but-live values (`0` / `''` / `false`) per batch method, the sparse-hole case, `encryptQueryBulk`'s previously untested length guard, and the two stale "throws" titles. 840 tests pass. Zero new tsc errors, diffed against main's error set. 0 biome errors. Bundle verified free of the native FFI. --- .changeset/wasm-encrypt-query.md | 5 +- e2e/wasm/query-types.test.ts | 103 ++++-- e2e/wasm/roundtrip.test.ts | 68 +++- .../stack/__tests__/helpers/expect-result.ts | 23 +- .../stack/__tests__/wasm-inline-bulk.test.ts | 87 ++++- .../wasm-inline-bundle-isolation.test.ts | 117 ++++++ packages/stack/integration/wasm/adapter.ts | 8 +- packages/stack/src/wasm-inline.ts | 339 ++++++++++++------ 8 files changed, 577 insertions(+), 173 deletions(-) create mode 100644 packages/stack/__tests__/wasm-inline-bundle-isolation.test.ts 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/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/packages/stack/__tests__/helpers/expect-result.ts b/packages/stack/__tests__/helpers/expect-result.ts index 4d62a198d..f654bc8eb 100644 --- a/packages/stack/__tests__/helpers/expect-result.ts +++ b/packages/stack/__tests__/helpers/expect-result.ts @@ -1,28 +1,25 @@ -import type { Result } from '@byteslice/result' -import type { EncryptionError } from '../../src/errors' +import type { WasmResult } from '../../src/wasm-inline' /** - * Narrow a `Result` to its success arm in tests. + * Narrow a `WasmResult` to its success arm in tests. * - * `Result` is `Success | Failure`, and `Failure` has NO `data` - * property — so `result.data` doesn't type-check until the union is narrowed. - * Every assertion on a successful payload would otherwise need its own - * `if (r.failure) throw` preamble. + * `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. * - * `F` is pinned to `EncryptionError` rather than left generic: the library - * constrains it to `FailureCase | Error`, and a bare type parameter neither - * satisfies that constraint nor lets TypeScript discriminate the union. - * Every caller here is an encryption operation anyway. + * 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: Result): S { +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 + return result.data as S } diff --git a/packages/stack/__tests__/wasm-inline-bulk.test.ts b/packages/stack/__tests__/wasm-inline-bulk.test.ts index 79adc261a..1f2ef6377 100644 --- a/packages/stack/__tests__/wasm-inline-bulk.test.ts +++ b/packages/stack/__tests__/wasm-inline-bulk.test.ts @@ -15,7 +15,10 @@ const ffi = vi.hoisted(() => ({ decrypt: vi.fn(async () => 'plain'), isEncrypted: vi.fn(() => true), encryptQuery: vi.fn(async () => ({ v: 3, i: {} })), - encryptQueryBulk: vi.fn(async () => []), + 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}` })), @@ -241,12 +244,90 @@ describe('WasmEncryptionClient.bulkDecrypt', () => { // 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 throws rather than returning a partially-null batch', async () => { + it('bulkEncrypt fails rather than returning a partially-null batch', async () => { ffi.encryptBulk.mockResolvedValueOnce([{ v: 3, i: {}, c: 'only-one' }]) const c = await client() @@ -262,7 +343,7 @@ describe('bulk result/input length mismatch', () => { }) }) - it('bulkDecrypt throws rather than returning a partially-null batch', async () => { + it('bulkDecrypt fails rather than returning a partially-null batch', async () => { ffi.decryptBulkFallible.mockResolvedValueOnce([ { data: 'only-one' }, ] as never) 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/integration/wasm/adapter.ts b/packages/stack/integration/wasm/adapter.ts index 188b40715..a9a41d3ea 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([ @@ -100,12 +101,7 @@ function toWasmPlaintext(value: Plain): WasmPlaintext { * 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: - | { data: T; failure?: never } - | { data?: never; failure: { message: string } }, - op: string, -): T { +function unwrap(result: WasmResult, op: string): T { if (result.failure) { throw new Error(`[wasm adapter]: ${op} failed — ${result.failure.message}`) } diff --git a/packages/stack/src/wasm-inline.ts b/packages/stack/src/wasm-inline.ts index 17567d05d..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,7 +77,7 @@ * re-exported from this entry. */ -import { type Result, withResult } from '@byteslice/result' +import { withResult } from '@byteslice/result' import { AccessKeyStrategy, type OidcFederationStrategy, @@ -86,7 +92,6 @@ import { isEncrypted as wasmIsEncrypted, newClient as wasmNewClient, } from '@cipherstash/protect-ffi/wasm-inline' -import { getErrorCode } from '@/encryption/helpers/error-code' import { resolveIndexType } from '@/encryption/helpers/infer-index-type' import { assertValidNumericValue, @@ -129,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. */ @@ -345,13 +357,54 @@ export type WasmBulkPlaintext = { * `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: getErrorCode(error), + code: readErrorCode(error), }) } @@ -378,13 +431,42 @@ function toFailure( 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 { - // 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 — `String` covers both. - return new Error(JSON.stringify(ex) ?? String(ex)) + message = JSON.stringify(ex) ?? safeString(ex) } catch { - return new Error(String(ex)) + 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) } } @@ -399,7 +481,7 @@ function toError(ex: unknown): Error { function wasmResult( operation: () => Promise, type: EncryptionError['type'], -): Promise> { +): Promise> { return withResult(operation, toFailure(type), { onException: toError }) } @@ -413,6 +495,54 @@ function wasmResult( * 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( @@ -490,7 +620,7 @@ export class WasmEncryptionClient { async encrypt( plaintext: WasmPlaintext, opts: EncryptOptions, - ): Promise> { + ): Promise> { return wasmResult(async () => { const ffiOpts = { plaintext, @@ -506,9 +636,7 @@ export class WasmEncryptionClient { }, EncryptionErrorTypes.EncryptionError) } - async decrypt( - encrypted: Encrypted, - ): Promise> { + async decrypt(encrypted: Encrypted): Promise> { return wasmResult( async () => (await wasmDecrypt( @@ -542,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) @@ -552,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) @@ -561,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 @@ -571,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 @@ -580,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` @@ -588,7 +721,6 @@ 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. * @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 @@ -599,7 +731,7 @@ export class WasmEncryptionClient { async encryptQuery( plaintext: WasmPlaintext, opts: WasmEncryptQueryOptions, - ): Promise> { + ): Promise> { return wasmResult(async () => { if (plaintext === null || plaintext === undefined) return null return (await wasmEncryptQuery( @@ -640,34 +772,25 @@ export class WasmEncryptionClient { */ async encryptQueryBulk( terms: readonly WasmQueryTerm[], - ): Promise, EncryptionError>> { + ): Promise>> { return wasmResult(async () => { - 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[] - - assertBatchLength('encryptQueryBulk', encrypted.length, live.length) - - encrypted.forEach((value, i) => { - const slot = live[i] - if (slot) out[slot.at] = value - }) + 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) } @@ -718,40 +841,27 @@ export class WasmEncryptionClient { */ async bulkEncrypt( items: readonly WasmBulkPlaintext[], - ): Promise, EncryptionError>> { + ): Promise>> { return wasmResult(async () => { - const live: Array<{ item: WasmBulkPlaintext; at: number }> = [] - items.forEach((item, at) => { - if (item.plaintext !== null && item.plaintext !== undefined) - live.push({ item, at }) - }) - const out: Array = items.map(() => null) - if (live.length === 0) return out - - const encrypted = (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[] - - // Results are matched to inputs BY POSITION (the FFI payload carries no - // correlation id). A length mismatch would silently leave trailing slots - // null — indistinguishable from "this row had no value" — so treat it as - // the contract violation it is rather than returning plausible-looking - // data. - assertBatchLength('bulkEncrypt', encrypted.length, live.length) - - encrypted.forEach((value, i) => { - const slot = live[i] - if (slot) out[slot.at] = value - }) + 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) } @@ -795,46 +905,53 @@ export class WasmEncryptionClient { */ async bulkDecrypt( ciphertexts: readonly (Encrypted | null | undefined)[], - ): Promise, EncryptionError>> { + ): Promise>> { return wasmResult(async () => { - const live: Array<{ ciphertext: Encrypted; at: number }> = [] - ciphertexts.forEach((ciphertext, at) => { - if (ciphertext !== null && ciphertext !== undefined) - live.push({ ciphertext, at }) - }) - const out: Array = ciphertexts.map(() => null) - if (live.length === 0) return out - - const results = (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 Array<{ data: WasmPlaintext } | { error: string; code?: string }> + type FallibleItem = + | { data: WasmPlaintext } + | { error: string; code?: string } - // Positional matching, same contract as `bulkEncrypt` — see there. - assertBatchLength('bulkDecrypt', results.length, live.length) + 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[] = [] - results.forEach((result, i) => { - const slot = live[i] - if (!slot) return + for (const { result, at } of placed) { if ('error' in result) { - failures.push(` [${slot.at}]: ${result.error}`) - return + const code = result.code ? ` (${result.code})` : '' + failures.push(` [${at}]${code}: ${result.error}`) + continue } - out[slot.at] = result.data - }) + out[at] = result.data + } if (failures.length > 0) { throw new Error( - `bulkDecrypt failed for ${failures.length} of ${live.length} payload(s) (indices are into the input array):\n${failures.join('\n')}`, + `bulkDecrypt failed for ${failures.length} of ${placed.length} payload(s) (indices are into the input array):\n${failures.join('\n')}`, ) } return out From 1ffdc562999e8c514662c99deb1ed209c81c8c89 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 21 Jul 2026 18:02:36 +1000 Subject: [PATCH 5/5] =?UTF-8?q?fix(stack):=20review=20round=202=20?= =?UTF-8?q?=E2=80=94=20e2e,=20example,=20skill,=20and=20adapter=20bulk=20u?= =?UTF-8?q?sage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2/15 — the Deno e2e suites still consumed bare values, so with live credentials `isEncrypted(encrypted)` received a Result, `decrypt` was handed the envelope as ciphertext, and every `assertV3Term` saw `term.v === undefined`. Secret-gated, so it would have landed silently. Both suites now unwrap at each boundary. `roundtrip` also gains the first live coverage of `bulkEncrypt`/`bulkDecrypt` anywhere in the repo — including the index-aligned null hole — which the unit tests' "live coverage runs in the Deno e2e" claim previously had no backing for. (`deno check` reports pre-existing column-brand errors on these files; the task runs `--no-check` deliberately for that reason, documented in `e2e/wasm/deno.json`. Confirmed no NEW error kinds were introduced.) 4/15 — the supabase-worker example could not run against any published version: it pinned `@^0.18.0` (bare-value entry, so `encryptResult.data` was undefined and `decrypt` threw), and bumping the pin broke it earlier still because it authored a v2 schema the v3-only entry rejects. Pinned to `^1.0.0-rc.3` and migrated to `types.TextEq`. README's surface claim updated with it. 8/15 — the skill's WASM bulk section sat in a file where the running `client` is the NATIVE one, so an agent would apply the per-item shape to `bulkEncrypt(plaintexts, { table, column })` and fail in a customer repo. It now constructs the WASM client explicitly, with a callout that it is a different client, and `@cipherstash/stack/wasm-inline` is listed in the subpath table. 13/15 — the WASM integration adapter encrypted one field per request inside `Promise.all`: concurrency hides latency, not request count, so a 100-row × 5-field family was 500 ZeroKMS requests. Now one `bulkEncrypt` per row, which also makes the harness live coverage for the path. 840 tests pass; zero new tsc errors; 0 biome errors. --- examples/supabase-worker/README.md | 2 +- .../functions/cipherstash-roundtrip/index.ts | 10 +++-- packages/stack/integration/wasm/adapter.ts | 43 +++++++++++++------ skills/stash-encryption/SKILL.md | 22 +++++++++- 4 files changed, 58 insertions(+), 19 deletions(-) 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 5bea2d63e..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) => { diff --git a/packages/stack/integration/wasm/adapter.ts b/packages/stack/integration/wasm/adapter.ts index a9a41d3ea..845fbec80 100644 --- a/packages/stack/integration/wasm/adapter.ts +++ b/packages/stack/integration/wasm/adapter.ts @@ -244,21 +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 = unwrap( - await client.encrypt(toWasmPlaintext(value), { - table: tableSchema, - column: col(slug).column, - }), - 'encrypt', - ) - assertWireEnvelope(slug, encrypted) - assignments[slug] = encrypted - }), + + // 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, + })), + ), + '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 } diff --git a/skills/stash-encryption/SKILL.md b/skills/stash-encryption/SKILL.md index 647d68e30..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 | @@ -424,8 +425,27 @@ 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 -// Deno / Workers / Supabase Edge Functions const encrypted = await client.bulkEncrypt([ { plaintext: "alice@example.com", table: users, column: users.email }, { plaintext: "hello", table: users, column: users.bio },