-
Notifications
You must be signed in to change notification settings - Fork 3
feat(stack): add WASM-inline subpath + Deno verification #496
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
f712647
feat(stack): add WASM-inline subpath + Deno verification
coderdan 1dd6020
fix(stack): keep wizard/cli on auth 0.36.0, bundle zod for wasm-inline
coderdan 0829aa2
fix(stack/wasm-inline): normalize cast_as before passing to WASM newC…
coderdan 81687f2
chore(stack): bump @cipherstash/auth to 0.38.0
coderdan 75e6f6a
fix(wizard,cli): declare @cipherstash/auth native binaries as optiona…
coderdan 93d92a0
fix(stack/wasm-inline): address code-review findings
coderdan 4660b00
review: address CodeRabbit + Toby feedback on the WASM-inline PR
coderdan ae76993
review: address remaining WASM-inline review findings
coderdan c39e895
docs(stack/wasm-inline): switch documented default region to us-east-…
coderdan File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| { | ||
| "//1": "Deno smoke test for @cipherstash/stack/wasm-inline. Run `pnpm exec turbo run build --filter @cipherstash/stack` first so dist/ is fresh.", | ||
| "//2": "stack is imported via a file URL because the wasm-inline subpath isn't on a published version yet — once stack ships with /wasm-inline this can switch to a plain npm: specifier. protect-ffi and auth resolve via npm: against the workspace's installed versions (Deno's nodeModulesDir: auto lets it use what pnpm fetched).", | ||
| "//3": "No --allow-ffi grant. If protect-ffi ever silently fell back to a native binding under Deno, the test would fail on missing FFI permission — this is the WASM guarantee.", | ||
| "nodeModulesDir": "auto", | ||
| "tasks": { | ||
| "//task": "--no-check because TypeScript can't infer the column-on-table intersection types when stack is loaded via file URL (the dist .d.ts strips brand info). This test exists to verify RUNTIME WASM round-trips — type narrowing is covered by the package's own vitest suite.", | ||
| "test": "deno test --no-check --allow-env --allow-net --allow-read --allow-sys --no-prompt" | ||
| }, | ||
| "imports": { | ||
| "@cipherstash/stack/wasm-inline": "../../packages/stack/dist/wasm-inline.js", | ||
| "@cipherstash/protect-ffi/wasm-inline": "npm:@cipherstash/protect-ffi@0.24.0/wasm-inline", | ||
| "@cipherstash/auth/wasm-inline": "npm:@cipherstash/auth@0.38.0/wasm-inline" | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| /** | ||
| * WASM smoke test for `@cipherstash/stack/wasm-inline`. | ||
| * | ||
| * Runs under Deno against real CipherStash credentials. Proves three | ||
| * 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. | ||
| * | ||
| * 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, | ||
| } from '@cipherstash/stack/wasm-inline' | ||
|
|
||
| // `CS_WORKSPACE_CRN` is intentionally not in this list — the WASM | ||
| // client doesn't read it (workspace identity comes from the access-key | ||
| // token). A separate ticket tracks adding parity with the Node entry, | ||
| // at which point CRN should be added back here. | ||
| const REQUIRED_ENV = [ | ||
| 'CS_CLIENT_ACCESS_KEY', | ||
| 'CS_CLIENT_ID', | ||
| 'CS_CLIENT_KEY', | ||
| ] as const | ||
|
|
||
| function envOrSkip(): Record<(typeof REQUIRED_ENV)[number], string> | null { | ||
| const out: Record<string, string> = {} | ||
| for (const name of REQUIRED_ENV) { | ||
| const v = Deno.env.get(name) | ||
| if (!v) return null | ||
| out[name] = v | ||
| } | ||
| return out as Record<(typeof REQUIRED_ENV)[number], string> | ||
| } | ||
|
|
||
| const env = envOrSkip() | ||
|
|
||
| Deno.test({ | ||
| name: 'stack/wasm-inline: encrypt → decrypt round-trip via WASM', | ||
| ignore: env === null, | ||
| permissions: { | ||
| env: true, | ||
| net: true, | ||
| read: true, | ||
| sys: true, | ||
| // No FFI permission. If protect-ffi ever silently tries a native | ||
| // binding under Deno, the call will reject — proving WASM took the | ||
| // request. | ||
| ffi: false, | ||
| }, | ||
| async fn() { | ||
| // Sanity: we really are in Deno, and WASM is available. | ||
| assertExists(globalThis.WebAssembly, 'WebAssembly global missing') | ||
| assertExists(globalThis.Deno, 'Deno global missing (test framework misconfigured)') | ||
|
|
||
| const users = encryptedTable('protect-ci', { | ||
| email: encryptedColumn('email'), | ||
| }) | ||
|
|
||
| const client = await Encryption({ | ||
| schemas: [users], | ||
| config: { | ||
| // The WASM entry needs an explicit region for AccessKeyStrategy. | ||
| // This is the region of the CI test workspace the CS_* secrets | ||
| // target — not the documented default (see wasm-inline.ts). | ||
| region: 'ap-southeast-2.aws', | ||
| accessKey: env!.CS_CLIENT_ACCESS_KEY, | ||
| clientId: env!.CS_CLIENT_ID, | ||
| clientKey: env!.CS_CLIENT_KEY, | ||
| }, | ||
| }) | ||
|
|
||
| const plaintext = `wasm-smoke-${crypto.randomUUID()}@example.com` | ||
|
|
||
| const encrypted = await client.encrypt(plaintext, { | ||
| column: users.email, | ||
| table: users, | ||
| }) | ||
|
|
||
| assertEquals(isEncrypted(encrypted), true, 'encrypt() did not return a recognised EQL payload') | ||
|
|
||
| const decrypted = await client.decrypt(encrypted) | ||
| assertEquals(decrypted, plaintext, 'round-trip plaintext mismatch') | ||
| }, | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| # Copy to .env.local before running `supabase functions serve`. | ||
| # Get these from your CipherStash workspace dashboard. | ||
|
|
||
| CS_CLIENT_ACCESS_KEY= | ||
| CS_CLIENT_ID= | ||
| CS_CLIENT_KEY= | ||
| CS_REGION=us-east-1.aws | ||
|
|
||
| # `CS_WORKSPACE_CRN` is intentionally omitted: the WASM client derives | ||
| # workspace identity from the access-key token, not from the CRN. This | ||
| # is a known parity gap with the Node entry — tracked separately. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| # CipherStash Protect in a Supabase Edge Function | ||
|
|
||
| A minimal demo of using [`@cipherstash/stack`](https://www.npmjs.com/package/@cipherstash/stack) inside a Supabase Edge Function. The function encrypts a hardcoded plaintext value with CipherStash Protect, decrypts it back, and returns the round-trip result as JSON. | ||
|
|
||
| The function imports from the `@cipherstash/stack/wasm-inline` subpath — the WASM build of Protect, with the WASM module inlined into the JS bundle. No native bindings are loaded, so it works in Supabase Edge (Deno) and any other V8-only runtime (Cloudflare Workers, Bun, modern browsers). | ||
|
|
||
| ## Prerequisites | ||
|
|
||
| - Node ≥ 22 and pnpm 10 (matches the repo root); only needed for the local install step. | ||
| - A CipherStash workspace + service-to-service credentials (client ID, client key, access key) — see the [CipherStash docs](https://cipherstash.com/docs). | ||
| - [Supabase CLI](https://supabase.com/docs/guides/cli) installed locally. | ||
|
|
||
| ## Install | ||
|
|
||
| This example has no compile step — the Edge runtime resolves `npm:` specifiers at function start. The `pnpm install` below only wires up workspace metadata (no runtime dependencies): | ||
|
|
||
| ```sh | ||
| pnpm install | ||
| ``` | ||
|
|
||
| ## Run locally | ||
|
|
||
| ```sh | ||
| cp .env.example .env.local | ||
| # fill in CS_CLIENT_ID, CS_CLIENT_KEY, CS_CLIENT_ACCESS_KEY (and optionally CS_REGION) | ||
|
|
||
| supabase functions serve --env-file .env.local cipherstash-roundtrip | ||
| ``` | ||
|
|
||
| In another shell: | ||
|
|
||
| ```sh | ||
| curl -s http://localhost:54321/functions/v1/cipherstash-roundtrip | jq | ||
| ``` | ||
|
|
||
| Expected response: | ||
|
|
||
| ```json | ||
| { | ||
| "ok": true, | ||
| "plaintext": "alice@example.com", | ||
| "decrypted": "alice@example.com", | ||
| "isEncrypted": true, | ||
| "ciphertextIdentifier": { "t": "users", "c": "email" } | ||
| } | ||
| ``` | ||
|
|
||
| ## Deploy | ||
|
|
||
| ```sh | ||
| supabase functions deploy cipherstash-roundtrip | ||
| supabase secrets set --env-file .env.local | ||
| ``` | ||
|
|
||
| ## Native modules | ||
|
|
||
| There are none. The `@cipherstash/stack/wasm-inline` subpath embeds the protect-ffi WASM module as base64 inside its JS bundle and pulls `AccessKeyStrategy` from `@cipherstash/auth/wasm-inline` (also pure WASM). No `node-gyp`, no `.node` binaries, no platform-specific install scripts. | ||
|
|
||
| ## 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. | ||
| - A CipherStash service-to-service `AccessKeyStrategy` is the right credential shape for serverless / edge environments. | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| 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. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| { | ||
| "name": "@cipherstash/supabase-worker-example", | ||
| "private": true, | ||
| "version": "0.0.0", | ||
| "description": "CipherStash Protect inside a Supabase Edge Function, via @cipherstash/stack/wasm-inline.", | ||
| "type": "module", | ||
| "packageManager": "pnpm@10.33.2", | ||
| "engines": { | ||
| "node": ">=22" | ||
| }, | ||
| "scripts": { | ||
| "//": "Run via the Supabase CLI; the function imports stack/wasm-inline (and its transitive npm: deps) at runtime, so there is no build step here.", | ||
| "serve": "supabase functions serve --env-file .env.local cipherstash-roundtrip" | ||
| } | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
90 changes: 90 additions & 0 deletions
90
examples/supabase-worker/supabase/functions/cipherstash-roundtrip/index.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| /// <reference types="https://esm.sh/@supabase/functions-js/src/edge-runtime.d.ts" /> | ||
| /** | ||
| * Supabase Edge Function demo: encrypt a value with CipherStash Protect | ||
| * and decrypt it back, all via WASM (no native bindings). | ||
| * | ||
| * Imports `@cipherstash/stack/wasm-inline` — the WASM-inline subpath | ||
| * works in any V8-only runtime (Supabase Edge, Cloudflare Workers, Bun, | ||
| * Deno, modern browsers). | ||
| * | ||
| * Usage: | ||
| * cp ../../.env.example ../../.env.local # fill in your CS_* values | ||
| * supabase functions serve --env-file ../../.env.local cipherstash-roundtrip | ||
| * curl http://localhost:54321/functions/v1/cipherstash-roundtrip | ||
| */ | ||
|
|
||
| import { | ||
| Encryption, | ||
| encryptedColumn, | ||
| encryptedTable, | ||
| isEncrypted, | ||
| } from 'npm:@cipherstash/stack@^0.18.0/wasm-inline' | ||
|
|
||
| const users = encryptedTable('users', { | ||
| email: encryptedColumn('email').equality(), | ||
| }) | ||
|
|
||
| Deno.serve(async (_req: Request) => { | ||
| const accessKey = Deno.env.get('CS_CLIENT_ACCESS_KEY') | ||
| const clientId = Deno.env.get('CS_CLIENT_ID') | ||
| const clientKey = Deno.env.get('CS_CLIENT_KEY') | ||
| const region = Deno.env.get('CS_REGION') ?? 'us-east-1.aws' | ||
|
|
||
| const missing = Object.entries({ | ||
| CS_CLIENT_ACCESS_KEY: accessKey, | ||
| CS_CLIENT_ID: clientId, | ||
| CS_CLIENT_KEY: clientKey, | ||
| }) | ||
| .filter(([, v]) => !v) | ||
| .map(([k]) => k) | ||
|
|
||
| if (missing.length > 0) { | ||
| return Response.json( | ||
| { | ||
| error: `missing env vars: ${missing.join(', ')}`, | ||
| hint: 'Pass via `supabase functions serve --env-file .env.local`', | ||
| }, | ||
| { status: 400 }, | ||
| ) | ||
| } | ||
|
|
||
| try { | ||
| const client = await Encryption({ | ||
| schemas: [users], | ||
| config: { | ||
| region, | ||
| accessKey: accessKey!, | ||
| clientId: clientId!, | ||
| clientKey: clientKey!, | ||
| }, | ||
| }) | ||
|
|
||
| const plaintext = 'alice@example.com' | ||
| const encrypted = await client.encrypt(plaintext, { | ||
| column: users.email, | ||
| table: users, | ||
| }) | ||
| const decrypted = await client.decrypt(encrypted) | ||
|
|
||
| return Response.json( | ||
| { | ||
| ok: decrypted === plaintext, | ||
| plaintext, | ||
| decrypted, | ||
| isEncrypted: isEncrypted(encrypted), | ||
| ciphertextIdentifier: (encrypted as { i?: unknown }).i, | ||
| }, | ||
| { headers: { 'content-type': 'application/json' } }, | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| ) | ||
| } catch (e) { | ||
| // Debug-only response shape — a production handler should never | ||
| // surface error details to callers. The `stack` field is logged | ||
| // for the operator but not returned in the response body. | ||
| const err = e as { code?: string; message?: string; stack?: string } | ||
| console.error('cipherstash-roundtrip failed:', err.stack ?? err.message) | ||
| return Response.json( | ||
| { code: err.code, message: err.message }, | ||
| { status: 500 }, | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| ) | ||
| } | ||
| }) | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.