Skip to content
184 changes: 184 additions & 0 deletions .changeset/supabase-docs-wasm-inline-entry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
---
'@cipherstash/stack-supabase': patch
'stash': patch
'@cipherstash/wizard': patch
---

Stop telling customers the Supabase wrapper cannot run in a Worker.

`@cipherstash/stack-supabase` has shipped two entry points since #912. The
package root introspects your database and runs on Node; the `wasm-inline`
entry carries the WASM engine, takes declared `schemas` instead of
introspecting, and runs on Deno, Supabase Edge Functions and Cloudflare
Workers. Introspection was the only thing that needed a Postgres socket, and
that entry does not do it.

Two shipping documents were never updated and still described the state before
that change:

- `packages/stack-supabase/README.md` said "the factory cannot run in an edge
Worker or the browser" and did not mention the `wasm-inline` entry anywhere
in the file. This is the npm package page.
- `skills/stash-supabase/SKILL.md` said the same thing in its setup section.
The skill ships inside the `stash` tarball, and `stash init` copies it into
the customer's own repository, where their coding agent reads it as
instruction. The one correct mention of the edge entry was in a callout near
the top that the setup steps never pointed at, so a reader following the
setup never learned the second entry existed.

The population this misled hardest is the one that needs the edge entry most:
server code on Lovable, v0, Bolt and Replit runs on an edge runtime, which is
exactly the case `wasm-inline` was built for and exactly the case these
documents called impossible.

Both files now describe both entries. The README gains an "Edge runtimes"
section with the call shape; the skill gains a fifth setup step with the same,
and the introspection paragraph now scopes its restriction to the native entry
and points there. Both name the four ways the edge entry differs: `schemas` is
required, `config` is required, `databaseUrl` is refused, and
`.withLockContext()` / `.audit()` throw rather than silently dropping an
identity claim (#797).

The **browser** half of the old sentence was correct and is kept, with the
reason now given: the WASM client requires a workspace `clientKey` on every
authentication path, so a browser build would ship the key with it (#804).

Four claims that were wrong in the same neighbourhood are corrected while we
are here, three of them pre-dating this change:

- **`skills/stash-managed-platforms/SKILL.md` shipped a snippet that does not
compile.** It authored the `schemas` object from `@cipherstash/stack/wasm-inline`
and handed it to `encryptedSupabase` from `@cipherstash/stack-supabase/wasm-inline`.
The adapter types `schemas` from `@cipherstash/stack/eql/v3`, and the two
entries ship independent declarations of the column classes whose private
`columnName` field TypeScript compares nominally — so `tsc` rejects it while
the code runs perfectly, which is why nobody noticed. The schema import now
comes from `eql/v3`, and a new guard
(`scripts/__tests__/skills-supabase-edge-schema-entry.test.mjs`) fails if any
shipped document pairs the two again.
- **`skills/stash-edge/SKILL.md` is what produced that snippet.** Its "Schema
Modules Do Not Cross Entries" section told edge projects to author schemas
from `@cipherstash/stack/wasm-inline` with no carve-out. The rule is really
"author against the entry whose *client type* consumes the schema": a raw
`Encryption` client from `wasm-inline` wants `wasm-inline` tables, but the
Supabase adapter wants `eql/v3` tables on both of its entries, WASM engine or
not. The section now says so, and the "The Supabase adapter has its own edge
entry" note points at it.
- **`skills/stash-supabase/SKILL.md` described the wrong failure mode for a
missing declaration.** Omitting `schemas` on the edge entry cannot produce a
no-column client — it is non-optional on the type and throws at construction —
and an undeclared *table* throws rather than passing through unencrypted. The
hazard is an undeclared **column** on a declared table, which is treated as
plaintext; the bullet now says that, along with the one thing that limits it
(a plaintext write to an `eql_v3_*` column fails the domain CHECK, though a
NULL still passes) and the fact that the native entry's warning about
unverified declarations is gated on the introspector and so never fires
there. Reads get no equivalent backstop, and the bullet now says so: the
`select('*')` refusal looks like one, but a query awaited with no
`.select()` at all takes the raw-`*` branch in `query-builder.ts` and
returns every column undecrypted.
- **"Undeclared tables behave exactly as with no `schemas` at all" was false on
the native entry too.** Introspection is gated on a resolved database URL,
not on the absence of `schemas`, and an ambient `DATABASE_URL` is
deliberately ignored once tables are declared. The statement holds only when
`databaseUrl` is passed *alongside* `schemas`, which is what it now says.

`config` is corrected everywhere that called all four `CS_*` values mandatory:
the README, `skills/stash-supabase/SKILL.md`, and — in `skills/stash-edge/SKILL.md`
— its frontmatter description, its Credentials section, its troubleshooting
advice, and the native-vs-WASM comparison table. That skill already contradicted
itself, since its own `config.authStrategy` example passes two values, not four.
The same sentence in the `EncryptedSupabaseWasmOptions` doc comment
(`packages/stack-supabase/src/wasm-inline.ts`) is fixed too, comment-only. Only
`clientId` and `clientKey` are always required. Beyond them the config is a union — the
access-key path adds `workspaceCrn` + `accessKey`, and the strategy path takes
a pre-built `config.authStrategy` and makes `workspaceCrn` optional, because a
built strategy already carries the CRN. `OidcFederationStrategy` is re-exported
from `@cipherstash/stack/wasm-inline`, so authenticating as the end user works
on the edge; what does not work is binding data to that user, which stays
called out in its own `.withLockContext()` bullet.

Tests now anchor the corrected claims against the code rather than against
prose. `packages/stack-supabase/__tests__/supabase-wasm-config.test-d.ts` asserts
at the type level that the edge `config` accepts both the access-key arm and a
strategy-only arm without `workspaceCrn`, and rejects `clientId` + `clientKey`
alone. `supabase-declared-mode.test.ts` gains a case pinning the real hazard: an
undeclared column on a declared table reaches PostgREST as plaintext on insert,
update and filter, and is absent from the decrypt call.

Review found four more, one of them a change to a published type:

- **`databaseUrl` was only refused for callers who wrote the options inline.**
`EncryptedSupabaseWasmOptions` left the field out, and omission is policed by
excess-property checking, which fires on fresh object literals alone. An
options object assembled as a `const` and passed by variable — which is what
a Node-to-edge port actually holds — type-checked clean and reached the
construction-time throw instead, from documents saying the type checker
enforced it. The field is now declared `databaseUrl?: never`, mirroring
`WasmClientConfig.eqlVersion?: never` in `@cipherstash/stack`, which exists
for the identical reason one package along. The runtime throw stays as the
backstop for plain JS. New type tests cover the inline and by-variable
shapes on both call forms, plus a positive control that the same options
object still compiles once `databaseUrl` is dropped.
- **The `select('*')` correction had landed in only one of its two shipped
copies.** `skills/stash-supabase/SKILL.md` carried it;
`skills/stash-managed-platforms/SKILL.md`, edited in the same change, still
framed declared mode as giving things up "loudly rather than silently" over a
bullet naming the refusal — precisely the inference the correction exists to
kill. That skill is read as instruction by an agent on Lovable, v0, Bolt or
Replit, and the failure it mispromised is silent: raw EQL payloads returned
as `data`, no error. The wording is carried across, and a new guard
(`scripts/__tests__/skills-select-star-not-a-read-backstop.test.mjs`) fails
if any shipped document states the refusal without the caveat in the same
section. Two copies of one fact drift the moment one of them is edited, and
no reviewer diff shows the copy nobody touched.
- **"Everything after construction is the same wrapper" was false in the first
way a reader hits it.** Both `packages/stack-supabase/README.md` and
`skills/stash-supabase/SKILL.md` said `from()`, the filters and the response
shape are identical across the two entries — the README saying so twenty-five
lines under a paragraph telling the same reader `select('*')` just works. The
edge entry is always in declared mode, where `select('*')` is refused and
`from()` on an undeclared table throws. Both sentences now name the two
exceptions, so the quick-start snippet the section tells you to port no
longer arrives with a promise it breaks.
- **The ambient-`DATABASE_URL` warning cannot fire on the edge entry.**
`skills/stash-managed-platforms/SKILL.md` said an ambient `DATABASE_URL` is
ignored when `schemas` are passed, "with a warning that the declaration is
unverified" — in a section whose subject is `wasm-inline`. Both the ambient
read and the warning are gated on the introspector, which that build does not
have, so nothing there ever tells you a declaration is incomplete. Now scoped
to Node, with the edge case stated. The sentence immediately above it had the
same fault and is fixed with it: the ⚠️ callout on undeclared columns offered
"pass `databaseUrl` so introspection fills the gaps" as the remedy, inside a
section about the entry that refuses `databaseUrl` — and it is the remedy a
Lovable or Replit agent, which has only the edge entry, would have reached
for. It now says introspection is unavailable there and what to do instead.

`@cipherstash/wizard` is bumped alongside `stash` because `skills/` ships in
both tarballs — `packages/wizard/tsup.config.ts` copies it into `dist/skills`
and `package.json` lists that under `files`. Without the bump the published
wizard keeps shipping the old text until some unrelated change moves its
version.

One more wording correction, of the kind #952 fixed in this package's `.d.ts`:
`packages/stack-supabase/README.md` and `skills/stash-supabase/SKILL.md` both
derived "runs on Node only" from introspection — "introspection needs a direct
Postgres connection, **so** … this entry runs on Node only". Introspection is
not the cause. The entry binds the native engine, so it is Node-only whether or
not you declare `schemas`; a reader who took the stated cause at face value
would conclude that declaring tables makes the root entry edge-capable, which is
the exact wrong turn the `wasm-inline` entry exists to prevent. Both sentences
now attribute the restriction to the engine and say that declaring `schemas`
does not move it. Both files are enrolled in #952's
`scripts/__tests__/supabase-runtime-claims.test.mjs`, which is what its own
comment said to do once this branch stopped rewriting the same lines — the
README and this skill are the two copies that reach a customer, and the guard
now fails if either grows the claim back. `skills/stash-managed-platforms/SKILL.md`
stays out, with the reason written down: its causal claims are correct, but
rewording the unqualified "Worker" in its frontmatter `description` changes
what the skill matches on, which is not a rider on a documentation fix.

No runtime behaviour changes. The one non-documentation change is the
`databaseUrl?: never` field on `EncryptedSupabaseWasmOptions`, which is
type-level: it rejects at compile time a call that already threw at
construction.
60 changes: 57 additions & 3 deletions packages/stack-supabase/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,10 @@ Full guide: [Supabase quickstart →][supabase-docs]
config to maintain — `select('*')` just works, inserts and updates encrypt automatically, and
reads decrypt automatically.

Introspection needs a direct Postgres connection (`DATABASE_URL`), so `pg` is an optional peer
dependency and the factory cannot run in an edge Worker or the browser — construct it in your
server-side code.
Introspection needs a direct Postgres connection (`DATABASE_URL`), which is why `pg` is an
optional peer dependency. This entry runs on Node only either way — it binds the native engine,
and declaring `schemas` doesn't move that — so construct it in your server-side code. For an
edge runtime, see the second entry point below.

It runs alongside Supabase Auth and RLS, and supports
[identity-locking encryption][identity] — binding a row's data key to the signed-in user's
Expand All @@ -98,6 +99,57 @@ JWT claim — via the same lock-context API as the rest of the Stack.
> `encryptedSupabaseV3` remains as a `@deprecated`, type-identical alias of `encryptedSupabase`,
> so existing imports keep working.

## Edge runtimes: `@cipherstash/stack-supabase/wasm-inline`

Deno, Supabase Edge Functions and Cloudflare Workers cannot load a native module or open a raw
Postgres socket. The `wasm-inline` entry point has neither requirement: the encryption engine is
a WASM blob inlined into the bundle, and you declare your tables instead of introspecting them.

| Entry point | Engine | Schema | Runs on |
| --- | --- | --- | --- |
| `@cipherstash/stack-supabase` | native | introspected from the `public.eql_v3_*` domains | Node |
| `@cipherstash/stack-supabase/wasm-inline` | WASM, inlined | declared — `schemas` is required | Deno, Supabase Edge Functions, Cloudflare Workers |

After construction the wrapper behaves the same — the filters and the response shape are
identical — with two exceptions. Declared mode refuses `select('*')` and bare `select()`, so name
the columns you want; that refusal is not a read backstop, because a query awaited with no
`.select()` at all still returns every column undecrypted. And `from()` on a table you did not
declare throws, because there is no introspected table list to fall back on.

```ts
import { encryptedTable, types } from '@cipherstash/stack/eql/v3'
import { encryptedSupabase } from '@cipherstash/stack-supabase/wasm-inline'

const users = encryptedTable('users', { email: types.TextSearch('email') })

const es = await encryptedSupabase(supabaseUrl, supabaseKey, {
schemas: { users },
config: {
workspaceCrn: Deno.env.get('CS_WORKSPACE_CRN')!,
accessKey: Deno.env.get('CS_CLIENT_ACCESS_KEY')!,
clientId: Deno.env.get('CS_CLIENT_ID')!,
clientKey: Deno.env.get('CS_CLIENT_KEY')!,
},
})
```

Four differences from the entry above, three of them enforced by the type checker: `schemas` is
required, because nothing introspects here; `config` is required, because there is no
`~/.cipherstash` on an edge runtime to discover credentials from; `databaseUrl` is refused; and
`.withLockContext()` / `.audit()` throw rather than silently dropping the identity claim — the
WASM engine does not implement them yet ([#797][issue-797]).

`config` always needs `clientId` and `clientKey`. Past those it is a union: pass
`workspaceCrn` + `accessKey` for the access-key path shown above, or a pre-built
`config.authStrategy` — `AccessKeyStrategy` or `OidcFederationStrategy`, both re-exported from
`@cipherstash/stack/wasm-inline` — which already carries the CRN and so makes `workspaceCrn`
optional. Authenticating as the end user over OIDC federation therefore works on the edge; what
does not is binding data to that user with `.withLockContext()`.

This entry is ESM-only, and it is server-side rather than browser-safe: the WASM client requires
a workspace `clientKey` on every authentication path, so a browser build would ship the key with
it ([#804][issue-804]).

## How it works

<p align="center">
Expand Down Expand Up @@ -132,3 +184,5 @@ it should, and the EQL install needs no superuser (it works on cloud-hosted Supa
[eql]: https://github.com/cipherstash/encrypt-query-language
[stack-drizzle]: https://www.npmjs.com/package/@cipherstash/stack-drizzle
[stack-prisma]: https://www.npmjs.com/package/@cipherstash/stack-prisma
[issue-797]: https://github.com/cipherstash/stack/issues/797
[issue-804]: https://github.com/cipherstash/stack/issues/804
Loading
Loading