From f7126473ac3c8df571dda48973859fb6bd93a925 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Tue, 26 May 2026 22:26:08 +1000 Subject: [PATCH 1/7] feat(stack): add WASM-inline subpath + Deno verification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `@cipherstash/stack/wasm-inline` so Protect runs in Deno, Bun, Cloudflare Workers, Supabase Edge, and browsers — anywhere the native protect-ffi NAPI bindings don't work. Why now: protect-ffi 0.24.0 ships a WASM build; auth 0.37.0-alpha.8 ships the matching `AccessKeyStrategy` for the WASM path. The stack package is the natural place to expose this combination as a single import target. What changed: - Bump @cipherstash/protect-ffi 0.23.0 → 0.24.0 (Node API unchanged — strategy-based newClient only lives on /wasm-inline). Bump @cipherstash/auth catalog → 0.37.0-alpha.8 for the WASM strategy. Both packages added to pnpm-workspace.yaml's release-age exclude. - New packages/stack/src/wasm-inline.ts exporting `Encryption()` + `WasmEncryptionClient` (encrypt / decrypt round-trip), plus raw re-exports of `newClientRaw`/`encryptRaw`/`decryptRaw`/ `AccessKeyStrategy` for consumers who want the spike-style API. - ESM-only bundle (the WASM-inline runtime is ESM, CJS require() would ERR_REQUIRE_ESM); tsup drops dist/wasm-inline.cjs in onSuccess, package.json exports omits the require branch. - tsconfig customConditions: ["node"] so bundler resolution picks Node protect-ffi types for the main entries; /wasm-inline subpath has unconditional types so it's unaffected. Verification: - e2e/wasm/ Deno smoke test runs encrypt → decrypt round-trip against ZeroKMS/CTS with no --allow-ffi, proving the WASM path is the only path that could have completed. - New wasm-e2e-tests CI job (Deno 2.x on blacksmith runner) exercises this on every PR. - examples/supabase-worker/ demonstrates the same code path inside a Supabase Edge Function for documentation / runbook use. Draft: depends on @cipherstash/auth shipping a non-alpha release with /wasm-inline (planned for 0.38.0); bump catalog when that lands. --- .github/workflows/tests.yml | 50 ++++ e2e/package.json | 1 + e2e/wasm/deno.json | 14 ++ e2e/wasm/roundtrip.test.ts | 90 +++++++ examples/supabase-worker/.env.example | 10 + examples/supabase-worker/README.md | 50 ++++ examples/supabase-worker/package.json | 11 + .../functions/cipherstash-roundtrip/index.ts | 86 +++++++ packages/stack/package.json | 12 +- packages/stack/src/wasm-inline.ts | 225 +++++++++++++++++ packages/stack/tsconfig.json | 8 + packages/stack/tsup.config.ts | 64 +++-- pnpm-lock.yaml | 228 ++++++++---------- pnpm-workspace.yaml | 7 +- 14 files changed, 702 insertions(+), 154 deletions(-) create mode 100644 e2e/wasm/deno.json create mode 100644 e2e/wasm/roundtrip.test.ts create mode 100644 examples/supabase-worker/.env.example create mode 100644 examples/supabase-worker/README.md create mode 100644 examples/supabase-worker/package.json create mode 100644 examples/supabase-worker/supabase/functions/cipherstash-roundtrip/index.ts create mode 100644 packages/stack/src/wasm-inline.ts diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index d9c2cc26..2afad3da 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -156,6 +156,56 @@ 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 + + env: + CS_WORKSPACE_CRN: ${{ secrets.CS_WORKSPACE_CRN }} + 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 + + - 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' + + - name: Install node-gyp + 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 + + - 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 diff --git a/e2e/package.json b/e2e/package.json index 4fac7acd..b7c6a791 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -11,6 +11,7 @@ "stash": "workspace:*", "@cipherstash/drizzle": "workspace:*", "@cipherstash/protect": "workspace:*", + "@cipherstash/stack": "workspace:*", "@cipherstash/wizard": "workspace:*" }, "devDependencies": { diff --git a/e2e/wasm/deno.json b/e2e/wasm/deno.json new file mode 100644 index 00000000..88da63da --- /dev/null +++ b/e2e/wasm/deno.json @@ -0,0 +1,14 @@ +{ + "//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": { + "test": "deno test --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.37.0-alpha.8/wasm-inline" + } +} diff --git a/e2e/wasm/roundtrip.test.ts b/e2e/wasm/roundtrip.test.ts new file mode 100644 index 00000000..682df28d --- /dev/null +++ b/e2e/wasm/roundtrip.test.ts @@ -0,0 +1,90 @@ +/** + * 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' + +const REQUIRED_ENV = [ + 'CS_CLIENT_ACCESS_KEY', + 'CS_CLIENT_ID', + 'CS_CLIENT_KEY', + 'CS_WORKSPACE_CRN', +] as const + +function envOrSkip(): Record<(typeof REQUIRED_ENV)[number], string> | null { + const out: Record = {} + 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: { + // Default region in the stack is ap-southeast-2.aws; the WASM + // entry needs an explicit region for AccessKeyStrategy. + 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') + }, +}) diff --git a/examples/supabase-worker/.env.example b/examples/supabase-worker/.env.example new file mode 100644 index 00000000..843f04ca --- /dev/null +++ b/examples/supabase-worker/.env.example @@ -0,0 +1,10 @@ +# Copy to .env.local before running `supabase functions serve`. +# Get these from your CipherStash workspace dashboard. + +CS_WORKSPACE_CRN= +CS_CLIENT_ID= +CS_CLIENT_KEY= +CS_CLIENT_ACCESS_KEY= + +# Optional: defaults to ap-southeast-2.aws. +# CS_REGION=ap-southeast-2.aws diff --git a/examples/supabase-worker/README.md b/examples/supabase-worker/README.md new file mode 100644 index 00000000..5e497b08 --- /dev/null +++ b/examples/supabase-worker/README.md @@ -0,0 +1,50 @@ +# 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 + +- A CipherStash workspace + client credentials (workspace CRN, client ID/key, access key) — see the [CipherStash docs](https://cipherstash.com/docs). +- [Supabase CLI](https://supabase.com/docs/guides/cli) installed locally. + +## Run locally + +```sh +cp .env.example .env.local +# fill in CS_WORKSPACE_CRN, CS_CLIENT_ID, CS_CLIENT_KEY, CS_CLIENT_ACCESS_KEY + +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 +``` + +## What this proves + +- 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. diff --git a/examples/supabase-worker/package.json b/examples/supabase-worker/package.json new file mode 100644 index 00000000..26f65f2e --- /dev/null +++ b/examples/supabase-worker/package.json @@ -0,0 +1,11 @@ +{ + "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", + "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" + } +} diff --git a/examples/supabase-worker/supabase/functions/cipherstash-roundtrip/index.ts b/examples/supabase-worker/supabase/functions/cipherstash-roundtrip/index.ts new file mode 100644 index 00000000..eb080eff --- /dev/null +++ b/examples/supabase-worker/supabase/functions/cipherstash-roundtrip/index.ts @@ -0,0 +1,86 @@ +/// +/** + * 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') ?? 'ap-southeast-2.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' } }, + ) + } catch (e) { + const err = e as { code?: string; message?: string; stack?: string } + return Response.json( + { code: err.code, message: err.message, stack: err.stack }, + { status: 500 }, + ) + } +}) diff --git a/packages/stack/package.json b/packages/stack/package.json index fe5d15e4..27fb4feb 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -62,6 +62,9 @@ ], "supabase": [ "./dist/supabase/index.d.ts" + ], + "wasm-inline": [ + "./dist/wasm-inline.d.ts" ] } }, @@ -176,6 +179,12 @@ "default": "./dist/errors/index.cjs" } }, + "./wasm-inline": { + "import": { + "types": "./dist/wasm-inline.d.ts", + "default": "./dist/wasm-inline.js" + } + }, "./package.json": "./package.json" }, "scripts": { @@ -203,7 +212,8 @@ }, "dependencies": { "@byteslice/result": "0.2.0", - "@cipherstash/protect-ffi": "0.23.0", + "@cipherstash/auth": "catalog:repo", + "@cipherstash/protect-ffi": "0.24.0", "evlog": "1.11.0", "uuid": "14.0.0", "zod": "3.25.76" diff --git a/packages/stack/src/wasm-inline.ts b/packages/stack/src/wasm-inline.ts new file mode 100644 index 00000000..b7c6fe23 --- /dev/null +++ b/packages/stack/src/wasm-inline.ts @@ -0,0 +1,225 @@ +/** + * WASM-inline entry for `@cipherstash/stack` — for Deno, Bun, Cloudflare + * Workers, Supabase Edge Functions, and any runtime where the native + * `@cipherstash/protect-ffi` NAPI bindings are unavailable. + * + * Mirrors the protect-ffi / auth `/wasm-inline` pattern: the WASM module + * is inlined into the JS bundle as a base64 blob (no separate `.wasm` + * fetch / file read), so this entry works in environments that disallow + * `fs` or relative asset loading. + * + * Use this import path: `@cipherstash/stack/wasm-inline` + * + * @example + * ```ts + * import { + * Encryption, encryptedTable, encryptedColumn, + * } from "@cipherstash/stack/wasm-inline" + * + * const users = encryptedTable("users", { email: encryptedColumn("email") }) + * + * const client = await Encryption({ + * schemas: [users], + * config: { + * region: "ap-southeast-2.aws", + * accessKey: Deno.env.get("CS_CLIENT_ACCESS_KEY")!, + * clientId: Deno.env.get("CS_CLIENT_ID")!, + * clientKey: Deno.env.get("CS_CLIENT_KEY")!, + * }, + * }) + * + * const enc = await client.encrypt("alice@example.com", { + * column: users.email, + * table: users, + * }) + * const dec = await client.decrypt(enc) + * ``` + * + * For lower-level access, the raw `@cipherstash/protect-ffi/wasm-inline` + * functions and the `@cipherstash/auth/wasm-inline` strategy are + * re-exported below. + */ + +import { + decrypt as wasmDecrypt, + encrypt as wasmEncrypt, + isEncrypted as wasmIsEncrypted, + newClient as wasmNewClient, +} from '@cipherstash/protect-ffi/wasm-inline' +import { AccessKeyStrategy } from '@cipherstash/auth/wasm-inline' +import { + type EncryptConfig, + type EncryptedTable, + type EncryptedTableColumn, + buildEncryptConfig, + encryptConfigSchema, +} from '@/schema' +import { EncryptedColumn, EncryptedField } from '@/schema' +import type { Encrypted, EncryptOptions } from '@/types' + +// ----------------------------------------------------------------------- +// Re-exports — direct passthrough for consumers who want the raw API. +// ----------------------------------------------------------------------- + +export { + encryptedColumn, + encryptedField, + encryptedTable, +} from '@/schema' + +export type { + EncryptedColumn, + EncryptedField, + EncryptedTable, + EncryptedTableColumn, + InferEncrypted, + InferPlaintext, +} from '@/schema' + +export type { Encrypted } from '@/types' + +export { + decrypt as decryptRaw, + encrypt as encryptRaw, + isEncrypted, + newClient as newClientRaw, +} from '@cipherstash/protect-ffi/wasm-inline' + +export { AccessKeyStrategy } from '@cipherstash/auth/wasm-inline' + +// ----------------------------------------------------------------------- +// High-level `Encryption` factory + client. +// ----------------------------------------------------------------------- + +/** Default region used when `WasmClientConfig.region` is unset. */ +const DEFAULT_REGION = 'ap-southeast-2.aws' + +/** + * Config for {@link Encryption} on the WASM entry point. + * + * Unlike the Node entry, the WASM path requires an explicit auth + * strategy. For service-to-service / CI use, pass an `accessKey` and we + * construct an {@link AccessKeyStrategy} for you; alternatively, pass + * your own pre-built `strategy` to use OAuth-style flows or a custom + * token store. + */ +export type WasmClientConfig = { + /** CipherStash region, e.g. `"ap-southeast-2.aws"`. Defaults to ap-southeast-2.aws. */ + region?: string + /** Static access key. Mutually exclusive with `strategy`. */ + accessKey?: string + /** Pre-built auth strategy (e.g. `AccessKeyStrategy.create(...)` with a custom token store). */ + strategy?: { getToken(): Promise } + /** Workspace credentials. */ + clientId?: string + clientKey?: string +} + +export type WasmEncryptionConfig = { + schemas: [ + EncryptedTable, + ...EncryptedTable[], + ] + config: WasmClientConfig +} + +/** + * WASM encryption client. Returned by {@link Encryption}. + * + * Wraps an opaque {@link wasmNewClient} handle and exposes a minimal + * `encrypt` / `decrypt` surface. Larger surface (bulk, query, model + * helpers) lives on the Node entry — port lazily as Deno / edge + * consumers demand it. + */ +export class WasmEncryptionClient { + /** @internal */ + private readonly client: unknown + + constructor(client: unknown) { + this.client = client + } + + async encrypt( + plaintext: string | number | boolean | Record, + opts: EncryptOptions, + ): Promise { + const ffiOpts = { + plaintext, + table: opts.table.tableName, + column: getColumnName(opts.column), + } + return (await wasmEncrypt( + this.client as never, + ffiOpts as never, + )) as Encrypted + } + + async decrypt(encrypted: Encrypted): Promise> { + return (await wasmDecrypt(this.client as never, { + ciphertext: encrypted, + } as never)) as string | number | boolean | Record + } + + isEncrypted(value: unknown): boolean { + return wasmIsEncrypted(value as never) + } +} + +/** + * Initialize a WASM-backed encryption client. + * + * Mirrors the Node entry's {@link import('./encryption').Encryption} + * factory, but constructs the protect-ffi client via the WASM strategy + * API. Use from Deno / Edge / Workers / Bun. + */ +export async function Encryption( + config: WasmEncryptionConfig, +): Promise { + const { schemas, config: clientConfig } = config + + if (!schemas.length) { + throw new Error( + '[encryption]: At least one encryptedTable must be provided to initialize the encryption client', + ) + } + + const encryptConfig: EncryptConfig = encryptConfigSchema.parse( + buildEncryptConfig(...schemas), + ) + + const strategy = resolveStrategy(clientConfig) + + const client = await wasmNewClient(strategy as never, { + encryptConfig, + clientId: clientConfig.clientId, + clientKey: clientConfig.clientKey, + } as never) + + return new WasmEncryptionClient(client) +} + +function getColumnName( + col: EncryptOptions['column'], +): string { + if (col instanceof EncryptedColumn || col instanceof EncryptedField) { + return col.getName() + } + throw new Error( + '[encryption]: opts.column must be an EncryptedColumn or EncryptedField', + ) +} + +function resolveStrategy( + cfg: WasmClientConfig, +): { getToken(): Promise } { + if (cfg.strategy) return cfg.strategy + if (cfg.accessKey) { + return AccessKeyStrategy.create( + cfg.region ?? DEFAULT_REGION, + cfg.accessKey, + ) + } + throw new Error( + '[encryption]: WASM entry requires either `config.strategy` or `config.accessKey`', + ) +} diff --git a/packages/stack/tsconfig.json b/packages/stack/tsconfig.json index ac674b43..515c043f 100644 --- a/packages/stack/tsconfig.json +++ b/packages/stack/tsconfig.json @@ -15,6 +15,14 @@ "verbatimModuleSyntax": true, "noEmit": true, + // protect-ffi 0.24+ uses conditional exports — `node` picks the NAPI + // bindings, `default` picks the WASM bundle. Bundler resolution doesn't + // include `node` by default, so without this stack's main entry would + // resolve to the WASM types (which have a different `newClient` + // signature). The `/wasm-inline` subpath has unconditional types so it + // is unaffected. + "customConditions": ["node"], + // Best practices "strict": true, "skipLibCheck": true, diff --git a/packages/stack/tsup.config.ts b/packages/stack/tsup.config.ts index a3f888a3..81dfbaa1 100644 --- a/packages/stack/tsup.config.ts +++ b/packages/stack/tsup.config.ts @@ -1,27 +1,43 @@ import { defineConfig } from 'tsup' -export default defineConfig([ - { - entry: [ - 'src/index.ts', - 'src/client.ts', - 'src/types-public.ts', - 'src/identity/index.ts', - 'src/secrets/index.ts', - 'src/schema/index.ts', - 'src/drizzle/index.ts', - 'src/dynamodb/index.ts', - 'src/supabase/index.ts', - 'src/encryption/index.ts', - 'src/errors/index.ts', - ], - format: ['cjs', 'esm'], - sourcemap: true, - dts: true, - clean: true, - target: 'es2022', - tsconfig: './tsconfig.json', - external: ['drizzle-orm', '@supabase/supabase-js'], - noExternal: ['evlog', 'uuid'], +export default defineConfig({ + entry: [ + 'src/index.ts', + 'src/client.ts', + 'src/types-public.ts', + 'src/identity/index.ts', + 'src/secrets/index.ts', + 'src/schema/index.ts', + 'src/drizzle/index.ts', + 'src/dynamodb/index.ts', + 'src/supabase/index.ts', + 'src/encryption/index.ts', + 'src/errors/index.ts', + 'src/wasm-inline.ts', + ], + format: ['cjs', 'esm'], + sourcemap: true, + dts: true, + clean: true, + target: 'es2022', + tsconfig: './tsconfig.json', + external: ['drizzle-orm', '@supabase/supabase-js'], + noExternal: ['evlog', 'uuid'], + // Drop dist/wasm-inline.cjs after bundling — the protect-ffi + // wasm-inline runtime it transitively requires is ESM-only and + // crashes a Node CJS consumer with ERR_REQUIRE_ESM. The runtimes + // that need /wasm-inline (Deno, Bun, Workers, Supabase Edge, browsers) + // are ESM-first anyway, and package.json's exports map omits the + // `require` branch for ./wasm-inline so npm consumers never reach + // this path. The dual ESM + CJS d.ts pair stays so type-only CJS + // imports of stack's public surface still resolve. + onSuccess: async () => { + const { rm } = await import('node:fs/promises') + for (const file of [ + 'dist/wasm-inline.cjs', + 'dist/wasm-inline.cjs.map', + ]) { + await rm(file, { force: true }) + } }, -]) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c5a5306a..8ebc5985 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,8 +7,8 @@ settings: catalogs: repo: '@cipherstash/auth': - specifier: 0.36.0 - version: 0.36.0 + specifier: 0.37.0-alpha.8 + version: 0.37.0-alpha.8 tsup: specifier: 8.5.1 version: 8.5.1 @@ -60,6 +60,9 @@ importers: '@cipherstash/protect': specifier: workspace:* version: link:../packages/protect + '@cipherstash/stack': + specifier: workspace:* + version: link:../packages/stack '@cipherstash/wizard': specifier: workspace:* version: link:../packages/wizard @@ -192,7 +195,7 @@ importers: dependencies: '@cipherstash/auth': specifier: catalog:repo - version: 0.36.0 + version: 0.37.0-alpha.8 '@cipherstash/migrate': specifier: workspace:* version: link:../migrate @@ -319,6 +322,10 @@ importers: next: specifier: ^14 || ^15 version: 15.5.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + optionalDependencies: + '@rollup/rollup-linux-x64-gnu': + specifier: 4.60.4 + version: 4.60.4 devDependencies: '@clerk/nextjs': specifier: catalog:security @@ -335,10 +342,6 @@ importers: vitest: specifier: catalog:repo version: 3.2.4(@types/node@25.8.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.22.1)(yaml@2.9.0) - optionalDependencies: - '@rollup/rollup-linux-x64-gnu': - specifier: 4.60.4 - version: 4.60.4 packages/prisma-next: dependencies: @@ -436,6 +439,10 @@ importers: zod: specifier: ^3.25.76 version: 3.25.76 + optionalDependencies: + '@rollup/rollup-linux-x64-gnu': + specifier: 4.60.4 + version: 4.60.4 devDependencies: '@supabase/supabase-js': specifier: ^2.105.4 @@ -461,10 +468,6 @@ importers: vitest: specifier: catalog:repo version: 3.2.4(@types/node@25.8.0)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.22.1)(yaml@2.9.0) - optionalDependencies: - '@rollup/rollup-linux-x64-gnu': - specifier: 4.60.4 - version: 4.60.4 packages/protect-dynamodb: dependencies: @@ -512,9 +515,12 @@ importers: '@byteslice/result': specifier: 0.2.0 version: 0.2.0 + '@cipherstash/auth': + specifier: catalog:repo + version: 0.37.0-alpha.8 '@cipherstash/protect-ffi': - specifier: 0.23.0 - version: 0.23.0 + specifier: 0.24.0 + version: 0.24.0 evlog: specifier: 1.11.0 version: 1.11.0(next@15.5.10(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3) @@ -569,7 +575,7 @@ importers: version: 0.3.143(@anthropic-ai/sdk@0.81.0(zod@3.25.76))(@modelcontextprotocol/sdk@1.29.0(zod@3.25.76))(zod@3.25.76) '@cipherstash/auth': specifier: catalog:repo - version: 0.36.0 + version: 0.37.0-alpha.8 '@clack/prompts': specifier: 1.4.0 version: 1.4.0 @@ -621,25 +627,21 @@ packages: resolution: {integrity: sha512-9UeV1W2vjOVwJSJrq9aw3UeMo82Ir59FfJ5mchh7OXZEaevkANvHYn25bTCnIpqfqOx7qFEosJW2ELIoV1nprg==} cpu: [arm64] os: [linux] - libc: [musl] '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.143': resolution: {integrity: sha512-/9oP/FCewrPnwVN+QUS5rlO3kMa07w+hOrpWrz24aEpBYhcHzr0zoNMBriPDAkTr3ao/z1k40UZ2dHmgsSODzA==} cpu: [arm64] os: [linux] - libc: [glibc] '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.143': resolution: {integrity: sha512-rr4334GOLl9caYDeyWsbwMaVJCiNvKHE9nLdey8opIkq7/FHHu712U6tDk0tcoCdsGU/S3/BBaZParOgF+s5qw==} cpu: [x64] os: [linux] - libc: [musl] '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.143': resolution: {integrity: sha512-kwqnbHo4Zj6TzO1V/83uLhsTt0xBp/BN5V/aHIX+khM4UuNO6NOKNaZvr8Int3sF0ARF95Hjr4l/hMKxry6DhQ==} cpu: [x64] os: [linux] - libc: [glibc] '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.143': resolution: {integrity: sha512-q5UaLZ9ABbqQN8UXpqHUqjW6akI1zMrV5Jvtq0yueKP4nIRbBBZBQ80M4bpdrc0+SiRmjVRV3p8lsCCAd8azgg==} @@ -704,28 +706,24 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [musl] '@biomejs/cli-linux-arm64@2.4.15': resolution: {integrity: sha512-owaAMZD/T4LrD0ELNCk0Km3qrRHuM0X6EAyVE1FSqGY0rbLoiDLrO4Us2tllm6cAeB2Ioa9C2C08NZPdr8+0Ug==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] - libc: [glibc] '@biomejs/cli-linux-x64-musl@2.4.15': resolution: {integrity: sha512-CNq/9W38SYSH023lfcQ4KKU8K0YX8T//FZUhcgtMMRABDojx5XsMV7jlweAvGSl389wJQB29Qo6Zb/a+jdvt+w==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [musl] '@biomejs/cli-linux-x64@2.4.15': resolution: {integrity: sha512-0jj7THz12GbUOLmMibktK6DZjqz2zV64KFxyBtcFTKPiiOIY0a7vns1elpO1dERvxpsZ5ik0oFfz0oGwFde1+g==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] - libc: [glibc] '@biomejs/cli-win32-arm64@2.4.15': resolution: {integrity: sha512-ouhkYdlhp/1GghEJPdWwD/Vi3gQ1nFxuSpMolWsbq3Lsq3QUR4jl6UdhhscdCugKU5vOEuMiJhvKj66O0OCq+w==} @@ -797,75 +795,95 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} - '@cipherstash/auth-darwin-arm64@0.36.0': - resolution: {integrity: sha512-KnRBW90HHJdxtMTjts1OxnlKdcuKdkWfdd+XwZXWCGlzlIxjq2QGMoVlvGzk7kMoZapLomRMn+f4RBzM1dwsWQ==} - cpu: [arm64] - os: [darwin] - - '@cipherstash/auth-darwin-x64@0.36.0': - resolution: {integrity: sha512-bCAdJSwAz79mFr36GeGn4IddDCRQokFcqV1qzmTsgzjt8Q3B+vmglY7uoGWQUJWTyfDrflEH1P+kivGeKYehyQ==} - cpu: [x64] - os: [darwin] - - '@cipherstash/auth-linux-arm64-gnu@0.36.0': - resolution: {integrity: sha512-PDpm1EHC1XzVtEDGzcyr0UXNca8IFkfPusqqVJ5CSpzCtlYipIClYui197zQ4NGMHIAQD168IEFOK2TROyb4Tw==} - cpu: [arm64] - os: [linux] - libc: [glibc] - - '@cipherstash/auth-linux-x64-gnu@0.36.0': - resolution: {integrity: sha512-Gm20ezVlGmNrkMH4s+I+JT13hDRD6vEX3fu3VDQQhWUiYCdgbdVsNJQgOr6QMY1cJkkmGyNlQKfiCPn4zlqtMg==} - cpu: [x64] - os: [linux] - libc: [glibc] - - '@cipherstash/auth-linux-x64-musl@0.36.0': - resolution: {integrity: sha512-RUQeLc19JnURAMEoemP3+2DyptK+pqNFrVGgiKKOMVql0SZDVMlN2IyFrTKJ2emv1yuf4Gr1+E4jIdKPR0Oh+g==} - cpu: [x64] - os: [linux] - libc: [musl] - - '@cipherstash/auth-win32-x64-msvc@0.36.0': - resolution: {integrity: sha512-1mQ8E6YFy7frHkvrDmSixpy47EakGPRh4qgoXPgk9lqZnlbMECYZhoKWQEs5wa3tLGgiX5G6jKC3NQZsOOqEfQ==} - cpu: [x64] - os: [win32] - - '@cipherstash/auth@0.36.0': - resolution: {integrity: sha512-Pb+KdUre8d/lgtsiYpVvCI7zsrZIUElpb0gdU71HMYvNW5sopj3xQv0TLOhxrVLclmOoii/nYLJm+GH3eNYdpg==} + '@cipherstash/auth@0.37.0-alpha.8': + resolution: {integrity: sha512-Bwl8LdYEqQUGcfVfhyemOZa6ZrWEvA3av3c9Vg1Y1eR6OkFLgf4QEI4lHGIiNlU34BmZKShpGwrTWUN+Kct6vg==} + peerDependencies: + '@cipherstash/auth-darwin-arm64': 0.37.0-alpha.8 + '@cipherstash/auth-darwin-x64': 0.37.0-alpha.8 + '@cipherstash/auth-linux-arm64-gnu': 0.37.0-alpha.8 + '@cipherstash/auth-linux-x64-gnu': 0.37.0-alpha.8 + '@cipherstash/auth-linux-x64-musl': 0.37.0-alpha.8 + '@cipherstash/auth-win32-x64-msvc': 0.37.0-alpha.8 + peerDependenciesMeta: + '@cipherstash/auth-darwin-arm64': + optional: true + '@cipherstash/auth-darwin-x64': + optional: true + '@cipherstash/auth-linux-arm64-gnu': + optional: true + '@cipherstash/auth-linux-x64-gnu': + optional: true + '@cipherstash/auth-linux-x64-musl': + optional: true + '@cipherstash/auth-win32-x64-msvc': + optional: true '@cipherstash/protect-ffi-darwin-arm64@0.23.0': resolution: {integrity: sha512-DhkKC+trOfk3RLDvPXqGsrpWdVnLAMEVLUI59OuR9tdTcJeiABtbQx8VaXdbzvNxnbkoDnOqbFRE5D11Z7nerQ==} cpu: [arm64] os: [darwin] + '@cipherstash/protect-ffi-darwin-arm64@0.24.0': + resolution: {integrity: sha512-86OyhIciDfLtiJN3+L7jdaXBf+V2XvM3NKYzGWoal2wv3mJKuUbdyTo27kmVd14+NeEoTmx6xYYtLevbCUTJGg==} + cpu: [arm64] + os: [darwin] + '@cipherstash/protect-ffi-darwin-x64@0.23.0': resolution: {integrity: sha512-YuEn2RDHOaj9s8qDIX9cpQuBmsN2SZp/RjiNX72LxhV7JEDJuLSt0ySrl+k6MHoLiZotjkp7I1u6tq3vuLCC0Q==} cpu: [x64] os: [darwin] + '@cipherstash/protect-ffi-darwin-x64@0.24.0': + resolution: {integrity: sha512-lDXBCUeGKO2bDTqIIqGhRasip5LBC0lQIn2QepwEuByugkXMGtmYMQmmwvMUhXKkp2keG7HAkeTniFSfcj6pYg==} + cpu: [x64] + os: [darwin] + '@cipherstash/protect-ffi-linux-arm64-gnu@0.23.0': resolution: {integrity: sha512-I1kID2JqWnJUd0VHzNQo4gxeOAhEgzeXg3Fn0iDHnGKy+HDHd7+t/qEei9YLrV0wAXDnDFRhXXWRRVs8CxDzZA==} cpu: [arm64] os: [linux] + '@cipherstash/protect-ffi-linux-arm64-gnu@0.24.0': + resolution: {integrity: sha512-i6ufKc4vcVpMBuR9sdW09acULEg5FaTvpvbN5MCQ6XEF0iMkXM4/D7W4VnE1jHIJf1qgU2jE+T5oIlqp8Hnj3w==} + cpu: [arm64] + os: [linux] + '@cipherstash/protect-ffi-linux-x64-gnu@0.23.0': resolution: {integrity: sha512-n4aCDK0os4iY1BQIHVVUBgt8WnfIb8R3gLXTRrTkMVug0dcoQ0ZZaL5ltIUgFGJG4bvfW8+7zWLRZ51CZkqKsQ==} cpu: [x64] os: [linux] + '@cipherstash/protect-ffi-linux-x64-gnu@0.24.0': + resolution: {integrity: sha512-zTa0IosxIo1qZInWSwCTns+TRjzOeuBGnMJg3OyK/q90I+RybaRROhMpbeWUV5QY7pLNwt6uICoghFJqI/Vh3Q==} + cpu: [x64] + os: [linux] + '@cipherstash/protect-ffi-linux-x64-musl@0.23.0': resolution: {integrity: sha512-62WG6ayFJ/1+M7W/AWGEDskLo6aHtr8PFoHoXkSTdhWf29RPb4+yU1pPNYVCitVWB1sdGs+lXSO6MFD4N6IIXw==} cpu: [x64] os: [linux] + '@cipherstash/protect-ffi-linux-x64-musl@0.24.0': + resolution: {integrity: sha512-OEs12fukiVgOxr/1Hn/23x4Tahl1gQG4pac9Uzr7Zz/O45365dK/4gazdmBpSsQYelESBiIEILGtw01QsmzLtw==} + cpu: [x64] + os: [linux] + '@cipherstash/protect-ffi-win32-x64-msvc@0.23.0': resolution: {integrity: sha512-z+jErHcPw1RwiwhSqqx/QzKqkk06gulh6YJl4TlSBPlJPjhR30TEcxQpQ2zf7kuv86JqsBRHv8UazLNePSiEww==} cpu: [x64] os: [win32] + '@cipherstash/protect-ffi-win32-x64-msvc@0.24.0': + resolution: {integrity: sha512-g4JVGK2DYPc5zdzgBtYYgH5H8zQoV/DEfILKXLPlFfQFnGoDR8CQktXAdivgVwKWIXvOrmci+uuG6n8+tPZxSw==} + cpu: [x64] + os: [win32] + '@cipherstash/protect-ffi@0.23.0': resolution: {integrity: sha512-Ca8MKLrrumC561VoPDOhuUZcF8C8YenqO1Ig9hSJSRUB+jFeIJXeyn7glExsvKYWtxOx/pRub9FV8A0RyuPHMg==} + '@cipherstash/protect-ffi@0.24.0': + resolution: {integrity: sha512-duYmf4kZsSJvdAjKuacXSO9qF9PFqaV9TU+2Yr0uy5FHdOw3G9dUCasZCnnnrDfnu92gJPxrsvZW6DMm0dbx+w==} + '@clack/core@1.3.0': resolution: {integrity: sha512-xJPHpAmEQUBrXSLx0gF+q5K/IyihXpsHZcha+jB+tyahsKRK3Dxo4D0coZDewHo12NhiuzC3dTtMPbm53GEAAA==} engines: {node: '>= 20.12.0'} @@ -1551,105 +1569,89 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] - libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] - libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] - libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] - libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] - libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] - libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -1741,28 +1743,24 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [glibc] '@next/swc-linux-arm64-musl@15.5.7': resolution: {integrity: sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - libc: [musl] '@next/swc-linux-x64-gnu@15.5.7': resolution: {integrity: sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [glibc] '@next/swc-linux-x64-musl@15.5.7': resolution: {integrity: sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - libc: [musl] '@next/swc-win32-arm64-msvc@15.5.7': resolution: {integrity: sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==} @@ -1969,157 +1967,131 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-gnueabihf@4.60.4': resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} cpu: [arm] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm-musleabihf@4.60.4': resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} cpu: [arm] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-gnu@4.60.4': resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} cpu: [arm64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-arm64-musl@4.60.4': resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} cpu: [arm64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-gnu@4.60.4': resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} cpu: [loong64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-loong64-musl@4.60.4': resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} cpu: [loong64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-gnu@4.60.4': resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} cpu: [ppc64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-ppc64-musl@4.60.4': resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} cpu: [ppc64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-gnu@4.60.4': resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} cpu: [riscv64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-riscv64-musl@4.60.4': resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} cpu: [riscv64] os: [linux] - libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-s390x-gnu@4.60.4': resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} cpu: [s390x] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.60.4': resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} cpu: [x64] os: [linux] - libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-linux-x64-musl@4.60.4': resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} cpu: [x64] os: [linux] - libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -3107,28 +3079,24 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [glibc] lightningcss-linux-arm64-musl@1.30.2: resolution: {integrity: sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] - libc: [musl] lightningcss-linux-x64-gnu@1.30.2: resolution: {integrity: sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [glibc] lightningcss-linux-x64-musl@1.30.2: resolution: {integrity: sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] - libc: [musl] lightningcss-win32-arm64-msvc@1.30.2: resolution: {integrity: sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==} @@ -4270,51 +4238,44 @@ snapshots: human-id: 4.1.3 prettier: 2.8.8 - '@cipherstash/auth-darwin-arm64@0.36.0': - optional: true + '@cipherstash/auth@0.37.0-alpha.8': {} - '@cipherstash/auth-darwin-x64@0.36.0': + '@cipherstash/protect-ffi-darwin-arm64@0.23.0': optional: true - '@cipherstash/auth-linux-arm64-gnu@0.36.0': + '@cipherstash/protect-ffi-darwin-arm64@0.24.0': optional: true - '@cipherstash/auth-linux-x64-gnu@0.36.0': + '@cipherstash/protect-ffi-darwin-x64@0.23.0': optional: true - '@cipherstash/auth-linux-x64-musl@0.36.0': + '@cipherstash/protect-ffi-darwin-x64@0.24.0': optional: true - '@cipherstash/auth-win32-x64-msvc@0.36.0': + '@cipherstash/protect-ffi-linux-arm64-gnu@0.23.0': optional: true - '@cipherstash/auth@0.36.0': - optionalDependencies: - '@cipherstash/auth-darwin-arm64': 0.36.0 - '@cipherstash/auth-darwin-x64': 0.36.0 - '@cipherstash/auth-linux-arm64-gnu': 0.36.0 - '@cipherstash/auth-linux-x64-gnu': 0.36.0 - '@cipherstash/auth-linux-x64-musl': 0.36.0 - '@cipherstash/auth-win32-x64-msvc': 0.36.0 - - '@cipherstash/protect-ffi-darwin-arm64@0.23.0': + '@cipherstash/protect-ffi-linux-arm64-gnu@0.24.0': optional: true - '@cipherstash/protect-ffi-darwin-x64@0.23.0': + '@cipherstash/protect-ffi-linux-x64-gnu@0.23.0': optional: true - '@cipherstash/protect-ffi-linux-arm64-gnu@0.23.0': + '@cipherstash/protect-ffi-linux-x64-gnu@0.24.0': optional: true - '@cipherstash/protect-ffi-linux-x64-gnu@0.23.0': + '@cipherstash/protect-ffi-linux-x64-musl@0.23.0': optional: true - '@cipherstash/protect-ffi-linux-x64-musl@0.23.0': + '@cipherstash/protect-ffi-linux-x64-musl@0.24.0': optional: true '@cipherstash/protect-ffi-win32-x64-msvc@0.23.0': optional: true + '@cipherstash/protect-ffi-win32-x64-msvc@0.24.0': + optional: true + '@cipherstash/protect-ffi@0.23.0': dependencies: '@neon-rs/load': 0.1.82 @@ -4326,6 +4287,17 @@ snapshots: '@cipherstash/protect-ffi-linux-x64-musl': 0.23.0 '@cipherstash/protect-ffi-win32-x64-msvc': 0.23.0 + '@cipherstash/protect-ffi@0.24.0': + dependencies: + '@neon-rs/load': 0.1.82 + optionalDependencies: + '@cipherstash/protect-ffi-darwin-arm64': 0.24.0 + '@cipherstash/protect-ffi-darwin-x64': 0.24.0 + '@cipherstash/protect-ffi-linux-arm64-gnu': 0.24.0 + '@cipherstash/protect-ffi-linux-x64-gnu': 0.24.0 + '@cipherstash/protect-ffi-linux-x64-musl': 0.24.0 + '@cipherstash/protect-ffi-win32-x64-msvc': 0.24.0 + '@clack/core@1.3.0': dependencies: fast-wrap-ansi: 0.2.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 92c6ea3e..ccb6262a 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,7 +5,7 @@ packages: catalogs: repo: - '@cipherstash/auth': 0.36.0 + '@cipherstash/auth': 0.37.0-alpha.8 tsup: 8.5.1 tsx: 4.22.1 typescript: 5.9.3 @@ -28,7 +28,12 @@ blockExoticSubdeps: true # - @cipherstash/protect-ffi* CipherStash-published native FFI and its # per-platform binary packages (darwin-arm64, # linux-x64-gnu, etc.); bumped in lockstep with stack +# - @cipherstash/auth* CipherStash-published auth strategies (NAPI + +# WASM-inline variant); also tracked in lockstep +# with protect-ffi for the WASM path. minimumReleaseAgeExclude: - '@prisma-next/*' - '@cipherstash/protect-ffi' - '@cipherstash/protect-ffi-*' + - '@cipherstash/auth' + - '@cipherstash/auth-*' From 1dd6020f85d586c81c083c2df6908faaa8f49e16 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 27 May 2026 00:26:46 +1000 Subject: [PATCH 2/7] fix(stack): keep wizard/cli on auth 0.36.0, bundle zod for wasm-inline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI on the first push exposed two issues: 1. `Failed to load native binding for linux-x64` in @cipherstash/wizard and the e2e/ suite. Cause: auth 0.37.0-alpha migrated the per-platform napi binaries from `optionalDependencies` to `peerDependencies` (optional), and pnpm does NOT install platform-matched optional peer deps. Revert the catalog to 0.36.0 (which still uses optionalDependencies and works for NAPI consumers) and pin @cipherstash/stack to 0.37.0-alpha.8 directly — stack only uses the `/wasm-inline` subpath, never touches the NAPI bindings. 2. `Import "zod" not a dependency` when Deno loaded dist/wasm-inline.js. Cause: tsup left zod as an external import, and Deno's nodeModulesDir: auto doesn't resolve bare specifiers without an explicit map. Bundle zod and @byteslice/result via noExternal — both are small and dependency-free. Also add --no-check to the Deno test task: stack's dist .d.ts strips the encryptedTable column-intersection brand when loaded via file URL, so TypeScript can't see `users.email`. The runtime round-trip is the only thing this smoke test asserts; column-on-table typing is covered by the package's own vitest suite. --- e2e/wasm/deno.json | 3 +- packages/stack/package.json | 2 +- packages/stack/tsup.config.ts | 7 +++- pnpm-lock.yaml | 72 ++++++++++++++++++++++++++++++++--- pnpm-workspace.yaml | 11 +++++- 5 files changed, 86 insertions(+), 9 deletions(-) diff --git a/e2e/wasm/deno.json b/e2e/wasm/deno.json index 88da63da..24867163 100644 --- a/e2e/wasm/deno.json +++ b/e2e/wasm/deno.json @@ -4,7 +4,8 @@ "//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": { - "test": "deno test --allow-env --allow-net --allow-read --allow-sys --no-prompt" + "//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", diff --git a/packages/stack/package.json b/packages/stack/package.json index 27fb4feb..dfadd38d 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -212,7 +212,7 @@ }, "dependencies": { "@byteslice/result": "0.2.0", - "@cipherstash/auth": "catalog:repo", + "@cipherstash/auth": "0.37.0-alpha.8", "@cipherstash/protect-ffi": "0.24.0", "evlog": "1.11.0", "uuid": "14.0.0", diff --git a/packages/stack/tsup.config.ts b/packages/stack/tsup.config.ts index 81dfbaa1..c26682d9 100644 --- a/packages/stack/tsup.config.ts +++ b/packages/stack/tsup.config.ts @@ -22,7 +22,12 @@ export default defineConfig({ target: 'es2022', tsconfig: './tsconfig.json', external: ['drizzle-orm', '@supabase/supabase-js'], - noExternal: ['evlog', 'uuid'], + // zod + @byteslice/result are bundled so dist/wasm-inline.js carries no + // bare-specifier transitive imports — important for Deno / Edge / + // browser consumers whose runtime won't resolve npm names without an + // explicit import map. Both are small (zod ~50 KB, result ~3 KB) and + // dependency-free, so bundling them into the Node entries too is fine. + noExternal: ['evlog', 'uuid', 'zod', '@byteslice/result'], // Drop dist/wasm-inline.cjs after bundling — the protect-ffi // wasm-inline runtime it transitively requires is ESM-only and // crashes a Node CJS consumer with ERR_REQUIRE_ESM. The runtimes diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8ebc5985..1bc660a6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,8 +7,8 @@ settings: catalogs: repo: '@cipherstash/auth': - specifier: 0.37.0-alpha.8 - version: 0.37.0-alpha.8 + specifier: 0.36.0 + version: 0.36.0 tsup: specifier: 8.5.1 version: 8.5.1 @@ -163,6 +163,8 @@ importers: specifier: catalog:repo version: 3.2.4(@types/node@22.19.19)(jiti@2.7.0)(lightningcss@1.30.2)(terser@5.44.1)(tsx@4.22.1)(yaml@2.9.0) + examples/supabase-worker: {} + packages/bench: dependencies: '@cipherstash/stack': @@ -195,7 +197,7 @@ importers: dependencies: '@cipherstash/auth': specifier: catalog:repo - version: 0.37.0-alpha.8 + version: 0.36.0 '@cipherstash/migrate': specifier: workspace:* version: link:../migrate @@ -516,7 +518,7 @@ importers: specifier: 0.2.0 version: 0.2.0 '@cipherstash/auth': - specifier: catalog:repo + specifier: 0.37.0-alpha.8 version: 0.37.0-alpha.8 '@cipherstash/protect-ffi': specifier: 0.24.0 @@ -575,7 +577,7 @@ importers: version: 0.3.143(@anthropic-ai/sdk@0.81.0(zod@3.25.76))(@modelcontextprotocol/sdk@1.29.0(zod@3.25.76))(zod@3.25.76) '@cipherstash/auth': specifier: catalog:repo - version: 0.37.0-alpha.8 + version: 0.36.0 '@clack/prompts': specifier: 1.4.0 version: 1.4.0 @@ -795,6 +797,39 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + '@cipherstash/auth-darwin-arm64@0.36.0': + resolution: {integrity: sha512-KnRBW90HHJdxtMTjts1OxnlKdcuKdkWfdd+XwZXWCGlzlIxjq2QGMoVlvGzk7kMoZapLomRMn+f4RBzM1dwsWQ==} + cpu: [arm64] + os: [darwin] + + '@cipherstash/auth-darwin-x64@0.36.0': + resolution: {integrity: sha512-bCAdJSwAz79mFr36GeGn4IddDCRQokFcqV1qzmTsgzjt8Q3B+vmglY7uoGWQUJWTyfDrflEH1P+kivGeKYehyQ==} + cpu: [x64] + os: [darwin] + + '@cipherstash/auth-linux-arm64-gnu@0.36.0': + resolution: {integrity: sha512-PDpm1EHC1XzVtEDGzcyr0UXNca8IFkfPusqqVJ5CSpzCtlYipIClYui197zQ4NGMHIAQD168IEFOK2TROyb4Tw==} + cpu: [arm64] + os: [linux] + + '@cipherstash/auth-linux-x64-gnu@0.36.0': + resolution: {integrity: sha512-Gm20ezVlGmNrkMH4s+I+JT13hDRD6vEX3fu3VDQQhWUiYCdgbdVsNJQgOr6QMY1cJkkmGyNlQKfiCPn4zlqtMg==} + cpu: [x64] + os: [linux] + + '@cipherstash/auth-linux-x64-musl@0.36.0': + resolution: {integrity: sha512-RUQeLc19JnURAMEoemP3+2DyptK+pqNFrVGgiKKOMVql0SZDVMlN2IyFrTKJ2emv1yuf4Gr1+E4jIdKPR0Oh+g==} + cpu: [x64] + os: [linux] + + '@cipherstash/auth-win32-x64-msvc@0.36.0': + resolution: {integrity: sha512-1mQ8E6YFy7frHkvrDmSixpy47EakGPRh4qgoXPgk9lqZnlbMECYZhoKWQEs5wa3tLGgiX5G6jKC3NQZsOOqEfQ==} + cpu: [x64] + os: [win32] + + '@cipherstash/auth@0.36.0': + resolution: {integrity: sha512-Pb+KdUre8d/lgtsiYpVvCI7zsrZIUElpb0gdU71HMYvNW5sopj3xQv0TLOhxrVLclmOoii/nYLJm+GH3eNYdpg==} + '@cipherstash/auth@0.37.0-alpha.8': resolution: {integrity: sha512-Bwl8LdYEqQUGcfVfhyemOZa6ZrWEvA3av3c9Vg1Y1eR6OkFLgf4QEI4lHGIiNlU34BmZKShpGwrTWUN+Kct6vg==} peerDependencies: @@ -4238,6 +4273,33 @@ snapshots: human-id: 4.1.3 prettier: 2.8.8 + '@cipherstash/auth-darwin-arm64@0.36.0': + optional: true + + '@cipherstash/auth-darwin-x64@0.36.0': + optional: true + + '@cipherstash/auth-linux-arm64-gnu@0.36.0': + optional: true + + '@cipherstash/auth-linux-x64-gnu@0.36.0': + optional: true + + '@cipherstash/auth-linux-x64-musl@0.36.0': + optional: true + + '@cipherstash/auth-win32-x64-msvc@0.36.0': + optional: true + + '@cipherstash/auth@0.36.0': + optionalDependencies: + '@cipherstash/auth-darwin-arm64': 0.36.0 + '@cipherstash/auth-darwin-x64': 0.36.0 + '@cipherstash/auth-linux-arm64-gnu': 0.36.0 + '@cipherstash/auth-linux-x64-gnu': 0.36.0 + '@cipherstash/auth-linux-x64-musl': 0.36.0 + '@cipherstash/auth-win32-x64-msvc': 0.36.0 + '@cipherstash/auth@0.37.0-alpha.8': {} '@cipherstash/protect-ffi-darwin-arm64@0.23.0': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index ccb6262a..a1548fed 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,7 +5,16 @@ packages: catalogs: repo: - '@cipherstash/auth': 0.37.0-alpha.8 + # NAPI consumers (wizard, cli) stay on 0.36.0 — that release uses + # `optionalDependencies` for the per-platform native binaries, so pnpm + # auto-installs the matching binary on each CI platform. 0.37.0-alpha + # moved them to optional peerDependencies which pnpm does NOT install + # by platform, breaking `import auth from '@cipherstash/auth'` on + # Linux CI. `@cipherstash/stack` pins 0.37.0-alpha.8 directly for the + # `/wasm-inline` subpath — it never touches the NAPI bindings, so the + # missing native binary doesn't matter there. Promote the catalog + # once auth 0.38.0 ships a stable release. + '@cipherstash/auth': 0.36.0 tsup: 8.5.1 tsx: 4.22.1 typescript: 5.9.3 From 0829aa23d17058d8d76a52c9fea0a3bbf175a298 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 27 May 2026 00:39:31 +1000 Subject: [PATCH 3/7] fix(stack/wasm-inline): normalize cast_as before passing to WASM newClient MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Node entry of protect-ffi normalizes SDK-facing cast_as values (`string`, `number`, …) to EQL-native (`text`, `double`, …) via `normalizeEncryptConfig.js` before handing them to the Rust core. The WASM bindings don't ship that normalization layer, so an `encryptedColumn('email')` (defaults to `cast_as: 'string'`) was rejected with `unknown variant 'string', expected one of 'big_int', 'boolean', 'date', 'decimal', 'double', 'float', 'real', 'int', 'json', 'jsonb', 'small_int', 'text', 'timestamp'`. Walk the EncryptConfig in the WASM Encryption() factory and apply `toEqlCastAs` to each column's cast_as field. Same mapping the Node side does internally. --- packages/stack/src/wasm-inline.ts | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/stack/src/wasm-inline.ts b/packages/stack/src/wasm-inline.ts index b7c6fe23..f011eba3 100644 --- a/packages/stack/src/wasm-inline.ts +++ b/packages/stack/src/wasm-inline.ts @@ -48,11 +48,13 @@ import { } from '@cipherstash/protect-ffi/wasm-inline' import { AccessKeyStrategy } from '@cipherstash/auth/wasm-inline' import { + type CastAs, type EncryptConfig, type EncryptedTable, type EncryptedTableColumn, buildEncryptConfig, encryptConfigSchema, + toEqlCastAs, } from '@/schema' import { EncryptedColumn, EncryptedField } from '@/schema' import type { Encrypted, EncryptOptions } from '@/types' @@ -190,7 +192,7 @@ export async function Encryption( const strategy = resolveStrategy(clientConfig) const client = await wasmNewClient(strategy as never, { - encryptConfig, + encryptConfig: normalizeCastAs(encryptConfig), clientId: clientConfig.clientId, clientKey: clientConfig.clientKey, } as never) @@ -198,6 +200,31 @@ export async function Encryption( return new WasmEncryptionClient(client) } +/** + * Convert SDK-facing `cast_as` values (`'string'`, `'number'`, …) to the + * EQL-native variants (`'text'`, `'double'`, …) that the WASM + * `newClient` accepts. + * + * The Node entry of protect-ffi performs this normalization internally + * via `normalizeEncryptConfig.js`; the WASM bindings do not. Without + * this, the WASM client rejects an `encryptedColumn('email')` (which + * defaults to `cast_as: 'string'`) with + * `unknown variant `string`, expected one of `big_int`, …`. + */ +function normalizeCastAs(config: EncryptConfig): unknown { + const tables: Record> = {} + for (const [tableName, columns] of Object.entries(config.tables)) { + const normalised: Record = {} + for (const [colName, col] of Object.entries(columns)) { + normalised[colName] = col.cast_as + ? { ...col, cast_as: toEqlCastAs(col.cast_as as CastAs) } + : col + } + tables[tableName] = normalised + } + return { ...config, tables } +} + function getColumnName( col: EncryptOptions['column'], ): string { From 81687f25485067d294cff1442545d534a2b4db2b Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 27 May 2026 10:02:53 +1000 Subject: [PATCH 4/7] chore(stack): bump @cipherstash/auth to 0.38.0 0.38.0 promotes the WASM-inline work that landed across the 0.37.0-alpha series to a stable release. Bump the catalog so all consumers (wizard, cli, stack) track the same major; drop the direct 0.37.0-alpha.8 pin from packages/stack and the Deno test's import map. --- e2e/wasm/deno.json | 2 +- packages/stack/package.json | 2 +- pnpm-lock.yaml | 90 +++++++------------------------------ pnpm-workspace.yaml | 11 +---- 4 files changed, 18 insertions(+), 87 deletions(-) diff --git a/e2e/wasm/deno.json b/e2e/wasm/deno.json index 24867163..24bb913a 100644 --- a/e2e/wasm/deno.json +++ b/e2e/wasm/deno.json @@ -10,6 +10,6 @@ "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.37.0-alpha.8/wasm-inline" + "@cipherstash/auth/wasm-inline": "npm:@cipherstash/auth@0.38.0/wasm-inline" } } diff --git a/packages/stack/package.json b/packages/stack/package.json index dfadd38d..27fb4feb 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -212,7 +212,7 @@ }, "dependencies": { "@byteslice/result": "0.2.0", - "@cipherstash/auth": "0.37.0-alpha.8", + "@cipherstash/auth": "catalog:repo", "@cipherstash/protect-ffi": "0.24.0", "evlog": "1.11.0", "uuid": "14.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1bc660a6..2e7d98e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,8 +7,8 @@ settings: catalogs: repo: '@cipherstash/auth': - specifier: 0.36.0 - version: 0.36.0 + specifier: 0.38.0 + version: 0.38.0 tsup: specifier: 8.5.1 version: 8.5.1 @@ -197,7 +197,7 @@ importers: dependencies: '@cipherstash/auth': specifier: catalog:repo - version: 0.36.0 + version: 0.38.0 '@cipherstash/migrate': specifier: workspace:* version: link:../migrate @@ -518,8 +518,8 @@ importers: specifier: 0.2.0 version: 0.2.0 '@cipherstash/auth': - specifier: 0.37.0-alpha.8 - version: 0.37.0-alpha.8 + specifier: catalog:repo + version: 0.38.0 '@cipherstash/protect-ffi': specifier: 0.24.0 version: 0.24.0 @@ -577,7 +577,7 @@ importers: version: 0.3.143(@anthropic-ai/sdk@0.81.0(zod@3.25.76))(@modelcontextprotocol/sdk@1.29.0(zod@3.25.76))(zod@3.25.76) '@cipherstash/auth': specifier: catalog:repo - version: 0.36.0 + version: 0.38.0 '@clack/prompts': specifier: 1.4.0 version: 1.4.0 @@ -797,48 +797,15 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} - '@cipherstash/auth-darwin-arm64@0.36.0': - resolution: {integrity: sha512-KnRBW90HHJdxtMTjts1OxnlKdcuKdkWfdd+XwZXWCGlzlIxjq2QGMoVlvGzk7kMoZapLomRMn+f4RBzM1dwsWQ==} - cpu: [arm64] - os: [darwin] - - '@cipherstash/auth-darwin-x64@0.36.0': - resolution: {integrity: sha512-bCAdJSwAz79mFr36GeGn4IddDCRQokFcqV1qzmTsgzjt8Q3B+vmglY7uoGWQUJWTyfDrflEH1P+kivGeKYehyQ==} - cpu: [x64] - os: [darwin] - - '@cipherstash/auth-linux-arm64-gnu@0.36.0': - resolution: {integrity: sha512-PDpm1EHC1XzVtEDGzcyr0UXNca8IFkfPusqqVJ5CSpzCtlYipIClYui197zQ4NGMHIAQD168IEFOK2TROyb4Tw==} - cpu: [arm64] - os: [linux] - - '@cipherstash/auth-linux-x64-gnu@0.36.0': - resolution: {integrity: sha512-Gm20ezVlGmNrkMH4s+I+JT13hDRD6vEX3fu3VDQQhWUiYCdgbdVsNJQgOr6QMY1cJkkmGyNlQKfiCPn4zlqtMg==} - cpu: [x64] - os: [linux] - - '@cipherstash/auth-linux-x64-musl@0.36.0': - resolution: {integrity: sha512-RUQeLc19JnURAMEoemP3+2DyptK+pqNFrVGgiKKOMVql0SZDVMlN2IyFrTKJ2emv1yuf4Gr1+E4jIdKPR0Oh+g==} - cpu: [x64] - os: [linux] - - '@cipherstash/auth-win32-x64-msvc@0.36.0': - resolution: {integrity: sha512-1mQ8E6YFy7frHkvrDmSixpy47EakGPRh4qgoXPgk9lqZnlbMECYZhoKWQEs5wa3tLGgiX5G6jKC3NQZsOOqEfQ==} - cpu: [x64] - os: [win32] - - '@cipherstash/auth@0.36.0': - resolution: {integrity: sha512-Pb+KdUre8d/lgtsiYpVvCI7zsrZIUElpb0gdU71HMYvNW5sopj3xQv0TLOhxrVLclmOoii/nYLJm+GH3eNYdpg==} - - '@cipherstash/auth@0.37.0-alpha.8': - resolution: {integrity: sha512-Bwl8LdYEqQUGcfVfhyemOZa6ZrWEvA3av3c9Vg1Y1eR6OkFLgf4QEI4lHGIiNlU34BmZKShpGwrTWUN+Kct6vg==} + '@cipherstash/auth@0.38.0': + resolution: {integrity: sha512-jsrJ8fqenjcuMGWG1pBzVUflZUQZ7c1MosYiqyxh6fYDsMlTl70S8agDSsa2ciXmVQyti49n4on8c1CZxGhF5w==} peerDependencies: - '@cipherstash/auth-darwin-arm64': 0.37.0-alpha.8 - '@cipherstash/auth-darwin-x64': 0.37.0-alpha.8 - '@cipherstash/auth-linux-arm64-gnu': 0.37.0-alpha.8 - '@cipherstash/auth-linux-x64-gnu': 0.37.0-alpha.8 - '@cipherstash/auth-linux-x64-musl': 0.37.0-alpha.8 - '@cipherstash/auth-win32-x64-msvc': 0.37.0-alpha.8 + '@cipherstash/auth-darwin-arm64': 0.38.0 + '@cipherstash/auth-darwin-x64': 0.38.0 + '@cipherstash/auth-linux-arm64-gnu': 0.38.0 + '@cipherstash/auth-linux-x64-gnu': 0.38.0 + '@cipherstash/auth-linux-x64-musl': 0.38.0 + '@cipherstash/auth-win32-x64-msvc': 0.38.0 peerDependenciesMeta: '@cipherstash/auth-darwin-arm64': optional: true @@ -4273,34 +4240,7 @@ snapshots: human-id: 4.1.3 prettier: 2.8.8 - '@cipherstash/auth-darwin-arm64@0.36.0': - optional: true - - '@cipherstash/auth-darwin-x64@0.36.0': - optional: true - - '@cipherstash/auth-linux-arm64-gnu@0.36.0': - optional: true - - '@cipherstash/auth-linux-x64-gnu@0.36.0': - optional: true - - '@cipherstash/auth-linux-x64-musl@0.36.0': - optional: true - - '@cipherstash/auth-win32-x64-msvc@0.36.0': - optional: true - - '@cipherstash/auth@0.36.0': - optionalDependencies: - '@cipherstash/auth-darwin-arm64': 0.36.0 - '@cipherstash/auth-darwin-x64': 0.36.0 - '@cipherstash/auth-linux-arm64-gnu': 0.36.0 - '@cipherstash/auth-linux-x64-gnu': 0.36.0 - '@cipherstash/auth-linux-x64-musl': 0.36.0 - '@cipherstash/auth-win32-x64-msvc': 0.36.0 - - '@cipherstash/auth@0.37.0-alpha.8': {} + '@cipherstash/auth@0.38.0': {} '@cipherstash/protect-ffi-darwin-arm64@0.23.0': optional: true diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a1548fed..9921636c 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,16 +5,7 @@ packages: catalogs: repo: - # NAPI consumers (wizard, cli) stay on 0.36.0 — that release uses - # `optionalDependencies` for the per-platform native binaries, so pnpm - # auto-installs the matching binary on each CI platform. 0.37.0-alpha - # moved them to optional peerDependencies which pnpm does NOT install - # by platform, breaking `import auth from '@cipherstash/auth'` on - # Linux CI. `@cipherstash/stack` pins 0.37.0-alpha.8 directly for the - # `/wasm-inline` subpath — it never touches the NAPI bindings, so the - # missing native binary doesn't matter there. Promote the catalog - # once auth 0.38.0 ships a stable release. - '@cipherstash/auth': 0.36.0 + '@cipherstash/auth': 0.38.0 tsup: 8.5.1 tsx: 4.22.1 typescript: 5.9.3 From 75e6f6ac2b923e758891780c5ca68d598d6a7f10 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 27 May 2026 10:05:54 +1000 Subject: [PATCH 5/7] fix(wizard,cli): declare @cipherstash/auth native binaries as optionalDeps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @cipherstash/auth 0.37.0+ ships per-platform native bindings as optional peerDependencies. pnpm doesn't auto-install platform-matched optional peer deps, so on fresh Linux CI no binary lands in node_modules and the NAPI loader throws: Failed to load native binding for linux-x64. Ensure the optional dependency "@cipherstash/auth-linux-x64-gnu" is installed. Declare the six platform sub-packages as `optionalDependencies` on the two workspace packages that actually `import auth from '@cipherstash/auth'` (wizard, cli). `optionalDependencies` IS platform-aware in pnpm — each sub-package's own `os`/`cpu` filter picks one binary for the host and silently skips the rest. @cipherstash/stack itself doesn't need this — it only uses the `/wasm-inline` subpath, which never loads the NAPI shim. --- packages/cli/package.json | 9 ++++ packages/wizard/package.json | 9 ++++ pnpm-lock.yaml | 101 +++++++++++++++++++++++++++++++++-- 3 files changed, 115 insertions(+), 4 deletions(-) diff --git a/packages/cli/package.json b/packages/cli/package.json index 3c1a6938..90621b1a 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -50,6 +50,15 @@ "posthog-node": "^5.34.2", "zod": "^3.25.76" }, + "//optionalDependencies": "@cipherstash/auth ships per-platform native bindings as optional peerDependencies. pnpm does not auto-install platform-matched optional peer deps, so we declare them here as optionalDependencies — pnpm then picks the binary matching the host's os/cpu (from each sub-package's own package.json) and ignores the rest. Bump in lockstep with @cipherstash/auth.", + "optionalDependencies": { + "@cipherstash/auth-darwin-arm64": "0.38.0", + "@cipherstash/auth-darwin-x64": "0.38.0", + "@cipherstash/auth-linux-arm64-gnu": "0.38.0", + "@cipherstash/auth-linux-x64-gnu": "0.38.0", + "@cipherstash/auth-linux-x64-musl": "0.38.0", + "@cipherstash/auth-win32-x64-msvc": "0.38.0" + }, "peerDependencies": { "@cipherstash/stack": ">=0.6.0" }, diff --git a/packages/wizard/package.json b/packages/wizard/package.json index 5c791933..fadcf313 100644 --- a/packages/wizard/package.json +++ b/packages/wizard/package.json @@ -33,6 +33,15 @@ "posthog-node": "^5.34.2", "zod": "^3.25.76" }, + "//optionalDependencies": "@cipherstash/auth ships per-platform native bindings as optional peerDependencies. pnpm does not auto-install platform-matched optional peer deps, so we declare them here as optionalDependencies — pnpm then picks the binary matching the host's os/cpu (from each sub-package's own package.json) and ignores the rest. Bump in lockstep with @cipherstash/auth.", + "optionalDependencies": { + "@cipherstash/auth-darwin-arm64": "0.38.0", + "@cipherstash/auth-darwin-x64": "0.38.0", + "@cipherstash/auth-linux-arm64-gnu": "0.38.0", + "@cipherstash/auth-linux-x64-gnu": "0.38.0", + "@cipherstash/auth-linux-x64-musl": "0.38.0", + "@cipherstash/auth-win32-x64-msvc": "0.38.0" + }, "devDependencies": { "@types/pg": "^8.20.0", "tsup": "catalog:repo", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2e7d98e4..e39a0d19 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -197,7 +197,7 @@ importers: dependencies: '@cipherstash/auth': specifier: catalog:repo - version: 0.38.0 + version: 0.38.0(@cipherstash/auth-darwin-arm64@0.38.0)(@cipherstash/auth-darwin-x64@0.38.0)(@cipherstash/auth-linux-arm64-gnu@0.38.0)(@cipherstash/auth-linux-x64-gnu@0.38.0)(@cipherstash/auth-linux-x64-musl@0.38.0)(@cipherstash/auth-win32-x64-msvc@0.38.0) '@cipherstash/migrate': specifier: workspace:* version: link:../migrate @@ -222,6 +222,25 @@ importers: zod: specifier: ^3.25.76 version: 3.25.76 + optionalDependencies: + '@cipherstash/auth-darwin-arm64': + specifier: 0.38.0 + version: 0.38.0 + '@cipherstash/auth-darwin-x64': + specifier: 0.38.0 + version: 0.38.0 + '@cipherstash/auth-linux-arm64-gnu': + specifier: 0.38.0 + version: 0.38.0 + '@cipherstash/auth-linux-x64-gnu': + specifier: 0.38.0 + version: 0.38.0 + '@cipherstash/auth-linux-x64-musl': + specifier: 0.38.0 + version: 0.38.0 + '@cipherstash/auth-win32-x64-msvc': + specifier: 0.38.0 + version: 0.38.0 devDependencies: '@cipherstash/stack': specifier: workspace:* @@ -519,7 +538,7 @@ importers: version: 0.2.0 '@cipherstash/auth': specifier: catalog:repo - version: 0.38.0 + version: 0.38.0(@cipherstash/auth-darwin-arm64@0.38.0)(@cipherstash/auth-darwin-x64@0.38.0)(@cipherstash/auth-linux-arm64-gnu@0.38.0)(@cipherstash/auth-linux-x64-gnu@0.38.0)(@cipherstash/auth-linux-x64-musl@0.38.0)(@cipherstash/auth-win32-x64-msvc@0.38.0) '@cipherstash/protect-ffi': specifier: 0.24.0 version: 0.24.0 @@ -577,7 +596,7 @@ importers: version: 0.3.143(@anthropic-ai/sdk@0.81.0(zod@3.25.76))(@modelcontextprotocol/sdk@1.29.0(zod@3.25.76))(zod@3.25.76) '@cipherstash/auth': specifier: catalog:repo - version: 0.38.0 + version: 0.38.0(@cipherstash/auth-darwin-arm64@0.38.0)(@cipherstash/auth-darwin-x64@0.38.0)(@cipherstash/auth-linux-arm64-gnu@0.38.0)(@cipherstash/auth-linux-x64-gnu@0.38.0)(@cipherstash/auth-linux-x64-musl@0.38.0)(@cipherstash/auth-win32-x64-msvc@0.38.0) '@clack/prompts': specifier: 1.4.0 version: 1.4.0 @@ -596,6 +615,25 @@ importers: zod: specifier: ^3.25.76 version: 3.25.76 + optionalDependencies: + '@cipherstash/auth-darwin-arm64': + specifier: 0.38.0 + version: 0.38.0 + '@cipherstash/auth-darwin-x64': + specifier: 0.38.0 + version: 0.38.0 + '@cipherstash/auth-linux-arm64-gnu': + specifier: 0.38.0 + version: 0.38.0 + '@cipherstash/auth-linux-x64-gnu': + specifier: 0.38.0 + version: 0.38.0 + '@cipherstash/auth-linux-x64-musl': + specifier: 0.38.0 + version: 0.38.0 + '@cipherstash/auth-win32-x64-msvc': + specifier: 0.38.0 + version: 0.38.0 devDependencies: '@types/pg': specifier: ^8.20.0 @@ -797,6 +835,36 @@ packages: '@changesets/write@0.4.0': resolution: {integrity: sha512-CdTLvIOPiCNuH71pyDu3rA+Q0n65cmAbXnwWH84rKGiFumFzkmHNT8KHTMEchcxN+Kl8I54xGUhJ7l3E7X396Q==} + '@cipherstash/auth-darwin-arm64@0.38.0': + resolution: {integrity: sha512-LF4u99t+KMll3m6ALcKzq8UCTC9zxUQLb6eKHMG5PGE5kIOb4MbG8yeEc3nUM6Up4hAl5leoCMlZsFdcVdSEQA==} + cpu: [arm64] + os: [darwin] + + '@cipherstash/auth-darwin-x64@0.38.0': + resolution: {integrity: sha512-E+S8ehq92S7fSuUSDTN1JZOZiWPVpmL01KK/aF+R7DaaGGBzQ0ZleR6JbY2t6pmX4TUpGRgbN8ImewuZW6QqgA==} + cpu: [x64] + os: [darwin] + + '@cipherstash/auth-linux-arm64-gnu@0.38.0': + resolution: {integrity: sha512-ZF167YZRIl4+Geqi0+diShyV2VdWG14UfAsvP1ZPfrLOsNJn5wCK3tL9Mw90Q526zr6Yik/smbfrUrS69rHU6A==} + cpu: [arm64] + os: [linux] + + '@cipherstash/auth-linux-x64-gnu@0.38.0': + resolution: {integrity: sha512-xl1zMuANCtHMhfC77QBKULlfsbGMsGOqWTl5zD6NPn8lrM4tqDpaOdLwEbIo4EjbLSoA38IY9jxYB0qvlV0QQA==} + cpu: [x64] + os: [linux] + + '@cipherstash/auth-linux-x64-musl@0.38.0': + resolution: {integrity: sha512-rN4E+sOjZH7xLCV/NFOixceTMYqivnF+CyFqxJaUpmqW36vwwuTAuv8S93A+wOzn+A6W8HPwfkBWMmZenNUznQ==} + cpu: [x64] + os: [linux] + + '@cipherstash/auth-win32-x64-msvc@0.38.0': + resolution: {integrity: sha512-cvnqgRL4sKeuJ7HvdLyLkwS59TW4FI9z/Fdreyv8Q78TEhjmG0HMXKdNeTW7AAATFYmzqJlmZX2RRa+QnUfhfQ==} + cpu: [x64] + os: [win32] + '@cipherstash/auth@0.38.0': resolution: {integrity: sha512-jsrJ8fqenjcuMGWG1pBzVUflZUQZ7c1MosYiqyxh6fYDsMlTl70S8agDSsa2ciXmVQyti49n4on8c1CZxGhF5w==} peerDependencies: @@ -4240,7 +4308,32 @@ snapshots: human-id: 4.1.3 prettier: 2.8.8 - '@cipherstash/auth@0.38.0': {} + '@cipherstash/auth-darwin-arm64@0.38.0': + optional: true + + '@cipherstash/auth-darwin-x64@0.38.0': + optional: true + + '@cipherstash/auth-linux-arm64-gnu@0.38.0': + optional: true + + '@cipherstash/auth-linux-x64-gnu@0.38.0': + optional: true + + '@cipherstash/auth-linux-x64-musl@0.38.0': + optional: true + + '@cipherstash/auth-win32-x64-msvc@0.38.0': + optional: true + + '@cipherstash/auth@0.38.0(@cipherstash/auth-darwin-arm64@0.38.0)(@cipherstash/auth-darwin-x64@0.38.0)(@cipherstash/auth-linux-arm64-gnu@0.38.0)(@cipherstash/auth-linux-x64-gnu@0.38.0)(@cipherstash/auth-linux-x64-musl@0.38.0)(@cipherstash/auth-win32-x64-msvc@0.38.0)': + optionalDependencies: + '@cipherstash/auth-darwin-arm64': 0.38.0 + '@cipherstash/auth-darwin-x64': 0.38.0 + '@cipherstash/auth-linux-arm64-gnu': 0.38.0 + '@cipherstash/auth-linux-x64-gnu': 0.38.0 + '@cipherstash/auth-linux-x64-musl': 0.38.0 + '@cipherstash/auth-win32-x64-msvc': 0.38.0 '@cipherstash/protect-ffi-darwin-arm64@0.23.0': optional: true From 93d92a0168d9f34f612e81e07ecf789323597aeb Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 27 May 2026 10:31:21 +1000 Subject: [PATCH 6/7] fix(stack/wasm-inline): address code-review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six fixes from a recall-biased high-effort review: 1. Bump @cipherstash/stack 0.17.0 → 0.18.0 so the Supabase example's `npm:@cipherstash/stack@^0.18.0/wasm-inline` import resolves once this lands on npm. The minor bump matches the new public subpath. 2. Make `WasmClientConfig.clientId` and `clientKey` required (was `?`), and use a discriminated union for `accessKey` vs `strategy` so the compiler enforces one-or-the-other. Previously a `{region, accessKey}` config typechecked but forwarded `{clientId: undefined, clientKey: undefined}` to the WASM client and failed authentication at first encrypt. 3. `normalizeCastAs` now throws synchronously if `toEqlCastAs` returns `undefined` (i.e. someone added a new SDK-facing cast_as variant without extending the switch). The previous behaviour silently handed `undefined` to the WASM serde, which surfaced as an opaque `unknown variant 'null'` startup crash. 4. Drop the `decryptRaw` / `encryptRaw` / `newClientRaw` / `AccessKeyStrategy` re-exports. They were a footgun — a consumer reaching `newClientRaw` bypasses `normalizeCastAs` and hits the exact same `unknown variant 'string'` failure the high-level `Encryption()` was patched to avoid. Anyone who needs raw access can import from `@cipherstash/protect-ffi/wasm-inline` or `@cipherstash/auth/wasm-inline` directly. 5. Move the six `@cipherstash/auth-` packages into the `pnpm-workspace.yaml` catalog and reference them via `catalog:repo` from `wizard` and `cli`'s `optionalDependencies`. Previously each sub-package was hard-pinned to the literal `0.38.0`, so a catalog bump of `@cipherstash/auth` alone would drift the platform binary versions and break the NAPI loader. 6. The `wasm-e2e-tests` job now asserts the four required `CS_*` secrets are present BEFORE running `deno task test`. Without this, a rotated / cleared CI secret made the test silently `ignore` and the job reported green, hiding any real WASM regression. --- .github/workflows/tests.yml | 14 +++++ packages/cli/package.json | 14 ++--- packages/stack/package.json | 2 +- packages/stack/src/wasm-inline.ts | 88 +++++++++++++++++++++---------- packages/wizard/package.json | 14 ++--- pnpm-lock.yaml | 42 ++++++++++----- pnpm-workspace.yaml | 12 +++++ 7 files changed, 130 insertions(+), 56 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 2afad3da..97eb0bfd 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -202,6 +202,20 @@ jobs: - 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_WORKSPACE_CRN 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 diff --git a/packages/cli/package.json b/packages/cli/package.json index 90621b1a..664faa9f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -50,14 +50,14 @@ "posthog-node": "^5.34.2", "zod": "^3.25.76" }, - "//optionalDependencies": "@cipherstash/auth ships per-platform native bindings as optional peerDependencies. pnpm does not auto-install platform-matched optional peer deps, so we declare them here as optionalDependencies — pnpm then picks the binary matching the host's os/cpu (from each sub-package's own package.json) and ignores the rest. Bump in lockstep with @cipherstash/auth.", + "//optionalDependencies": "@cipherstash/auth ships per-platform native bindings as optional peerDependencies. pnpm does not auto-install platform-matched optional peer deps, so we declare them here as optionalDependencies — pnpm then picks the binary matching the host's os/cpu (from each sub-package's own package.json) and ignores the rest. All seven names share a single catalog entry to keep them in lockstep.", "optionalDependencies": { - "@cipherstash/auth-darwin-arm64": "0.38.0", - "@cipherstash/auth-darwin-x64": "0.38.0", - "@cipherstash/auth-linux-arm64-gnu": "0.38.0", - "@cipherstash/auth-linux-x64-gnu": "0.38.0", - "@cipherstash/auth-linux-x64-musl": "0.38.0", - "@cipherstash/auth-win32-x64-msvc": "0.38.0" + "@cipherstash/auth-darwin-arm64": "catalog:repo", + "@cipherstash/auth-darwin-x64": "catalog:repo", + "@cipherstash/auth-linux-arm64-gnu": "catalog:repo", + "@cipherstash/auth-linux-x64-gnu": "catalog:repo", + "@cipherstash/auth-linux-x64-musl": "catalog:repo", + "@cipherstash/auth-win32-x64-msvc": "catalog:repo" }, "peerDependencies": { "@cipherstash/stack": ">=0.6.0" diff --git a/packages/stack/package.json b/packages/stack/package.json index 27fb4feb..616ca294 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -1,6 +1,6 @@ { "name": "@cipherstash/stack", - "version": "0.17.0", + "version": "0.18.0", "description": "CipherStash Stack for TypeScript and JavaScript", "keywords": [ "encrypted", diff --git a/packages/stack/src/wasm-inline.ts b/packages/stack/src/wasm-inline.ts index f011eba3..d1990b81 100644 --- a/packages/stack/src/wasm-inline.ts +++ b/packages/stack/src/wasm-inline.ts @@ -23,7 +23,7 @@ * config: { * region: "ap-southeast-2.aws", * accessKey: Deno.env.get("CS_CLIENT_ACCESS_KEY")!, - * clientId: Deno.env.get("CS_CLIENT_ID")!, + * clientId: Deno.env.get("CS_CLIENT_ID")!, * clientKey: Deno.env.get("CS_CLIENT_KEY")!, * }, * }) @@ -35,32 +35,36 @@ * const dec = await client.decrypt(enc) * ``` * - * For lower-level access, the raw `@cipherstash/protect-ffi/wasm-inline` - * functions and the `@cipherstash/auth/wasm-inline` strategy are - * re-exported below. + * For runtimes that need a custom token store (e.g. cookies on a + * Supabase Edge Function), pass a pre-built strategy via + * `config.strategy`. The strategy type is structural — any object with + * `getToken(): Promise<{ token: string }>` works. Use + * `AccessKeyStrategy.create(...)` from `@cipherstash/auth/wasm-inline` + * to build one with a `cookieStore` from `@cipherstash/auth/cookies`. */ +import { AccessKeyStrategy } from '@cipherstash/auth/wasm-inline' import { decrypt as wasmDecrypt, encrypt as wasmEncrypt, isEncrypted as wasmIsEncrypted, newClient as wasmNewClient, } from '@cipherstash/protect-ffi/wasm-inline' -import { AccessKeyStrategy } from '@cipherstash/auth/wasm-inline' import { type CastAs, type EncryptConfig, + EncryptedColumn, + EncryptedField, type EncryptedTable, type EncryptedTableColumn, buildEncryptConfig, encryptConfigSchema, toEqlCastAs, } from '@/schema' -import { EncryptedColumn, EncryptedField } from '@/schema' import type { Encrypted, EncryptOptions } from '@/types' // ----------------------------------------------------------------------- -// Re-exports — direct passthrough for consumers who want the raw API. +// Schema + type re-exports // ----------------------------------------------------------------------- export { @@ -80,14 +84,18 @@ export type { export type { Encrypted } from '@/types' -export { - decrypt as decryptRaw, - encrypt as encryptRaw, - isEncrypted, - newClient as newClientRaw, -} from '@cipherstash/protect-ffi/wasm-inline' +/** Re-exported convenience predicate — same as the raw protect-ffi one. */ +export function isEncrypted(value: unknown): boolean { + return wasmIsEncrypted(value as never) +} -export { AccessKeyStrategy } from '@cipherstash/auth/wasm-inline' +// Note: the raw `newClient` / `encrypt` / `decrypt` from +// `@cipherstash/protect-ffi/wasm-inline` and `AccessKeyStrategy` from +// `@cipherstash/auth/wasm-inline` are intentionally NOT re-exported. The +// raw `newClient` does not normalise SDK-facing `cast_as` values (see +// `normalizeCastAs` below) and a re-export would invite consumers to +// build configs that this normaliser rejects. Import those names +// directly from their source packages if you need raw access. // ----------------------------------------------------------------------- // High-level `Encryption` factory + client. @@ -100,22 +108,30 @@ const DEFAULT_REGION = 'ap-southeast-2.aws' * Config for {@link Encryption} on the WASM entry point. * * Unlike the Node entry, the WASM path requires an explicit auth - * strategy. For service-to-service / CI use, pass an `accessKey` and we - * construct an {@link AccessKeyStrategy} for you; alternatively, pass - * your own pre-built `strategy` to use OAuth-style flows or a custom - * token store. + * strategy. For service-to-service / CI use, pass `accessKey` plus the + * workspace `clientId` / `clientKey` and we construct an + * `AccessKeyStrategy` for you. To use a custom token store (e.g. + * cookies on a Supabase Edge Function), pass a pre-built `strategy` + * instead. */ export type WasmClientConfig = { /** CipherStash region, e.g. `"ap-southeast-2.aws"`. Defaults to ap-southeast-2.aws. */ region?: string - /** Static access key. Mutually exclusive with `strategy`. */ - accessKey?: string - /** Pre-built auth strategy (e.g. `AccessKeyStrategy.create(...)` with a custom token store). */ - strategy?: { getToken(): Promise } - /** Workspace credentials. */ - clientId?: string - clientKey?: string -} + /** Workspace client identifier — required by the WASM client. */ + clientId: string + /** Workspace client key — required by the WASM client. */ + clientKey: string +} & ( // Either pass an accessKey (we build the strategy) or hand in + // your own pre-built one — never both, never neither. + | { + accessKey: string + strategy?: never + } + | { + accessKey?: never + strategy: { getToken(): Promise } + } +) export type WasmEncryptionConfig = { schemas: [ @@ -210,15 +226,29 @@ export async function Encryption( * this, the WASM client rejects an `encryptedColumn('email')` (which * defaults to `cast_as: 'string'`) with * `unknown variant `string`, expected one of `big_int`, …`. + * + * `toEqlCastAs` is exhaustive over the current `CastAs` union; if a new + * SDK-facing variant is added without updating that switch, this + * function throws synchronously at startup with a clear message rather + * than handing `undefined` to the WASM serde (which surfaces as an + * opaque `unknown variant 'null'` error). */ function normalizeCastAs(config: EncryptConfig): unknown { const tables: Record> = {} for (const [tableName, columns] of Object.entries(config.tables)) { const normalised: Record = {} for (const [colName, col] of Object.entries(columns)) { - normalised[colName] = col.cast_as - ? { ...col, cast_as: toEqlCastAs(col.cast_as as CastAs) } - : col + if (col.cast_as) { + const eqlCastAs = toEqlCastAs(col.cast_as as CastAs) + if (eqlCastAs === undefined) { + throw new Error( + `[encryption]: unrecognised cast_as value "${col.cast_as}" on ${tableName}.${colName} — update toEqlCastAs() to map it to an EQL variant.`, + ) + } + normalised[colName] = { ...col, cast_as: eqlCastAs } + } else { + normalised[colName] = col + } } tables[tableName] = normalised } diff --git a/packages/wizard/package.json b/packages/wizard/package.json index fadcf313..1053706f 100644 --- a/packages/wizard/package.json +++ b/packages/wizard/package.json @@ -33,14 +33,14 @@ "posthog-node": "^5.34.2", "zod": "^3.25.76" }, - "//optionalDependencies": "@cipherstash/auth ships per-platform native bindings as optional peerDependencies. pnpm does not auto-install platform-matched optional peer deps, so we declare them here as optionalDependencies — pnpm then picks the binary matching the host's os/cpu (from each sub-package's own package.json) and ignores the rest. Bump in lockstep with @cipherstash/auth.", + "//optionalDependencies": "@cipherstash/auth ships per-platform native bindings as optional peerDependencies. pnpm does not auto-install platform-matched optional peer deps, so we declare them here as optionalDependencies — pnpm then picks the binary matching the host's os/cpu (from each sub-package's own package.json) and ignores the rest. All seven names share a single catalog entry to keep them in lockstep.", "optionalDependencies": { - "@cipherstash/auth-darwin-arm64": "0.38.0", - "@cipherstash/auth-darwin-x64": "0.38.0", - "@cipherstash/auth-linux-arm64-gnu": "0.38.0", - "@cipherstash/auth-linux-x64-gnu": "0.38.0", - "@cipherstash/auth-linux-x64-musl": "0.38.0", - "@cipherstash/auth-win32-x64-msvc": "0.38.0" + "@cipherstash/auth-darwin-arm64": "catalog:repo", + "@cipherstash/auth-darwin-x64": "catalog:repo", + "@cipherstash/auth-linux-arm64-gnu": "catalog:repo", + "@cipherstash/auth-linux-x64-gnu": "catalog:repo", + "@cipherstash/auth-linux-x64-musl": "catalog:repo", + "@cipherstash/auth-win32-x64-msvc": "catalog:repo" }, "devDependencies": { "@types/pg": "^8.20.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e39a0d19..304e7815 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -9,6 +9,24 @@ catalogs: '@cipherstash/auth': specifier: 0.38.0 version: 0.38.0 + '@cipherstash/auth-darwin-arm64': + specifier: 0.38.0 + version: 0.38.0 + '@cipherstash/auth-darwin-x64': + specifier: 0.38.0 + version: 0.38.0 + '@cipherstash/auth-linux-arm64-gnu': + specifier: 0.38.0 + version: 0.38.0 + '@cipherstash/auth-linux-x64-gnu': + specifier: 0.38.0 + version: 0.38.0 + '@cipherstash/auth-linux-x64-musl': + specifier: 0.38.0 + version: 0.38.0 + '@cipherstash/auth-win32-x64-msvc': + specifier: 0.38.0 + version: 0.38.0 tsup: specifier: 8.5.1 version: 8.5.1 @@ -224,22 +242,22 @@ importers: version: 3.25.76 optionalDependencies: '@cipherstash/auth-darwin-arm64': - specifier: 0.38.0 + specifier: catalog:repo version: 0.38.0 '@cipherstash/auth-darwin-x64': - specifier: 0.38.0 + specifier: catalog:repo version: 0.38.0 '@cipherstash/auth-linux-arm64-gnu': - specifier: 0.38.0 + specifier: catalog:repo version: 0.38.0 '@cipherstash/auth-linux-x64-gnu': - specifier: 0.38.0 + specifier: catalog:repo version: 0.38.0 '@cipherstash/auth-linux-x64-musl': - specifier: 0.38.0 + specifier: catalog:repo version: 0.38.0 '@cipherstash/auth-win32-x64-msvc': - specifier: 0.38.0 + specifier: catalog:repo version: 0.38.0 devDependencies: '@cipherstash/stack': @@ -617,22 +635,22 @@ importers: version: 3.25.76 optionalDependencies: '@cipherstash/auth-darwin-arm64': - specifier: 0.38.0 + specifier: catalog:repo version: 0.38.0 '@cipherstash/auth-darwin-x64': - specifier: 0.38.0 + specifier: catalog:repo version: 0.38.0 '@cipherstash/auth-linux-arm64-gnu': - specifier: 0.38.0 + specifier: catalog:repo version: 0.38.0 '@cipherstash/auth-linux-x64-gnu': - specifier: 0.38.0 + specifier: catalog:repo version: 0.38.0 '@cipherstash/auth-linux-x64-musl': - specifier: 0.38.0 + specifier: catalog:repo version: 0.38.0 '@cipherstash/auth-win32-x64-msvc': - specifier: 0.38.0 + specifier: catalog:repo version: 0.38.0 devDependencies: '@types/pg': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 9921636c..270fc7e5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -5,7 +5,19 @@ packages: catalogs: repo: + # @cipherstash/auth ships per-platform native bindings as optional + # peerDependencies. pnpm does not auto-install platform-matched + # optional peer deps, so the consuming packages (wizard, cli) + # declare them as `optionalDependencies` via `catalog:repo`. Keep + # all seven entries on the same version so a single bump here moves + # everything in lockstep. '@cipherstash/auth': 0.38.0 + '@cipherstash/auth-darwin-arm64': 0.38.0 + '@cipherstash/auth-darwin-x64': 0.38.0 + '@cipherstash/auth-linux-arm64-gnu': 0.38.0 + '@cipherstash/auth-linux-x64-gnu': 0.38.0 + '@cipherstash/auth-linux-x64-musl': 0.38.0 + '@cipherstash/auth-win32-x64-msvc': 0.38.0 tsup: 8.5.1 tsx: 4.22.1 typescript: 5.9.3 From 4660b00a9191fdbf94cf2be70f77ceaebe84cdce Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Wed, 27 May 2026 11:34:39 +1000 Subject: [PATCH 7/7] review: address CodeRabbit + Toby feedback on the WASM-inline PR MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Twelve fixes from a high-effort review pass: CI hardening: - Add least-privilege permissions: contents: read to the new wasm-e2e-tests job; disable persist-credentials on checkout. - Document node-gyp install — it's for the workspace-wide pnpm install (node-pty), not the WASM path itself. Supabase worker example: - Drop CS_WORKSPACE_CRN from .env.example — the worker doesn't read it (parity gap with the Node entry tracked separately). - Sanitise the 500 response: log err.stack to the operator, never return it to the caller. Mark the response shape debug-only. - Add engines.node + packageManager to follow repo policy. - Expand README: install step, native-modules note, and a brief "what this verifies" / "automated coverage" pointer to the Deno smoke test in e2e/wasm/. @cipherstash/stack/wasm-inline: - Hide WasmEncryptionClient constructor behind an internal symbol-gated factory so callers can't wrap arbitrary objects. - Broaden plaintext type to include arrays (WasmPlaintext is recursive, matching protect-ffi's JsPlaintext). - Tighten the strategy type to the actual AccessKeyStrategy class from @cipherstash/auth/wasm-inline instead of a structural duck-type. Custom token stores still flow via AccessKeyStrategy.create({ store }). - Mark region @deprecated — a follow-up will replace it with a workspaceCrn that the auth strategy derives region from (tracked in a separate bug). - Export normalizeCastAs for unit-test coverage. Build pipeline: - Split tsup back into two configs: main entries dual ESM+CJS, wasm-inline ESM-only. Move dist cleanup to a prebuild npm script so both configs run with clean: false and don't race. The previous onSuccess deny-list was brittle (Toby/Claude). - Side-effect: dist/wasm-inline.d.cts no longer emitted, closing the orphan-types-file concern from the same review. Tests: - New __tests__/wasm-inline-normalize.test.ts: exercises every CastAs variant, asserts toEqlCastAs is exhaustive, and verifies normalizeCastAs throws on drift instead of handing undefined to WASM serde. Catches future enum additions before the e2e step. CodeRabbit findings deferred: - The "{ data } success contract" and "{ failure } error shape" are skipped — no such convention in this repo's other examples. - Restoring CJS subpath for wasm-inline skipped — protect-ffi's wasm-inline runtime is ESM-only; a require branch would re-introduce ERR_REQUIRE_ESM for downstream consumers. --- .github/workflows/tests.yml | 17 ++- e2e/wasm/roundtrip.test.ts | 5 +- examples/supabase-worker/.env.example | 9 +- examples/supabase-worker/README.md | 21 +++- examples/supabase-worker/package.json | 4 + .../functions/cipherstash-roundtrip/index.ts | 6 +- .../__tests__/wasm-inline-normalize.test.ts | 96 ++++++++++++++++ packages/stack/package.json | 1 + packages/stack/src/wasm-inline.ts | 108 ++++++++++++------ packages/stack/tsup.config.ts | 97 ++++++++-------- 10 files changed, 272 insertions(+), 92 deletions(-) create mode 100644 packages/stack/__tests__/wasm-inline-normalize.test.ts diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 97eb0bfd..8fd4543c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -165,8 +165,13 @@ jobs: 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_WORKSPACE_CRN: ${{ secrets.CS_WORKSPACE_CRN }} CS_CLIENT_ID: ${{ secrets.CS_CLIENT_ID }} CS_CLIENT_KEY: ${{ secrets.CS_CLIENT_KEY }} CS_CLIENT_ACCESS_KEY: ${{ secrets.CS_CLIENT_ACCESS_KEY }} @@ -174,6 +179,8 @@ jobs: steps: - name: Checkout Repo uses: actions/checkout@v6 + with: + persist-credentials: false - uses: pnpm/action-setup@v6.0.8 name: Install pnpm @@ -186,6 +193,12 @@ jobs: 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 run: npm install -g node-gyp @@ -209,7 +222,7 @@ jobs: # Fail loudly instead. - name: Assert CS_* secrets are present run: | - for v in CS_WORKSPACE_CRN CS_CLIENT_ID CS_CLIENT_KEY CS_CLIENT_ACCESS_KEY; do + 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 diff --git a/e2e/wasm/roundtrip.test.ts b/e2e/wasm/roundtrip.test.ts index 682df28d..7ee9088a 100644 --- a/e2e/wasm/roundtrip.test.ts +++ b/e2e/wasm/roundtrip.test.ts @@ -22,11 +22,14 @@ import { 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', - 'CS_WORKSPACE_CRN', ] as const function envOrSkip(): Record<(typeof REQUIRED_ENV)[number], string> | null { diff --git a/examples/supabase-worker/.env.example b/examples/supabase-worker/.env.example index 843f04ca..d4ec9784 100644 --- a/examples/supabase-worker/.env.example +++ b/examples/supabase-worker/.env.example @@ -1,10 +1,11 @@ # Copy to .env.local before running `supabase functions serve`. # Get these from your CipherStash workspace dashboard. -CS_WORKSPACE_CRN= +CS_CLIENT_ACCESS_KEY= CS_CLIENT_ID= CS_CLIENT_KEY= -CS_CLIENT_ACCESS_KEY= +CS_REGION=ap-southeast-2.aws -# Optional: defaults to ap-southeast-2.aws. -# CS_REGION=ap-southeast-2.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. diff --git a/examples/supabase-worker/README.md b/examples/supabase-worker/README.md index 5e497b08..d02775fe 100644 --- a/examples/supabase-worker/README.md +++ b/examples/supabase-worker/README.md @@ -6,14 +6,23 @@ The function imports from the `@cipherstash/stack/wasm-inline` subpath — the W ## Prerequisites -- A CipherStash workspace + client credentials (workspace CRN, client ID/key, access key) — see the [CipherStash docs](https://cipherstash.com/docs). +- 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_WORKSPACE_CRN, CS_CLIENT_ID, CS_CLIENT_KEY, CS_CLIENT_ACCESS_KEY +# 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 ``` @@ -43,8 +52,14 @@ supabase functions deploy cipherstash-roundtrip supabase secrets set --env-file .env.local ``` -## What this proves +## 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. + +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. diff --git a/examples/supabase-worker/package.json b/examples/supabase-worker/package.json index 26f65f2e..d15c3e76 100644 --- a/examples/supabase-worker/package.json +++ b/examples/supabase-worker/package.json @@ -4,6 +4,10 @@ "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" diff --git a/examples/supabase-worker/supabase/functions/cipherstash-roundtrip/index.ts b/examples/supabase-worker/supabase/functions/cipherstash-roundtrip/index.ts index eb080eff..cf7b8281 100644 --- a/examples/supabase-worker/supabase/functions/cipherstash-roundtrip/index.ts +++ b/examples/supabase-worker/supabase/functions/cipherstash-roundtrip/index.ts @@ -77,9 +77,13 @@ Deno.serve(async (_req: Request) => { { headers: { 'content-type': 'application/json' } }, ) } 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, stack: err.stack }, + { code: err.code, message: err.message }, { status: 500 }, ) } diff --git a/packages/stack/__tests__/wasm-inline-normalize.test.ts b/packages/stack/__tests__/wasm-inline-normalize.test.ts new file mode 100644 index 00000000..77afbb5e --- /dev/null +++ b/packages/stack/__tests__/wasm-inline-normalize.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' +import { castAsEnum, toEqlCastAs } from '../src/schema' +import { normalizeCastAs } from '../src/wasm-inline' + +// Exhaustive mapping the wasm-inline normaliser is expected to produce. +// If you add a variant to `castAsEnum`, add the corresponding EQL value +// here AND extend `toEqlCastAs()` — the test below catches the case +// where the enum gains a member but `toEqlCastAs` doesn't. +const EXPECTED_MAPPING: Readonly> = { + bigint: 'big_int', + boolean: 'boolean', + date: 'date', + number: 'double', + string: 'text', + json: 'jsonb', + text: 'text', +} + +describe('wasm-inline normalizeCastAs', () => { + it('maps every SDK-facing CastAs variant to its EQL equivalent', () => { + for (const sdkValue of castAsEnum.removeDefault().options) { + const eql = toEqlCastAs(sdkValue) + expect(eql, `toEqlCastAs("${sdkValue}") returned undefined`).toBeDefined() + expect(eql).toBe(EXPECTED_MAPPING[sdkValue]) + } + }) + + it('catches schema drift — every castAsEnum value is in EXPECTED_MAPPING', () => { + // If this fails, someone added a CastAs variant without extending + // EXPECTED_MAPPING + toEqlCastAs. Update both before the WASM path + // starts rejecting startup with "unknown variant 'null'". + const enumKeys = new Set(castAsEnum.removeDefault().options) + const expectedKeys = new Set(Object.keys(EXPECTED_MAPPING)) + expect(enumKeys, 'castAsEnum drifted from EXPECTED_MAPPING').toEqual( + expectedKeys, + ) + }) + + it('rewrites cast_as on every column in a multi-table config', () => { + const config = { + v: 1, + tables: { + users: { + email: { cast_as: 'string', indexes: { unique: {} } }, + age: { cast_as: 'number', indexes: {} }, + }, + orders: { + total: { cast_as: 'bigint', indexes: {} }, + }, + }, + } + + const out = normalizeCastAs(config) as { + v: number + tables: Record> + } + + expect(out.tables.users.email.cast_as).toBe('text') + expect(out.tables.users.age.cast_as).toBe('double') + expect(out.tables.orders.total.cast_as).toBe('big_int') + }) + + it('preserves columns that omit cast_as', () => { + const config = { + v: 1, + tables: { users: { email: { indexes: { unique: {} } } } }, + } + + const out = normalizeCastAs(config) as { + tables: { users: { email: Record } } + } + + expect(out.tables.users.email).toEqual({ indexes: { unique: {} } }) + expect('cast_as' in out.tables.users.email).toBe(false) + }) + + it('throws synchronously when toEqlCastAs returns undefined', () => { + // Simulate the drift scenario: a config carrying a cast_as value + // that toEqlCastAs doesn't recognise. The normaliser must throw + // with a clear message identifying the table.column, not hand + // `undefined` through to WASM serde. + const config = { + v: 1, + tables: { + users: { + // biome-ignore lint/suspicious/noExplicitAny: forcing drift for the test + weird: { cast_as: 'uuid' as any, indexes: {} }, + }, + }, + } + + expect(() => normalizeCastAs(config)).toThrowError( + /unrecognised cast_as value "uuid" on users\.weird/, + ) + }) +}) diff --git a/packages/stack/package.json b/packages/stack/package.json index 616ca294..22be2377 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -188,6 +188,7 @@ "./package.json": "./package.json" }, "scripts": { + "prebuild": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"", "build": "tsup", "dev": "tsup --watch", "test": "vitest run", diff --git a/packages/stack/src/wasm-inline.ts b/packages/stack/src/wasm-inline.ts index d1990b81..25be20ca 100644 --- a/packages/stack/src/wasm-inline.ts +++ b/packages/stack/src/wasm-inline.ts @@ -21,7 +21,7 @@ * const client = await Encryption({ * schemas: [users], * config: { - * region: "ap-southeast-2.aws", + * region: "ap-southeast-2.aws", * accessKey: Deno.env.get("CS_CLIENT_ACCESS_KEY")!, * clientId: Deno.env.get("CS_CLIENT_ID")!, * clientKey: Deno.env.get("CS_CLIENT_KEY")!, @@ -36,11 +36,9 @@ * ``` * * For runtimes that need a custom token store (e.g. cookies on a - * Supabase Edge Function), pass a pre-built strategy via - * `config.strategy`. The strategy type is structural — any object with - * `getToken(): Promise<{ token: string }>` works. Use - * `AccessKeyStrategy.create(...)` from `@cipherstash/auth/wasm-inline` - * to build one with a `cookieStore` from `@cipherstash/auth/cookies`. + * Supabase Edge Function), build the strategy yourself with + * `AccessKeyStrategy.create(region, accessKey, { store })` from + * `@cipherstash/auth/wasm-inline` and pass it via `config.strategy`. */ import { AccessKeyStrategy } from '@cipherstash/auth/wasm-inline' @@ -101,22 +99,46 @@ export function isEncrypted(value: unknown): boolean { // High-level `Encryption` factory + client. // ----------------------------------------------------------------------- -/** Default region used when `WasmClientConfig.region` is unset. */ -const DEFAULT_REGION = 'ap-southeast-2.aws' +/** + * The plaintext shape accepted by `encrypt` and returned by `decrypt`. + * Mirrors protect-ffi's `JsPlaintext` (recursive: arrays of any of + * these are valid). Re-defined here so the wasm-inline `.d.ts` doesn't + * pull in the Node-only protect-ffi types. + */ +export type WasmPlaintext = + | string + | number + | boolean + | Record + | WasmPlaintext[] /** * Config for {@link Encryption} on the WASM entry point. * - * Unlike the Node entry, the WASM path requires an explicit auth - * strategy. For service-to-service / CI use, pass `accessKey` plus the - * workspace `clientId` / `clientKey` and we construct an - * `AccessKeyStrategy` for you. To use a custom token store (e.g. - * cookies on a Supabase Edge Function), pass a pre-built `strategy` - * instead. + * Unlike the Node entry, the WASM path needs the region passed + * explicitly today (no default — workspace deployment region is a + * caller concern). For service-to-service / CI use, pass `accessKey` + * plus the workspace `clientId` / `clientKey` and we construct an + * `AccessKeyStrategy` for you. To plug in a custom token store + * (cookies on Supabase Edge, KV on Cloudflare Workers, …) build the + * strategy with `AccessKeyStrategy.create(region, accessKey, { store })` + * and hand it to `config.strategy` instead. + * + * NOTE: `region` will be removed in a future release. The strategy + * will then take a `workspaceCrn` and derive the region from it — + * single source of truth, with the bearer token's workspace asserted + * against the CRN. Plan accordingly; the field is required for now + * because the underlying `@cipherstash/auth/wasm-inline` + * `AccessKeyStrategy.create()` still takes a region argument. */ export type WasmClientConfig = { - /** CipherStash region, e.g. `"ap-southeast-2.aws"`. Defaults to ap-southeast-2.aws. */ - region?: string + /** + * CipherStash region, e.g. `"ap-southeast-2.aws"`. Required for now. + * @deprecated will be replaced by `workspaceCrn` once + * `@cipherstash/auth` switches `AccessKeyStrategy.create()` to derive + * region from a CRN. + */ + region: string /** Workspace client identifier — required by the WASM client. */ clientId: string /** Workspace client key — required by the WASM client. */ @@ -129,7 +151,7 @@ export type WasmClientConfig = { } | { accessKey?: never - strategy: { getToken(): Promise } + strategy: AccessKeyStrategy } ) @@ -141,24 +163,45 @@ export type WasmEncryptionConfig = { config: WasmClientConfig } +/** + * Internal token used to gate the {@link WasmEncryptionClient} + * constructor. Symbols are unique by reference, so external code can't + * forge one even if they recreate `WasmEncryptionClient` via type + * inspection. + */ +const INTERNAL_CONSTRUCT = Symbol('cs-wasm-client') + /** * WASM encryption client. Returned by {@link Encryption}. * - * Wraps an opaque {@link wasmNewClient} handle and exposes a minimal + * Wraps an opaque `wasmNewClient` handle and exposes a minimal * `encrypt` / `decrypt` surface. Larger surface (bulk, query, model * helpers) lives on the Node entry — port lazily as Deno / edge * consumers demand it. + * + * Construct via {@link Encryption} — the constructor is private to + * prevent callers from wrapping arbitrary objects in this type. */ export class WasmEncryptionClient { /** @internal */ private readonly client: unknown - constructor(client: unknown) { + private constructor(token: symbol, client: unknown) { + if (token !== INTERNAL_CONSTRUCT) { + throw new Error( + '[encryption]: WasmEncryptionClient cannot be constructed directly — use the Encryption() factory.', + ) + } this.client = client } + /** @internal */ + static __construct(client: unknown): WasmEncryptionClient { + return new WasmEncryptionClient(INTERNAL_CONSTRUCT, client) + } + async encrypt( - plaintext: string | number | boolean | Record, + plaintext: WasmPlaintext, opts: EncryptOptions, ): Promise { const ffiOpts = { @@ -172,10 +215,10 @@ export class WasmEncryptionClient { )) as Encrypted } - async decrypt(encrypted: Encrypted): Promise> { + async decrypt(encrypted: Encrypted): Promise { return (await wasmDecrypt(this.client as never, { ciphertext: encrypted, - } as never)) as string | number | boolean | Record + } as never)) as WasmPlaintext } isEncrypted(value: unknown): boolean { @@ -213,7 +256,7 @@ export async function Encryption( clientKey: clientConfig.clientKey, } as never) - return new WasmEncryptionClient(client) + return WasmEncryptionClient.__construct(client) } /** @@ -232,8 +275,10 @@ export async function Encryption( * function throws synchronously at startup with a clear message rather * than handing `undefined` to the WASM serde (which surfaces as an * opaque `unknown variant 'null'` error). + * + * @internal exported for unit-test coverage of the drift-guard branch. */ -function normalizeCastAs(config: EncryptConfig): unknown { +export function normalizeCastAs(config: EncryptConfig): unknown { const tables: Record> = {} for (const [tableName, columns] of Object.entries(config.tables)) { const normalised: Record = {} @@ -266,17 +311,8 @@ function getColumnName( ) } -function resolveStrategy( - cfg: WasmClientConfig, -): { getToken(): Promise } { +function resolveStrategy(cfg: WasmClientConfig): AccessKeyStrategy { if (cfg.strategy) return cfg.strategy - if (cfg.accessKey) { - return AccessKeyStrategy.create( - cfg.region ?? DEFAULT_REGION, - cfg.accessKey, - ) - } - throw new Error( - '[encryption]: WASM entry requires either `config.strategy` or `config.accessKey`', - ) + // Discriminated union guarantees this branch implies `accessKey` is set. + return AccessKeyStrategy.create(cfg.region, cfg.accessKey as string) } diff --git a/packages/stack/tsup.config.ts b/packages/stack/tsup.config.ts index c26682d9..98cd6b51 100644 --- a/packages/stack/tsup.config.ts +++ b/packages/stack/tsup.config.ts @@ -1,48 +1,55 @@ import { defineConfig } from 'tsup' -export default defineConfig({ - entry: [ - 'src/index.ts', - 'src/client.ts', - 'src/types-public.ts', - 'src/identity/index.ts', - 'src/secrets/index.ts', - 'src/schema/index.ts', - 'src/drizzle/index.ts', - 'src/dynamodb/index.ts', - 'src/supabase/index.ts', - 'src/encryption/index.ts', - 'src/errors/index.ts', - 'src/wasm-inline.ts', - ], - format: ['cjs', 'esm'], - sourcemap: true, - dts: true, - clean: true, - target: 'es2022', - tsconfig: './tsconfig.json', - external: ['drizzle-orm', '@supabase/supabase-js'], - // zod + @byteslice/result are bundled so dist/wasm-inline.js carries no - // bare-specifier transitive imports — important for Deno / Edge / - // browser consumers whose runtime won't resolve npm names without an - // explicit import map. Both are small (zod ~50 KB, result ~3 KB) and - // dependency-free, so bundling them into the Node entries too is fine. - noExternal: ['evlog', 'uuid', 'zod', '@byteslice/result'], - // Drop dist/wasm-inline.cjs after bundling — the protect-ffi - // wasm-inline runtime it transitively requires is ESM-only and - // crashes a Node CJS consumer with ERR_REQUIRE_ESM. The runtimes - // that need /wasm-inline (Deno, Bun, Workers, Supabase Edge, browsers) - // are ESM-first anyway, and package.json's exports map omits the - // `require` branch for ./wasm-inline so npm consumers never reach - // this path. The dual ESM + CJS d.ts pair stays so type-only CJS - // imports of stack's public surface still resolve. - onSuccess: async () => { - const { rm } = await import('node:fs/promises') - for (const file of [ - 'dist/wasm-inline.cjs', - 'dist/wasm-inline.cjs.map', - ]) { - await rm(file, { force: true }) - } +// Two configs run in parallel inside tsup. They share the same `dist/` +// output dir, so neither uses `clean: true` — a parallel-run race could +// otherwise wipe the other config's output. The pre-tsup `rimraf dist` +// in `package.json`'s build script clears the dir once before either +// starts. +export default defineConfig([ + // Main entries — dual ESM + CJS bundles. + { + entry: [ + 'src/index.ts', + 'src/client.ts', + 'src/types-public.ts', + 'src/identity/index.ts', + 'src/secrets/index.ts', + 'src/schema/index.ts', + 'src/drizzle/index.ts', + 'src/dynamodb/index.ts', + 'src/supabase/index.ts', + 'src/encryption/index.ts', + 'src/errors/index.ts', + ], + format: ['cjs', 'esm'], + sourcemap: true, + dts: true, + clean: false, + target: 'es2022', + tsconfig: './tsconfig.json', + external: ['drizzle-orm', '@supabase/supabase-js'], + // zod + @byteslice/result are bundled so dist/wasm-inline.js carries + // no bare-specifier transitive imports — important for Deno / Edge / + // browser consumers whose runtime won't resolve npm names without an + // explicit import map. + noExternal: ['evlog', 'uuid', 'zod', '@byteslice/result'], }, -}) + // WASM-inline entry — ESM only. The protect-ffi wasm-inline bundle is + // an ESM module that dynamically imports the inlined base64 WASM blob; + // it cannot be loaded via Node CJS `require()` (ERR_REQUIRE_ESM), and + // the only runtimes that need wasm-inline (Deno, Bun, Workers, + // Supabase Edge, browsers) are ESM-first anyway. `package.json`'s + // `./wasm-inline` export deliberately omits the `require` branch to + // match. + { + entry: { 'wasm-inline': 'src/wasm-inline.ts' }, + format: ['esm'], + sourcemap: true, + dts: { entry: { 'wasm-inline': 'src/wasm-inline.ts' } }, + clean: false, + target: 'es2022', + tsconfig: './tsconfig.json', + external: ['drizzle-orm', '@supabase/supabase-js'], + noExternal: ['evlog', 'uuid', 'zod', '@byteslice/result'], + }, +])