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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/eql-v3-wasm-inline.md
Original file line number Diff line number Diff line change
@@ -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.
31 changes: 19 additions & 12 deletions e2e/wasm/roundtrip.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand All @@ -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({
Expand All @@ -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,
Expand Down
11 changes: 6 additions & 5 deletions packages/stack/__tests__/wasm-inline-new-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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: {
Expand Down
102 changes: 102 additions & 0 deletions packages/stack/__tests__/wasm-inline-v3.test.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
62 changes: 37 additions & 25 deletions packages/stack/src/wasm-inline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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],
Expand Down Expand Up @@ -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'
Expand All @@ -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. */
Expand Down Expand Up @@ -229,10 +224,9 @@ export type WasmClientConfig = {
export type WasmAuthStrategy = AccessKeyStrategy | OidcFederationStrategy

export type WasmEncryptionConfig = {
schemas: [
EncryptedTable<EncryptedTableColumn>,
...EncryptedTable<EncryptedTableColumn>[],
]
/** 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
}

Expand Down Expand Up @@ -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
Expand Down
Loading