diff --git a/.changeset/supabase-docs-wasm-inline-entry.md b/.changeset/supabase-docs-wasm-inline-entry.md new file mode 100644 index 000000000..89d409733 --- /dev/null +++ b/.changeset/supabase-docs-wasm-inline-entry.md @@ -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. diff --git a/packages/stack-supabase/README.md b/packages/stack-supabase/README.md index f3a980ed8..ef973abb9 100644 --- a/packages/stack-supabase/README.md +++ b/packages/stack-supabase/README.md @@ -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 @@ -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

@@ -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 diff --git a/skills/stash-supabase/SKILL.md b/skills/stash-supabase/SKILL.md index 442fba453..f7e9c9be9 100644 --- a/skills/stash-supabase/SKILL.md +++ b/skills/stash-supabase/SKILL.md @@ -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. @@ -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 @@ -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). @@ -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 ` 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