Skip to content
Draft
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
45 changes: 45 additions & 0 deletions .changeset/supabase-docs-wasm-inline-entry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
'@cipherstash/stack-supabase': patch
'stash': 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 and must carry all four `CS_*` values,
`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).

Documentation only — no runtime behaviour changes.
47 changes: 45 additions & 2 deletions packages/stack-supabase/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,8 @@ config to maintain — `select('*')` just works, inserts and updates encrypt aut
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.
dependency and this entry runs on Node only — 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 +98,47 @@ 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 |

Everything after construction is the same wrapper — `from()`, the filters, and the response
shape are identical.

```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 and must carry all four `CS_*`
values, because there is no `~/.cipherstash` on an edge runtime; `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]).

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 +173,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
75 changes: 69 additions & 6 deletions skills/stash-supabase/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ selects, with support for equality, range, and ordering.
- Using identity-aware encryption (lock contexts) with Supabase
- Building applications where sensitive columns need encryption at rest and in transit

> **On a managed AI platform — Lovable, v0, Bolt, Replit — read `stash-managed-platforms` first.** Two things there are decided before anything on this page applies: server code runs on an edge runtime, so it needs `@cipherstash/stack/wasm-inline` (`@cipherstash/protect` is the deprecated predecessor and its native module will not load — that dead end has cost an agent a whole turn), and the database role is not `postgres`, which changes how EQL gets installed. `encryptedSupabase` can be constructed inside a Worker, but only from the `@cipherstash/stack-supabase/wasm-inline` entry and only with declared `schemas` — introspection is what needs a Postgres connection, and declaring your tables is what skips it.
> **On a managed AI platform — Lovable, v0, Bolt, Replit — read `stash-managed-platforms` first.** Two things there are decided before anything on this page applies: server code runs on an edge runtime, so it needs `@cipherstash/stack/wasm-inline` (`@cipherstash/protect` is the deprecated predecessor and its native module will not load — that dead end has cost an agent a whole turn), and the database role is not `postgres`, which changes how EQL gets installed. `encryptedSupabase` can be constructed inside a Worker, but only from the `@cipherstash/stack-supabase/wasm-inline` entry and only with declared `schemas` — introspection is what needs a Postgres connection, and declaring your tables is what skips it. Step 5 of Setup has the call shape.

> **What survives PostgREST, in one line** (the full treatment is under [Query behaviour on encrypted columns](#query-behaviour-on-encrypted-columns), a long way down): `eq` / `neq` / `in` / `match()` and the range filters `gt` / `gte` / `lt` / `lte` **do** work on capable domains, and so does `order()` on OPE-backed ordering columns. Encrypted free-text `matches()` and encrypted-JSON `contains()` / `selectorEq()` / `selectorNe()` **do not** — they need `eql_v3.query_*` casts PostgREST cannot emit, and the wrapper fails fast rather than returning wrong rows. Agents guess wrong in both directions on this, so don't infer it; for the predicates that don't survive, use Drizzle, Prisma Next, or SQL in an RPC.

Expand Down Expand Up @@ -67,10 +67,12 @@ this is also how **Supabase Edge Functions** get credentials in local dev —
> needs only a grant — so a reader granted the writer's keyset but bound to
> a different one decrypts fine while its searches silently return zero
> rows. `stash-zerokms` is canonical for keyset scoping, `stash-auth` for
> credentials. Encryption *inside* an
> Edge Function (Deno, no native modules) uses the
> `@cipherstash/stack/wasm-inline` entry — see the `stash-edge` skill; SQL
> written by hand in a migration or RPC is covered by `stash-postgres`.
> credentials. Inside an
> Edge Function (Deno, no native modules) the wrapper comes from
> `@cipherstash/stack-supabase/wasm-inline` — step 5 below. For encryption
> without the Supabase wrapper it is `@cipherstash/stack/wasm-inline`, see the
> `stash-edge` skill; SQL written by hand in a migration or RPC is covered by
> `stash-postgres`.

### 1. Install EQL v3 on the database

Expand Down Expand Up @@ -266,7 +268,9 @@ detects EQL v3 columns by their Postgres domain, derives each column's
encryption config from the domain, and builds the encryption client
internally — there is no client-side schema to hand-maintain. Introspection
needs a direct Postgres connection (`options.databaseUrl`, defaulting to
`DATABASE_URL`), so the factory cannot run in a Worker or the browser.
`DATABASE_URL`), and the engine is a native module, so **this entry runs on
Node only**. On an edge runtime, import the `wasm-inline` entry instead —
step 5 below.

Options: `{ schemas?, databaseUrl?, config? }` — `config` is the encryption
client config (e.g. `config.authStrategy`, see Authentication below).
Expand Down Expand Up @@ -310,6 +314,65 @@ A JS property may map to a different DB column name
are translated automatically, and `date`/`timestamp` columns decrypt to real
`Date` objects.

### 5. Edge runtimes — the `wasm-inline` entry

Deno, Supabase Edge Functions and Cloudflare Workers cannot load a native
module and cannot open a raw Postgres socket. Both of those are properties of
the entry above, not of the wrapper, so the package ships a second entry that
has neither:

| Entry | Engine | Schema | Runs on |
|---|---|---|---|
| `@cipherstash/stack-supabase` | native | introspected | Node |
| `@cipherstash/stack-supabase/wasm-inline` | WASM, inlined into the bundle | declared — `schemas` is required | Deno, Supabase Edge Functions, Cloudflare Workers |

Everything after construction is the same wrapper: `from()`, the filters, the
transforms, and the response shape are identical.

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

const users = encryptedTable("users", {
email: types.TextSearch("email"),
amount: types.IntegerOrd("amount"),
})

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")!,
},
})

await es.from("users").select("id, email").eq("email", "a@b.com")
```

Four differences from the native entry, three of them enforced by the type
checker:

- **`schemas` is required.** Nothing introspects here, so a client built
without a declaration has no columns and encrypts nothing. Declare every
table you touch — undeclared tables pass through unencrypted.
- **`config` is required, and must carry all four `CS_*` values.** There is no
`~/.cipherstash` on an edge runtime to discover credentials from. Mint them
with `stash env --name <name>` and set them with `supabase secrets set`, or
pass `--env-file` for `supabase functions serve`.
- **`databaseUrl` is not accepted.** It is refused at runtime and absent from
the type.
- **`.withLockContext()` and `.audit()` throw.** Identity-bound encryption is
not implemented on the WASM engine (cipherstash/stack#797); the entry fails
loudly rather than dropping the identity claim and writing a value any
keyset holder could decrypt. If you need lock contexts, that path stays on
Node.

The entry is ESM-only. It is server-side, not browser-safe — the WASM client
requires a workspace `clientKey` on every authentication path, so shipping one
to a browser would ship the key with it (cipherstash/stack#804).

## Insert (Encrypted Automatically)

```typescript
Expand Down
Loading