Skip to content

Add the browser export condition to @cipherstash/stack-supabase, with a live browser smoke test #805

Description

@tobyhede

Follow-up from the portable-encryptedSupabase design (#708). Blocked on #804.

Rewritten 2026-08-26 after an audit. The original text was written against a design draft that never merged, and named three files, constants and functions that have never existed in this repository — verified by scanning all 31,837 objects in the git database, not just reachable refs. Those steps are removed below. The original is preserved in the issue history.

Status: correct action today is to change nothing

The export map has no browser condition, and that is right. Without one, a browser bundler falls through to importdist/index.js → the native binding → a loud build failure. Do not "fix" it.

This was already decided once. Commit 054d4f4b names the browser export condition (#805), a live browser smoke test and browser guidance as deliberately out of scope. The condition was proposed and rejected in writing. This issue re-opens that decision; it is not unstarted work.

The block, now checkable in-tree

packages/protect-ffi was absorbed into this repo, so the blocker is a local file rather than an upstream unknown. This issue unblocks when the wasm32 arm of build_key_provider in packages/protect-ffi/crates/protect-ffi/src/client_options.rs stops requiring secret_key():

#[cfg(target_arch = "wasm32")]
pub(crate) fn build_key_provider(&self) -> Result<SecretKey, Error> {
    self.secret_key().ok_or_else(|| {
        Error::Credentials(
            "clientOpts.clientId and clientOpts.clientKey are required — this build has no \
             profile store to fall back to".to_string(),
        )
    })
}

#804 confirmed the requirement is real, is not a type over-declaration, and that clientKey is cryptographic key material. That is a Rust/protocol job in cipherstash-suite, not a JS one here.

Why the condition was cut

browser is matched before import/require when written first — Node's docs: "Within the exports object, key order is significant. During condition matching, earlier entries have higher priority."

The blast radius is wider than originally written. browser is set by:

  • jest-environment-jsdom (defaults customExportConditions to ['browser'])
  • Vite client builds (resolve.conditions defaults to ['module', 'browser', …])
  • webpack target: 'web' — i.e. every Next.js client bundle
  • esbuild --platform=browser
  • Rollup @rollup/plugin-node-resolve({ browser: true })
  • Parcel
  • Metro, but only for the web platform, not by default

One narrowing correction: Vitest + jsdom is not reliably affected. Vite 6+ resolves those environments through SSR conditions, which exclude browser (vitest-dev/vitest#8431, #7070 — both closed, #8431 as won't-fix, so the behaviour persists).

So the realistic failure is not "a jsdom test resolves oddly". It is a WASM crypto blob and a clientKey-shaped API silently entering a production client bundle.

Checklist when it lands

  1. Add "browser" as the first key of the "." entry in packages/stack-supabase/package.json, pointing at the wasm build. There is no workerd condition to place it ahead of — none has ever existed.
  2. Also give @cipherstash/stack a browser condition. stack-supabase/wasm-inline imports @cipherstash/stack/wasm-inline; a condition on the leaf alone leaves the graph resolving through a browser-less parent.
  3. Add the runtime assertion to packages/stack-supabase/__tests__/wasm-entry-edge-safety.test.ts. (package-exports.test.ts and NON_NODE_RUNTIMES do not exist and never have.)
  4. Add a typeof document refusal of config.accessKey. It belongs in packages/stack/src/wasm-inline.ts — probably resolveStrategynot in stack-supabase, which has no makeClient function and no accessKey field. Nothing was removed from that package; there is no guard to "reinstate".
  5. Give the refusal a remedy that is not "pass a native client" — not because an adapter cannot take one, but because @cipherstash/protect-ffi is a Node-API binary and no native client can exist in a browser at all.
  6. Do not write the OIDC-federation instruction the original step 5 called for. WasmClientConfig requires clientKey on every auth arm, so no browser-safe wasm client can be constructed #804 proved federation does not lift the clientKey requirement — the wasm arm errors regardless of strategy. That instruction would not work.
  7. Add browser to the runtime lists in the skill, README and AGENTS.md, and write a new changeset. (AGENTS.md:81 is correct as-is today and needs no edit until this lands.)
  8. Add the live browser smoke test. Genuinely greenfield — there is no playwright, puppeteer, karma, jsdom or happy-dom anywhere in the repo, and no vite.config.*. It must load the real inlined WASM, as e2e/wasm/ does under Deno.

Guard that must not regress

The original said the series ships a grep asserting no positive browser claim exists in any shipping artifact. It does not. No such guard exists on main. The nearest thing is a two-line JSON assertion scoped to packages/stack/package.json alone, on the unmerged #810 branch:

expect(packageJson.browser).toBeUndefined()
expect(JSON.stringify(packageJson.exports)).not.toContain('"browser"')

If that lands, update it — and note it does not cover stack-supabase at all.

This is a breaking change

Adding an exports key is normally additive. It is not when the key sorts ahead of existing keys and redirects an already-resolving specifier to a different artifact. For anyone whose tooling sets browser, the same import silently changes build: introspection disappears (schemas becomes mandatory), withLockContext() and audit() become throwers, bulkEncrypt is not forwarded, and require() stops working because the wasm entry is ESM-only. Same specifier, same range, different behaviour, no type error.

Prefer a named subpath. @cipherstash/stack-supabase/browser is opt-in, breaks nobody, and makes the major bump unnecessary. A major bump does not discharge the duty here: a consumer bumping a major expects to read a changelog, not to have their client bundle quietly acquire a WASM crypto blob.

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions