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
5 changes: 3 additions & 2 deletions .changeset/wasm-encrypt-query.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<domain>` 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`).
47 changes: 47 additions & 0 deletions .changeset/wasm-inline-bulk-ops.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
---
'@cipherstash/stack': minor
'stash': patch
---

**Breaking (`@cipherstash/stack/wasm-inline`):** every fallible method now returns a `Result` — `{ data } | { failure }` — instead of throwing. And `bulkEncrypt` / `bulkDecrypt` are added, so a list of encrypted rows costs **one** ZeroKMS round trip instead of one per row.

### Result alignment

`encrypt`, `decrypt`, `encryptQuery` and `encryptQueryBulk` previously threw on failure, and returned bare values on success. They now return `{ data } | { failure }`, with `failure.type` drawn from `EncryptionErrorTypes` (`EncryptionError` for encrypt-side operations, `DecryptionError` for decrypt-side) and `failure.code` carrying the FFI error code where there is one.

```typescript
// before
const encrypted = await client.encrypt(plaintext, { table: users, column: users.email })

// after
const result = await client.encrypt(plaintext, { table: users, column: users.email })
if (result.failure) throw new Error(result.failure.message)
const encrypted = result.data
```

This is the contract the native entry has always honoured, and the one `AGENTS.md` states outright: *"Operations return `{ data }` or `{ failure }`. Preserve this shape and error `type` values in `EncryptionErrorTypes`."* The WASM entry never followed it. That was drift rather than a design decision — nothing about WASM prevents it (`@byteslice/result` is already bundled into `dist/wasm-inline.js`), and it meant edge code had to be written in a different shape from every other surface, with failures that were easy to miss.

Fixed now because it is a breaking change and 1.0.0 has not shipped: `@cipherstash/stack@latest` is still `0.19.0`, so this surface has only ever been published under the `rc` tag. After GA it would have had to wait for a major.

`isEncrypted` is unchanged — a pure predicate with nothing to fail at, exactly as on the native entry.

### Bulk operations

```typescript
// Write: several columns across many rows, one round trip
const encrypted = await client.bulkEncrypt([
{ plaintext: "alice@example.com", table: users, column: users.email },
{ plaintext: "hello", table: users, column: users.bio },
])

// Read: a whole page in one call
const emails = await client.bulkDecrypt(rows.map((r) => r.email))
```

The WASM entry previously exposed no bulk operations at all, so rendering an N-row list on Deno, Cloudflare Workers, or Supabase Edge Functions meant N sequential ZeroKMS calls. Combined with the WASM cold start, that made list endpoints impractical on the edge.

Both are index-aligned with their input, and `null` / `undefined` entries yield `null` at the same index without reaching ZeroKMS (an all-null batch makes no call at all). Because each entry names its own table and column, a single `bulkEncrypt` can cover several columns across many rows — which is what makes the saving real, since a single-column batch would still cost one round trip per column.

`bulkDecrypt` builds on the fallible FFI primitive, so when items fail the `failure.message` names **every** failing index with its reason, rather than surfacing the first and discarding the rest.

The model helpers (`encryptModel` / `decryptModel` and their bulk forms) remain Node-only: the WASM entry has no single-model operation to build them on, so those need their own port.
103 changes: 71 additions & 32 deletions e2e/wasm/query-types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>(
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<string, unknown>
Expand Down Expand Up @@ -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,
Expand All @@ -171,30 +203,36 @@ 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',
)

// 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,
Expand Down Expand Up @@ -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')
Expand Down
68 changes: 62 additions & 6 deletions e2e/wasm/roundtrip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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<string, unknown> | null
})
assertEquals(
termResult.failure,
undefined,
`encryptQuery() failed: ${termResult.failure?.message}`,
)
const term = termResult.data as Record<string, unknown> | null
assertExists(term, 'encryptQuery() returned null for live plaintext')
assertEquals(term.v, 3, 'query term is not EQL v3')
assertEquals(
Expand All @@ -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])
},
})
2 changes: 1 addition & 1 deletion examples/supabase-worker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -61,11 +65,28 @@ Deno.serve(async (_req: Request) => {
})

const plaintext = 'alice@example.com'
const encrypted = await client.encrypt(plaintext, {
// Every fallible method returns `{ data } | { failure }` — the same
// contract as the native entry (see AGENTS.md).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4/15 — as committed, this example can't run against any published version.

  • It still pins npm:@cipherstash/stack@^0.18.0/wasm-inline (line 21) — a version whose entry returns bare values. Deployed as-is: encrypt returns the payload, encryptResult.failure is undefined (check passes), encryptResult.data is undefineddecrypt(undefined) throws → 500 on every request.
  • Bump the pin and it breaks earlier: the example authors an EQL v2 schema (encryptedColumn('email').equality(), line 24), but the current entry exports only the v3 surface (export * from '@/eql/v3' — no encryptedColumn) and its factory rejects non-v3 tables outright.

examples/supabase-worker/README.md:62 still advertises encryptedColumn on this surface too. The example needs the version bump and the v3 schema migration together.

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(
{
Expand Down
Loading
Loading