Skip to content

Commit 1ffdc56

Browse files
committed
fix(stack): review round 2 — e2e, example, skill, and adapter bulk usage
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.
1 parent 0020b56 commit 1ffdc56

4 files changed

Lines changed: 58 additions & 19 deletions

File tree

examples/supabase-worker/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ There are none. The `@cipherstash/stack/wasm-inline` subpath embeds the protect-
5959
## What this verifies
6060

6161
- Protect's WASM build works inside Supabase Edge Functions.
62-
- The full `@cipherstash/stack/wasm-inline` developer surface (`Encryption`, `encryptedTable`, `encryptedColumn`, …) is usable from an Edge Function with no native dependencies.
62+
- 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.
6363
- A CipherStash service-to-service `AccessKeyStrategy` is the right credential shape for serverless / edge environments.
6464

6565
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.

examples/supabase-worker/supabase/functions/cipherstash-roundtrip/index.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,17 @@
1515

1616
import {
1717
Encryption,
18-
encryptedColumn,
1918
encryptedTable,
2019
isEncrypted,
21-
} from 'npm:@cipherstash/stack@^0.18.0/wasm-inline'
20+
types,
21+
} from 'npm:@cipherstash/stack@^1.0.0-rc.3/wasm-inline'
2222

23+
// EQL v3: the WASM entry exports the v3 authoring surface only (`types.*`),
24+
// not the v2 chainable builders — `encryptedColumn('email').equality()` is
25+
// rejected by the factory outright. `TextEq` is the v3 equality-capable text
26+
// domain, i.e. the direct replacement.
2327
const users = encryptedTable('users', {
24-
email: encryptedColumn('email').equality(),
28+
email: types.TextEq('email'),
2529
})
2630

2731
Deno.serve(async (_req: Request) => {

packages/stack/integration/wasm/adapter.ts

Lines changed: 29 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -244,21 +244,36 @@ export function makeWasmAdapter(): IntegrationAdapter {
244244

245245
async function encryptRow(row: PlainRow): Promise<Record<string, unknown>> {
246246
const assignments: Record<string, unknown> = { row_key: row.rowKey }
247-
// Field encrypts are independent ZeroKMS round-trips — run them
248-
// concurrently rather than paying fields × RTT per row.
249-
await Promise.all(
250-
Object.entries(row.values).map(async ([slug, value]) => {
251-
const encrypted = unwrap(
252-
await client.encrypt(toWasmPlaintext(value), {
253-
table: tableSchema,
254-
column: col(slug).column,
255-
}),
256-
'encrypt',
257-
)
258-
assertWireEnvelope(slug, encrypted)
259-
assignments[slug] = encrypted
260-
}),
247+
248+
// ONE ZeroKMS round trip for the whole row, not one per field. This was
249+
// `Promise.all` over per-field `client.encrypt` — concurrency hides
250+
// latency but not request count, so a 100-row × 5-field family was 500
251+
// requests. `bulkEncrypt`'s per-item `{ plaintext, table, column }`
252+
// routing exists precisely for this shape (#737).
253+
//
254+
// It also makes this harness the live coverage for the bulk path: the
255+
// unit tests mock the FFI, so without this nothing in the repo exercises
256+
// `bulkEncrypt` against real ZeroKMS.
257+
const entries = Object.entries(row.values)
258+
const encrypted = unwrap(
259+
await client.bulkEncrypt(
260+
entries.map(([slug, value]) => ({
261+
plaintext: toWasmPlaintext(value),
262+
table: tableSchema,
263+
column: col(slug).column,
264+
})),
265+
),
266+
'bulkEncrypt',
261267
)
268+
269+
entries.forEach(([slug], i) => {
270+
const payload = encrypted[i]
271+
// A null here means the fixture value was null/undefined — the column
272+
// is genuinely absent, not an encryption failure.
273+
if (payload === null || payload === undefined) return
274+
assertWireEnvelope(slug, payload)
275+
assignments[slug] = payload
276+
})
262277
return assignments
263278
}
264279

skills/stash-encryption/SKILL.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,7 @@ The SDK never logs plaintext data.
189189
| `@cipherstash/stack/types` | All TypeScript types |
190190
| `@cipherstash/stack-drizzle/v3` | Drizzle ORM integration for EQL v3 schemas (see the `stash-drizzle` skill) |
191191
| `@cipherstash/stack-supabase` | `encryptedSupabaseV3` wrapper for Supabase (see the `stash-supabase` skill) |
192+
| `@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. |
192193
| `@cipherstash/stack/dynamodb` | `encryptedDynamoDB`**still requires the legacy v2 schema surface**; see "Legacy: EQL v2" below |
193194
| `@cipherstash/stack/schema`, `@cipherstash/stack/client`, `@cipherstash/stack/encryption` | Legacy v2 schema builders and client surface — see "Legacy: EQL v2" below |
194195

@@ -424,8 +425,27 @@ for (const item of decrypted.data) {
424425

425426
**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.
426427

428+
> [!IMPORTANT]
429+
> 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:
430+
431+
```typescript
432+
// Deno / Workers / Supabase Edge Functions — note the import path
433+
import { Encryption, encryptedTable, types } from "@cipherstash/stack/wasm-inline"
434+
435+
const users = encryptedTable("users", { email: types.TextEq("email") })
436+
437+
const client = await Encryption({
438+
schemas: [users],
439+
config: {
440+
workspaceCrn: Deno.env.get("CS_WORKSPACE_CRN")!,
441+
accessKey: Deno.env.get("CS_CLIENT_ACCESS_KEY")!,
442+
clientId: Deno.env.get("CS_CLIENT_ID")!,
443+
clientKey: Deno.env.get("CS_CLIENT_KEY")!,
444+
},
445+
})
446+
```
447+
427448
```typescript
428-
// Deno / Workers / Supabase Edge Functions
429449
const encrypted = await client.bulkEncrypt([
430450
{ plaintext: "alice@example.com", table: users, column: users.email },
431451
{ plaintext: "hello", table: users, column: users.bio },

0 commit comments

Comments
 (0)