Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/wasm-inline-client-key-not-browser-safe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@cipherstash/stack': patch
'stash': patch
---

Document that `@cipherstash/stack/wasm-inline` is server-side only, and pin the
reason against the core.

`WasmClientConfig` requires `clientId` and `clientKey` on every auth arm,
including the `authStrategy` (OIDC federation) arm. That read like an
over-declaration the SDK could relax — if federation alone sufficed, a browser
could hold a client without a workspace secret. It cannot. The core requires
both fields regardless of strategy, and loads `clientKey` as encryption key
material *before* it ever calls the auth strategy. Since `clientKey` is a
workspace secret, no configuration of this entry belongs in a browser bundle —
which is why this entry has no `browser` export condition, and will not get one
until the core changes.

No behaviour change. The types and runtime are unchanged; what changes is that
the constraint is now stated where callers meet it — `WasmClientConfig`, the
`stash-edge` skill, the `stash-encryption` entry-point table, and, where this
entry had been described as browser-capable, the `stash-supabase` skill and
the `supabase-worker` example — and enforced by contract tests that run
against the real WASM core instead of the mocks and stubs the rest of the wasm
suite uses.
31 changes: 31 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,37 @@ jobs:
- name: Typecheck the generated WASM declarations
run: pnpm --filter @cipherstash/protect-ffi run test:typecheck:wasm

# The `clientKey` contract test (#804). It loads the REAL WASM core
# instead of the stub in `vitest.shared.ts`, so it needs the step above:
# excluded from stack's default vitest config, which runs in
# `run-tests`, where the binding is built without `wasm: 'true'` and
# `dist/wasm/protect_ffi_inline.js` therefore does not exist — there the
# file failed to COLLECT, which is not a skip.
#
# Not the only suite that loads the real core, and this is not the only
# job that builds it for one — `integration-drizzle.yml` runs
# `packages/stack`'s `integration/wasm/**` the same way. It is here
# because it needs NOTHING else: no credentials, no database. Those
# suites' `globalSetup` requires both unconditionally and throws rather
# than skipping, and that job is path-filtered and fork-skipped besides,
# so hosting a core contract there would leave it unchecked on every diff
# those paths do not select. See
# `packages/stack/vitest.wasm-core.config.ts` for the long form.
#
# Before `Build stack` deliberately — it reads protect-ffi's output
# directly and none of stack's, so a core that changed its credential
# contract fails here rather than after another build.
#
# Offline despite the credentials in this job's env: every assertion
# lands during argument deserialisation or key loading, before any
# ZeroKMS / CTS call. It is here for the WASM build, not the secrets.
#
# `scripts/__tests__/wasm-core-contract-ci.test.mjs` fails if this step
# goes away while the exclusion stays — a suite that no job runs reads
# exactly like a suite that passes.
- name: Test the WASM core credential contract (stack)
run: pnpm exec turbo run test:wasm-core --filter @cipherstash/stack

# The Deno smoke tests import the locally-built dist/wasm-inline.js of
# BOTH packages via file URLs in e2e/wasm/deno.json — they need fresh
# builds. stack-supabase is here for `supabase-declared.test.ts`, which
Expand Down
4 changes: 3 additions & 1 deletion examples/supabase-worker/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@

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

**Server-side only.** That list is deliberately server-side: the entry requires `CS_CLIENT_KEY`, a workspace secret, on every auth path — including when you supply a per-user `authStrategy` — so it must not be bundled into a browser ([#804](https://github.com/cipherstash/stack/issues/804)).

## Prerequisites

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@
* 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).
* works in any V8-only SERVER runtime (Supabase Edge, Cloudflare Workers,
* Bun, Deno). Not the browser: the entry requires `CS_CLIENT_KEY`, a
* workspace secret, on every auth path (#804).
*
* Usage:
* cp ../../.env.example ../../.env.local # fill in your CS_* values
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* `@cipherstash/stack-supabase` declares no `browser` export condition (#804).
*
* The sibling of `packages/stack/__tests__/browser-export-condition.test.ts`,
* and it exists because this package has a `wasm-inline` entry of its own.
* `./wasm-inline` binds the WASM engine from `@cipherstash/stack/wasm-inline`,
* and that engine's core requires `clientKey` — a workspace secret — on every
* auth path, OIDC federation included. So the entry is edge-safe and NOT
* browser-safe, which is what the note at the bottom of `src/wasm-inline.ts`
* says ("it is not browser-safe (#804)"). Nothing enforced it here: the two
* facts a bundler complaint would tempt someone to reconcile — an ESM-only,
* native-free entry that nonetheless must not reach a browser — sit in
* different files.
*
* Separate from `wasm-entry-edge-safety.test.ts`, which asserts the same
* entry's EDGE safety by scanning the emitted bundle and therefore skips when
* `dist/` is absent. This one reads the manifest, so it must not skip: a
* `browser` condition is wrong whether or not anyone has built the package.
*/

import { readFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'

describe('@cipherstash/stack-supabase declares no browser build (#804)', () => {
it('has no `browser` export condition on any subpath', () => {
const packageJson = JSON.parse(
readFileSync(
path.resolve(fileURLToPath(import.meta.url), '../../package.json'),
'utf8',
),
) as { browser?: unknown; exports: Record<string, unknown> }

expect(packageJson.browser).toBeUndefined()
expect(JSON.stringify(packageJson.exports)).not.toContain('"browser"')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ import { describe, expect, it } from 'vitest'
* So this asserts on the emitted file. It is a build-output gate, and it skips
* when `dist/` is absent so `pnpm test` stays green without a prior build —
* the same shape as the adapter-kit edge-safety gate added in #799.
*
* Edge-safe is not browser-safe: the WASM engine this entry binds still needs
* a `clientKey`, which is a workspace secret. That half is asserted in
* `browser-export-condition.test.ts`, a sibling rather than a case here
* because it reads the manifest and so must NOT skip on an unbuilt tree
* (#804).
*/

const DIST = resolve(dirname(fileURLToPath(import.meta.url)), '../dist')
Expand Down
52 changes: 52 additions & 0 deletions packages/stack/__tests__/browser-export-condition.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* `@cipherstash/stack` declares no `browser` export condition (#804).
*
* The consequence of the WASM core's credential contract, and the one part of
* it a reader can undo by accident. The core requires `clientId` AND
* `clientKey` on EVERY auth path — including OIDC federation, the arm that
* exists so a caller never handles a workspace secret — so
* `@cipherstash/stack/wasm-inline` is not browser-safe. That contract is
* asserted against the real core in
* `__tests__/wasm-inline-core-credential-contract.test.ts`; this file asserts
* the packaging that follows from it.
*
* `src/wasm-inline.ts` tells callers there is no `browser` condition and
* explains why. Nothing enforced it, so adding one to quiet a bundler
* complaint would ship a workspace secret to the browser and leave that
* doc silently wrong.
*
* WHY IT LIVES HERE and not with the contract file. It reads a manifest. It
* needs no WASM build, no credentials and no database, so it belongs in the
* suite every contributor runs. The contract file needs wasm-pack output that
* `pnpm install` does not produce, which is why it is excluded from
* `vitest.config.ts` and run by one CI job — and while this assertion lived
* inside it, it was checked by no local `pnpm --filter @cipherstash/stack
* test`, and on a fork PR by nothing at all (`wasm-e2e-tests` and `run-tests`
* both hard-fail at `require-cs-secrets` there; `lint` runs only Biome).
* `scripts/__tests__/wasm-core-contract-ci.test.mjs` holds it in the default
* suite.
*
* Same rule as the contract file: if the core stops requiring `clientKey`,
* come back through #804 — don't just delete this. The `browser` export
* condition (#805), a live browser smoke test and browser guidance in
* `skills/stash-supabase/SKILL.md` are all blocked on that and nothing else.
*/

import { readFileSync } from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'

describe('@cipherstash/stack declares no browser build (#804)', () => {
it('has no `browser` export condition on any subpath', () => {
const packageJson = JSON.parse(
readFileSync(
path.resolve(fileURLToPath(import.meta.url), '../../package.json'),
'utf8',
),
) as { browser?: unknown; exports: Record<string, unknown> }

expect(packageJson.browser).toBeUndefined()
expect(JSON.stringify(packageJson.exports)).not.toContain('"browser"')
})
})
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
/**
* Test stub for `@cipherstash/protect-ffi/wasm-inline`.
*
* The installed `@cipherstash/protect-ffi` only exports `.` — the `/wasm-inline`
* subpath does not exist, so Vitest cannot resolve `src/wasm-inline` (which
* imports it). These no-op stubs let the unit tests that only exercise pure
* helpers (`getColumnName`, `normalizeCastAs`) load the module. Aliased in via
* `vitest.config.ts`. Any test that actually needs WASM behaviour must mock it
* explicitly (see `wasm-inline-column-name.test.ts`).
* These no-op stubs let the unit tests that only exercise pure helpers
* (`getColumnName`, `normalizeCastAs`) load `src/wasm-inline` without paying
* for the real 4MB inlined-WASM module. Aliased in via `vitest.shared.ts`
* (`stackSourceAlias`). Any test that actually needs WASM behaviour must mock
* it explicitly (see `wasm-inline-column-name.test.ts`).
*
* The alias is a convenience, not a necessity: `@cipherstash/protect-ffi`
* does export `./wasm-inline` as of 0.30.0, and
* `wasm-inline-core-credential-contract.test.ts` deliberately bypasses this
* stub to assert against the real core.
*/
export const decrypt = (): never => {
throw new Error(
Expand Down
Loading
Loading