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
77 changes: 77 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,83 @@ jobs:
- name: Run E2E tests
run: pnpm exec turbo run test:e2e --filter @cipherstash/e2e

# Verifies @cipherstash/stack/wasm-inline works under Deno — i.e. the
# WASM build of protect-ffi 0.24+ and auth 0.37+ can round-trip an
# encryption against ZeroKMS / CTS in a runtime with no native
# bindings available. The deno.json deliberately omits --allow-ffi so
# a silent fallback to the NAPI module is impossible.
wasm-e2e-tests:
name: Run WASM E2E Tests (Deno)
runs-on: blacksmith-4vcpu-ubuntu-2404

permissions:
contents: read

# CS_WORKSPACE_CRN deliberately not exposed here: the WASM client
# doesn't read it. A separate ticket tracks adding parity with the
# Node entry, at which point the CRN should be re-added.
env:
CS_CLIENT_ID: ${{ secrets.CS_CLIENT_ID }}
CS_CLIENT_KEY: ${{ secrets.CS_CLIENT_KEY }}
CS_CLIENT_ACCESS_KEY: ${{ secrets.CS_CLIENT_ACCESS_KEY }}

steps:
- name: Checkout Repo
uses: actions/checkout@v6
Comment thread
coderabbitai[bot] marked this conversation as resolved.
with:
persist-credentials: false

- uses: pnpm/action-setup@v6.0.8
name: Install pnpm
with:
run_install: false

- name: Install Node.js
uses: actions/setup-node@v6
with:
node-version: 22
cache: 'pnpm'

# node-pty (a dev-dep of @cipherstash/cli used by its E2E PTY
# tests) falls back to `node-gyp rebuild` when no prebuild matches
# the runner, and pnpm/action-setup v6 no longer ships node-gyp on
# PATH. The WASM smoke test itself uses no native modules — this
# install only exists so the workspace-wide `pnpm install` step
# below doesn't fail.
- name: Install node-gyp
Comment thread
coderdan marked this conversation as resolved.
run: npm install -g node-gyp

- name: Install Deno
uses: denoland/setup-deno@v2
with:
deno-version: v2.x

- name: Install dependencies
run: pnpm install --frozen-lockfile

# The Deno smoke test imports the locally-built dist/wasm-inline.js
# via a file URL in e2e/wasm/deno.json — it needs a fresh build.
- name: Build stack
run: pnpm exec turbo run build --filter @cipherstash/stack

# roundtrip.test.ts skips itself (Deno.test.ignore) when any of
# the four CS_* env vars is missing — that's the right behaviour
# for local runs, but in CI a silent skip would mean a rotated /
# cleared secret hides a real WASM regression behind a green job.
# Fail loudly instead.
- name: Assert CS_* secrets are present
run: |
for v in CS_CLIENT_ID CS_CLIENT_KEY CS_CLIENT_ACCESS_KEY; do
if [ -z "${!v}" ]; then
echo "::error::Required secret $v is not set on this runner — the WASM smoke test would silently skip."
exit 1
fi
done

- name: Run Deno WASM smoke test
working-directory: e2e/wasm
run: deno task test

run-tests-bun:
name: Run Tests (Bun)
runs-on: blacksmith-4vcpu-ubuntu-2404
Expand Down
1 change: 1 addition & 0 deletions e2e/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"stash": "workspace:*",
"@cipherstash/drizzle": "workspace:*",
"@cipherstash/protect": "workspace:*",
"@cipherstash/stack": "workspace:*",
"@cipherstash/wizard": "workspace:*"
},
"devDependencies": {
Expand Down
15 changes: 15 additions & 0 deletions e2e/wasm/deno.json
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"
}
}
94 changes: 94 additions & 0 deletions e2e/wasm/roundtrip.test.ts
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')
},
})
11 changes: 11 additions & 0 deletions examples/supabase-worker/.env.example
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.
65 changes: 65 additions & 0 deletions examples/supabase-worker/README.md
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.
Comment thread
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.
15 changes: 15 additions & 0 deletions examples/supabase-worker/package.json
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"
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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' } },
Comment thread
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 },
Comment thread
coderabbitai[bot] marked this conversation as resolved.
)
}
})
Loading
Loading