diff --git a/.changeset/eql-v3-wasm-inline.md b/.changeset/eql-v3-wasm-inline.md new file mode 100644 index 000000000..6c30ba376 --- /dev/null +++ b/.changeset/eql-v3-wasm-inline.md @@ -0,0 +1,30 @@ +--- +'@cipherstash/stack': minor +--- + +`@cipherstash/stack/wasm-inline` is now EQL v3 (#614). + +The WASM entry (Deno / Bun / Cloudflare Workers / Supabase Edge) previously +created a client pinned to the FFI's EQL v2 wire format, so a v3 schema +(concrete `eql_v3_*` domains) failed every encrypt on the edge. It now targets +EQL v3 exclusively: + +- The factory constructs the WASM client with `eqlVersion: 3`, so v3 schemas + encrypt/decrypt correctly on the edge. +- The entry re-exports the **v3** authoring surface (`types`, `encryptedTable`, + the column classes, `buildEncryptConfig`, and the inference helpers) — the + same API as `@cipherstash/stack/eql/v3` — so an Edge Function authors and runs + v3 from one import: + + ```ts + import { Encryption, encryptedTable, types } from "@cipherstash/stack/wasm-inline" + + const patients = encryptedTable("patients", { email: types.TextSearch("email") }) + const client = await Encryption({ schemas: [patients], config }) + ``` + +The v2 schema builders (`encryptedColumn` / `encryptedField` / the v2 +`encryptedTable`) are no longer exported from this entry, and passing a v2 table +throws a clear error. The WASM path was never announced or documented for v2 and +had no known users; EQL v2 remains fully supported on the native +`@cipherstash/stack` entry. diff --git a/e2e/wasm/roundtrip.test.ts b/e2e/wasm/roundtrip.test.ts index 23e452907..03e172b90 100644 --- a/e2e/wasm/roundtrip.test.ts +++ b/e2e/wasm/roundtrip.test.ts @@ -1,25 +1,29 @@ /** * WASM smoke test for `@cipherstash/stack/wasm-inline`. * - * Runs under Deno against real CipherStash credentials. Proves three - * things together: + * Runs under Deno against real CipherStash credentials. Proves four things + * together: * 1. The stack `/wasm-inline` subpath resolves under Deno (no native * binding required). - * 2. The WASM protect-ffi client can complete an encrypt → decrypt - * round-trip against ZeroKMS / CTS. - * 3. No FFI permission was granted to the Deno process, so the WASM - * path is the *only* path that could have succeeded. + * 2. The entry is EQL v3: a schema authored with the `types` DSL round-trips. + * `TextSearch` maps to the concrete `eql_v3_text_search` domain, which a + * v2-mode client cannot resolve — so a successful round-trip proves the + * factory pinned `eqlVersion: 3` (#614). + * 3. The WASM protect-ffi client completes an encrypt → decrypt round-trip + * against ZeroKMS / CTS. + * 4. No FFI permission was granted to the Deno process, so the WASM path is + * the *only* path that could have succeeded. * - * Skipped when any of the four CS_* env vars is missing — matches the - * skip pattern in `e2e/tests/*.e2e.test.ts`. + * Skipped when any of the four CS_* env vars is missing — matches the skip + * pattern in `e2e/tests/*.e2e.test.ts`. */ import { assertEquals, assertExists } from 'jsr:@std/assert@^1.0.0' import { Encryption, - encryptedColumn, encryptedTable, isEncrypted, + types, } from '@cipherstash/stack/wasm-inline' // `CS_WORKSPACE_CRN` is the single source of truth for workspace @@ -46,7 +50,7 @@ function envOrSkip(): Record<(typeof REQUIRED_ENV)[number], string> | null { const env = envOrSkip() Deno.test({ - name: 'stack/wasm-inline: encrypt → decrypt round-trip via WASM', + name: 'stack/wasm-inline: EQL v3 encrypt → decrypt round-trip via WASM', ignore: env === null, permissions: { env: true, @@ -66,8 +70,11 @@ Deno.test({ 'Deno global missing (test framework misconfigured)', ) + // A v3 table authored with the `types` DSL re-exported from `/wasm-inline`. + // `TextSearch` maps to `eql_v3_text_search`, which only a v3-mode client + // can resolve — so this round-trip proves the factory selected eqlVersion 3. const users = encryptedTable('protect-ci', { - email: encryptedColumn('email'), + email: types.TextSearch('email'), }) const client = await Encryption({ @@ -82,7 +89,7 @@ Deno.test({ }, }) - const plaintext = `wasm-smoke-${crypto.randomUUID()}@example.com` + const plaintext = `wasm-v3-smoke-${crypto.randomUUID()}@example.com` const encrypted = await client.encrypt(plaintext, { column: users.email, diff --git a/packages/stack/__tests__/wasm-inline-new-client.test.ts b/packages/stack/__tests__/wasm-inline-new-client.test.ts index c7a1e5564..4d2140fa1 100644 --- a/packages/stack/__tests__/wasm-inline-new-client.test.ts +++ b/packages/stack/__tests__/wasm-inline-new-client.test.ts @@ -33,12 +33,13 @@ vi.mock('@cipherstash/protect-ffi/wasm-inline', () => ({ })) import { newClient as wasmNewClient } from '@cipherstash/protect-ffi/wasm-inline' -import { Encryption, encryptedColumn, encryptedTable } from '../src/wasm-inline' +import { Encryption, encryptedTable, types } from '../src/wasm-inline' const CRN = 'crn:ap-southeast-2.aws:test-workspace' +// The WASM entry is EQL v3 — author with the `types` DSL re-exported from it. const users = encryptedTable('users', { - email: encryptedColumn('email'), + email: types.TextSearch('email'), }) beforeEach(() => { @@ -83,9 +84,9 @@ describe('wasm-inline Encryption → newClient (protect-ffi 0.25 single-object f }) it('passes a cast_as-normalised encryptConfig (SDK "string" → EQL "text")', async () => { - // `encryptedColumn('email')` defaults to `cast_as: 'string'`; the WASM - // client only accepts EQL-native variants, so the factory must run the - // config through `normalizeCastAs` before handing it to `newClient`. + // `types.TextSearch('email')` carries `cast_as: 'string'`; the WASM client + // only accepts EQL-native variants, so the factory must run the config + // through `normalizeCastAs` before handing it to `newClient`. await Encryption({ schemas: [users], config: { diff --git a/packages/stack/__tests__/wasm-inline-v3.test.ts b/packages/stack/__tests__/wasm-inline-v3.test.ts new file mode 100644 index 000000000..67329827f --- /dev/null +++ b/packages/stack/__tests__/wasm-inline-v3.test.ts @@ -0,0 +1,102 @@ +/** + * `@cipherstash/stack/wasm-inline` is EQL v3 only (#614). This pins the three + * things that make that true and keep working: + * + * 1. The factory always constructs the client with `eqlVersion: 3` — a + * v2-mode client cannot resolve the concrete `eql_v3_*` domains and would + * fail every encrypt. Only the gated Deno e2e exercises the real wire + * format, so this asserts the plumbing the e2e's effect depends on. + * 2. It still normalises SDK-facing `cast_as` to the EQL-native variant the + * WASM client accepts (v3 columns carry `cast_as: 'string'`, not `'text'`). + * 3. It rejects a v2 table with a clear message rather than pinning v3 wire to + * a v2 schema and failing opaquely inside the FFI. + * + * It also pins that the v3 authoring surface is re-exported from this entry, so + * an edge consumer authors v3 schemas from a single import. + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@cipherstash/auth/wasm-inline', () => ({ + AccessKeyStrategy: { + create: vi.fn(() => ({ data: { __mock: 'access-key-strategy' } })), + }, + OidcFederationStrategy: class {}, +})) + +vi.mock('@cipherstash/protect-ffi/wasm-inline', () => ({ + newClient: vi.fn(async () => ({ __mock: 'wasm-client' })), + encrypt: vi.fn(), + decrypt: vi.fn(), + isEncrypted: vi.fn(), +})) + +import { newClient as wasmNewClient } from '@cipherstash/protect-ffi/wasm-inline' +// v2 builders come from the native schema entry — used only to prove the WASM +// factory REJECTS a v2 table. +import { + encryptedColumn, + encryptedTable as v2EncryptedTable, +} from '../src/schema' +import * as wasm from '../src/wasm-inline' +import { Encryption, encryptedTable, types } from '../src/wasm-inline' + +const config = { + workspaceCrn: 'crn:ap-southeast-2.aws:test-workspace', + accessKey: 'CSAK.test', + clientId: 'cid', + clientKey: 'ckey', +} as const + +// biome-ignore lint/suspicious/noExplicitAny: reading the recorded options object +const newClientOpts = () => vi.mocked(wasmNewClient).mock.calls[0][0] as any + +const users = encryptedTable('users', { + email: types.TextSearch('email'), +}) + +beforeEach(() => { + vi.clearAllMocks() +}) + +describe('wasm-inline is EQL v3 only (#614)', () => { + it('constructs the client with eqlVersion 3', async () => { + await Encryption({ schemas: [users], config }) + expect(newClientOpts().eqlVersion).toBe(3) + }) + + it('normalises cast_as on the v3 path (SDK "string" → EQL "text")', async () => { + await Encryption({ schemas: [users], config }) + // `types.TextSearch` carries `cast_as: 'string'`; the WASM client only + // accepts EQL-native variants, so the factory must map it to `'text'`. + expect(newClientOpts().encryptConfig.tables.users.email.cast_as).toBe( + 'text', + ) + }) + + it('rejects a v2 table with a clear error, before touching newClient', async () => { + const v2Users = v2EncryptedTable('users', { + email: encryptedColumn('email'), + }) + await expect( + // A JS caller can bypass the v3-only `schemas` type; the runtime guard + // must catch it. Cast to satisfy the compile-time type for this test. + Encryption({ + schemas: [v2Users as unknown as typeof users], + config, + }), + ).rejects.toThrow(/EQL v3 only/) + expect(vi.mocked(wasmNewClient)).not.toHaveBeenCalled() + }) + + it('re-exports the v3 authoring surface from this entry', () => { + expect(typeof wasm.encryptedTable).toBe('function') + expect(typeof wasm.types).toBe('object') + expect(typeof wasm.types.TextSearch).toBe('function') + expect(typeof wasm.buildEncryptConfig).toBe('function') + // The authored table is the v3 builder (carries the v3 `buildColumnKeyMap` + // marker) — not the v2 one. + expect(typeof users.buildColumnKeyMap).toBe('function') + expect(users.email.getEqlType()).toBe('public.eql_v3_text_search') + }) +}) diff --git a/packages/stack/src/wasm-inline.ts b/packages/stack/src/wasm-inline.ts index 48db3a32f..4f3b9eab9 100644 --- a/packages/stack/src/wasm-inline.ts +++ b/packages/stack/src/wasm-inline.ts @@ -10,13 +10,16 @@ * * Use this import path: `@cipherstash/stack/wasm-inline` * + * This entry is EQL v3: author schemas with the `types` DSL / `encryptedTable` + * re-exported here (the same authoring surface as `@cipherstash/stack/eql/v3`). + * * @example * ```ts * import { - * Encryption, encryptedTable, encryptedColumn, + * Encryption, encryptedTable, types, * } from "@cipherstash/stack/wasm-inline" * - * const users = encryptedTable("users", { email: encryptedColumn("email") }) + * const users = encryptedTable("users", { email: types.TextSearch("email") }) * * const client = await Encryption({ * schemas: [users], @@ -67,12 +70,10 @@ import { isEncrypted as wasmIsEncrypted, newClient as wasmNewClient, } from '@cipherstash/protect-ffi/wasm-inline' +import { type AnyV3Table, buildEncryptConfig } from '@/eql/v3' import { - buildEncryptConfig, type CastAs, type EncryptConfig, - type EncryptedTable, - type EncryptedTableColumn, encryptConfigSchema, toEqlCastAs, } from '@/schema' @@ -91,19 +92,13 @@ export { AccessKeyStrategy, OidcFederationStrategy, } from '@cipherstash/auth/wasm-inline' -export type { - EncryptedColumn, - EncryptedField, - EncryptedTable, - EncryptedTableColumn, - InferEncrypted, - InferPlaintext, -} from '@/schema' -export { - encryptedColumn, - encryptedField, - encryptedTable, -} from '@/schema' +// The WASM entry is EQL v3. Its authoring surface — `types`, `encryptedTable`, +// the column classes, `buildEncryptConfig`, and the inference helpers — is the +// v3 one, re-exported wholesale so an edge consumer authors v3 schemas from this +// single import. The v2 builders are intentionally NOT exported here: the WASM +// 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' export type { Encrypted } from '@/types' /** Re-exported convenience predicate — same as the raw protect-ffi one. */ @@ -229,10 +224,9 @@ export type WasmClientConfig = { export type WasmAuthStrategy = AccessKeyStrategy | OidcFederationStrategy export type WasmEncryptionConfig = { - schemas: [ - EncryptedTable, - ...EncryptedTable[], - ] + /** One or more EQL v3 tables, authored with `types` / `encryptedTable` from + * this entry. The WASM entry is EQL v3 only. */ + schemas: [AnyV3Table, ...AnyV3Table[]] config: WasmClientConfig } @@ -321,20 +315,38 @@ export async function Encryption( ) } + // The WASM entry is EQL v3 only. The types enforce v3 tables, but a plain-JS + // caller can bypass that — reject a non-v3 table (one lacking the v3 + // `buildColumnKeyMap` marker) with a clear message rather than pinning the + // client to v3 wire against a v2 schema and failing opaquely inside the FFI. + for (const table of schemas) { + const isV3 = + typeof (table as { buildColumnKeyMap?: unknown }).buildColumnKeyMap === + 'function' + if (!isV3) { + throw new Error( + '[encryption]: `@cipherstash/stack/wasm-inline` is EQL v3 only — author schemas with `types` / `encryptedTable` from this entry. (EQL v2 is available on the native `@cipherstash/stack` entry.)', + ) + } + } + const encryptConfig: EncryptConfig = encryptConfigSchema.parse( buildEncryptConfig(...schemas), ) const strategy = resolveStrategy(clientConfig) - // protect-ffi 0.25 takes a single options object with the strategy - // nested under `strategy` (0.24 passed the strategy as a separate - // first argument). + // protect-ffi 0.25 takes a single options object with the strategy nested + // under `strategy` (0.24 passed the strategy as a separate first argument). + // `eqlVersion: 3` pins the EQL v3 wire format — this entry is v3 only, so + // every encrypt/query emits v3 (a v2-mode client cannot resolve the concrete + // `eql_v3_*` domains and would fail every encrypt). const client = await wasmNewClient({ strategy, encryptConfig: normalizeCastAs(encryptConfig), clientId: clientConfig.clientId, clientKey: clientConfig.clientKey, + eqlVersion: 3, } as never) // `INTERNAL_CONSTRUCT` is module-scoped, so this factory is the only