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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions