From 2733d25d8aabd4e7570712b33cee9bcd06607abb Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 27 Aug 2026 09:20:59 +1000 Subject: [PATCH 1/3] docs(supabase): document the wasm-inline entry in the two files that ship MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@cipherstash/stack-supabase` has had two entry points since #912. The package root introspects the database and runs on Node; `/wasm-inline` 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 needing a Postgres socket, so that entry does run in a Worker. Two shipping documents were never updated and still describe the state before that change: - `packages/stack-supabase/README.md` said the factory "cannot run in an edge Worker or the browser", and contained no occurrence of the word "wasm" at all. This file renders on the npm package page. - `skills/stash-supabase/SKILL.md` said the same in its setup section. The skill ships inside the `stash` tarball and `stash init` copies it into the customer's repo, where their agent reads it as instruction. The file's one correct mention of the edge entry sat in a callout the setup steps never pointed at, so a reader following the steps never learned it existed. The readers this misled hardest are the ones who need the edge entry most: server code on Lovable, v0, Bolt and Replit runs on an edge runtime, which is exactly what `/wasm-inline` was built for. The failure is silent — an agent that reads "cannot run in a Worker" concludes the product does not support the platform and stops. Both files now carry an entry-point table and the edge call shape, and name the four ways that entry differs, each checked against the source rather than restated: `schemas` is required (`create.ts:307-313`), `config` is required and carries all four `CS_*` values (`wasm-inline.ts:18-30`), `databaseUrl` throws at construction (`create.ts:359-361`), and `.withLockContext()` / `.audit()` throw rather than silently dropping an identity claim (`wasm-client-adapter.ts:40-66`, #797). The browser half of the old sentence was correct and is kept, with its reason named: the WASM client requires a workspace `clientKey` on every authentication path, so a browser build would ship the key with it (#804). Deliberately untouched: `packages/stack/CHANGELOG.md`, `packages/stack-supabase/CHANGELOG.md` and the superpowers design spec carry the same sentence and are historical records, accurate for their dates. `docs/reference/supabase-sdk.md` carries it too and is fixed separately — it is internal and ships in no package. Changeset: `stash` patch for the skill, `@cipherstash/stack-supabase` patch for the README. Closes #950 Refs #912, #804, #797 Claude-Session: https://claude.ai/code/session_01E1J2nVGJWVkqvLepDfinRf --- .changeset/supabase-docs-wasm-inline-entry.md | 45 +++++++++++ packages/stack-supabase/README.md | 47 +++++++++++- skills/stash-supabase/SKILL.md | 75 +++++++++++++++++-- 3 files changed, 159 insertions(+), 8 deletions(-) create mode 100644 .changeset/supabase-docs-wasm-inline-entry.md 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 From 981f86a3d9a1d27f02c54fcd1bdacbcb3e52ff5f Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 31 Aug 2026 09:42:40 +1000 Subject: [PATCH 2/3] fix(skills): correct false edge-entry claims and guard the schema import MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of #951 found four wrong statements in files that ship to customers. Verified each against source before fixing; one was misattributed and the real defect turned out to be older and elsewhere. - `skills/stash-managed-platforms/SKILL.md` shipped a snippet that does not compile: it authored `schemas` from `@cipherstash/stack/wasm-inline` and handed them to `encryptedSupabase`, whose `schemas` is typed from `@cipherstash/stack/eql/v3`. 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 fine. Nothing caught it — `column-map.ts` probes structurally by design, and `e2e/wasm/deno.json` runs `deno test --no-check`. - `skills/stash-edge/SKILL.md` is what produced that snippet: it told edge projects to author schemas from the WASM entry with no carve-out. The rule is really "author against the entry whose client type consumes the schema". - `skills/stash-supabase/SKILL.md` named the wrong hazard. An undeclared table throws and a missing `schemas` cannot construct; what is silently treated as plaintext is an undeclared *column* on a declared table. - "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`. - "config must carry all four `CS_*` values" was wrong in five places. `WasmClientConfig` is a union: only `clientId` and `clientKey` are always required, and the `authStrategy` arm makes `workspaceCrn` optional. OIDC federation does work on the edge; `.withLockContext()` is what does not. `stash-edge` already contradicted itself here — its own example passes two. Three tests keep these honest, each confirmed to fail when its property is violated: - `scripts/__tests__/skills-supabase-edge-schema-entry.test.mjs` fails if any shipped document pairs the adapter's edge entry with a WASM-entry schema. - `supabase-wasm-config.test-d.ts` pins the config union at the type level. - `supabase-declared-mode.test.ts` pins the undeclared-column plaintext path across insert, update, filter, select and decrypt. Documentation and tests only — no runtime behaviour changes. Claude-Session: https://claude.ai/code/session_01UqnsDeKs8N9n9TStPAqzzw --- .changeset/supabase-docs-wasm-inline-entry.md | 68 +++++++- packages/stack-supabase/README.md | 11 +- .../__tests__/supabase-declared-mode.test.ts | 143 ++++++++++++++++ .../__tests__/supabase-wasm-config.test-d.ts | 140 +++++++++++++++ packages/stack-supabase/src/wasm-inline.ts | 12 +- ...skills-supabase-edge-schema-entry.test.mjs | 159 ++++++++++++++++++ skills/stash-edge/SKILL.md | 53 ++++-- skills/stash-managed-platforms/SKILL.md | 8 +- skills/stash-supabase/SKILL.md | 52 ++++-- 9 files changed, 610 insertions(+), 36 deletions(-) create mode 100644 packages/stack-supabase/__tests__/supabase-wasm-config.test-d.ts create mode 100644 scripts/__tests__/skills-supabase-edge-schema-entry.test.mjs diff --git a/.changeset/supabase-docs-wasm-inline-entry.md b/.changeset/supabase-docs-wasm-inline-entry.md index 89d409733..c174aee4c 100644 --- a/.changeset/supabase-docs-wasm-inline-entry.md +++ b/.changeset/supabase-docs-wasm-inline-entry.md @@ -34,12 +34,72 @@ 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). +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). -Documentation only — no runtime behaviour changes. +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 two things that limit it + (`select('*')` is refused in declared mode, and a plaintext write to an + `eql_v3_*` column fails the domain CHECK) and the fact that the native + entry's warning about unverified declarations is gated on the introspector + and so never fires there. +- **"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. + +Two 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. + +Documentation and tests only — no runtime behaviour changes. diff --git a/packages/stack-supabase/README.md b/packages/stack-supabase/README.md index ef973abb9..0dad431d6 100644 --- a/packages/stack-supabase/README.md +++ b/packages/stack-supabase/README.md @@ -130,11 +130,18 @@ const es = await encryptedSupabase(supabaseUrl, supabaseKey, { ``` 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 +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 of two paths: 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]). diff --git a/packages/stack-supabase/__tests__/supabase-declared-mode.test.ts b/packages/stack-supabase/__tests__/supabase-declared-mode.test.ts index e8ce4ca57..93cc43106 100644 --- a/packages/stack-supabase/__tests__/supabase-declared-mode.test.ts +++ b/packages/stack-supabase/__tests__/supabase-declared-mode.test.ts @@ -2,6 +2,12 @@ import { encryptedTable, types } from '@cipherstash/stack/eql/v3' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { makeEncryptedSupabase } from '../src/create' import type { SupabaseClientLike } from '../src/types' +import { + createMockEncryptionClient, + createMockSupabase, + fakeEnvelope, + isFakeEnvelope, +} from './helpers/supabase-mock' /** * Declared-schemas mode (#708). @@ -348,3 +354,140 @@ describe('constructing where there is no `process` global', () => { } }) }) + +/** + * The hazard declared mode actually carries: an undeclared COLUMN on a + * DECLARED table is silently treated as plaintext. + * + * The two loud failures are already pinned above — an undeclared TABLE throws + * (`from()`, line ~148) and `select('*')` is refused (line ~115, which is what + * keeps this from being a blanket read leak: you cannot fetch a column you did + * not name). What is NOT loud, and is the real risk, is a table you declared + * but declared incompletely. Nothing in the client can detect it: with no + * introspection there is no column list to compare the declaration against, so + * an `eql_v3_*` column missing from `schemas` never enters the encrypt config + * and never gets a `::jsonb` cast. `schema-builder.ts` says it plainly — + * "Undeclared columns stay synthesized" — and in declared mode nothing was + * synthesized. + * + * The one warning that names this (`create.ts`, "any encrypted column missing + * from the declaration is treated as plaintext") is gated on `introspector`, + * so it NEVER fires on the edge entry — where declared mode is the only mode + * and this is therefore the only failure shape available. The shipped + * `stash-supabase` skill described a different hazard entirely (#812), so + * these assertions exist to keep the corrected wording honest. + * + * Asserted on the observable wire payload via the same doubles the builder + * suites use, not on internals: what the request body carries, what the select + * string casts, and what comes back to the caller. + */ +describe('an undeclared column on a declared table', () => { + /** + * `users` above declares `email` and `age`. This one declares `email` only — + * standing in for a table whose `secret` column really is a + * `public.eql_v3_text_eq` in the database, and whose author forgot it. + */ + const partial = encryptedTable('users', { + email: types.TextSearch('email'), + }) + + type UntypedRow = Record + type LooseBuilder = { + select(columns?: string): LooseBuilder + insert(data: UntypedRow): LooseBuilder + update(data: UntypedRow): LooseBuilder + eq(column: string, value: unknown): LooseBuilder + } & PromiseLike<{ data: UntypedRow[] | null }> + + /** The decrypt entry point, typed to the two arguments this test reads. */ + type DecryptSpyTarget = { + bulkDecryptModels( + rows: UntypedRow[], + table: { buildColumnKeyMap(): Record }, + ): unknown + } + + async function declaredWireClient(rows: UntypedRow[] = []) { + const supabase = createMockSupabase(rows) + const encryption = + createMockEncryptionClient() as unknown as DecryptSpyTarget + encryptionMock.mockResolvedValue(encryption) + const { encryptedSupabase } = await import('../src/index') + const client = await encryptedSupabase( + supabase.client as unknown as SupabaseClientLike, + { schemas: { users: partial } }, + ) + const from = (table: string) => + (client as unknown as { from(t: string): LooseBuilder }).from(table) + return { from, supabase, encryption } + } + + it('is written to the database IN THE CLEAR on insert', async () => { + const { from, supabase } = await declaredWireClient() + + await from('users').insert({ email: 'ada@example.com', secret: 'hunter2' }) + + const body = supabase.callsFor('insert')[0].args[0] as UntypedRow + // The declared column is encrypted... + expect(isFakeEnvelope(body.email)).toBe(true) + // ...and the undeclared one is not. It leaves the process as plaintext. + expect(body.secret).toBe('hunter2') + expect(isFakeEnvelope(body.secret)).toBe(false) + }) + + it('is written in the clear on update too', async () => { + const { from, supabase } = await declaredWireClient() + + await from('users').update({ secret: 'hunter2' }).eq('id', 1) + + const body = supabase.callsFor('update')[0].args[0] as UntypedRow + expect(body.secret).toBe('hunter2') + expect(isFakeEnvelope(body.secret)).toBe(false) + }) + + it('is filtered on in the clear, so the search term leaks too', async () => { + const { from, supabase } = await declaredWireClient() + + await from('users').select('id').eq('secret', 'hunter2') + + // The operand reaches PostgREST unencrypted — which also means it can only + // ever match plaintext rows, so the query silently returns nothing. + expect(supabase.callsFor('eq')[0].args).toEqual(['secret', 'hunter2']) + }) + + it('gets no ::jsonb cast on select, so nothing can decrypt it', async () => { + const { from, supabase } = await declaredWireClient() + + await from('users').select('id, email, secret') + + const emitted = supabase.callsFor('select')[0].args[0] as string + expect(emitted).toContain('email::jsonb') + expect(emitted).not.toContain('secret::jsonb') + }) + + /** + * The other half of "never decrypted": the table the adapter hands to the + * decrypt call is the MERGED one, and in declared mode that is the + * declaration verbatim. A column absent from it is a column nothing looks + * for, so the stored ciphertext is returned to the caller untouched — no + * error, no warning, and a row type that still claims `string`. + * + * Asserted on the decrypt call rather than on the returned row because the + * shared encryption double decrypts by envelope SHAPE, which the real client + * cannot do here: without the `::jsonb` cast asserted above, PostgREST sends + * the domain's text rendering and there is no envelope to recognise. + */ + it('is absent from the table handed to the decrypt call', async () => { + const { from, encryption } = await declaredWireClient([ + { id: 1, email: fakeEnvelope('ada@example.com', 'email'), secret: 'x' }, + ]) + const decrypt = vi.spyOn(encryption, 'bulkDecryptModels') + + await from('users').select('id, email, secret') + + const table = decrypt.mock.calls[0][1] + const known = Object.keys(table.buildColumnKeyMap()) + expect(known).toContain('email') + expect(known).not.toContain('secret') + }) +}) diff --git a/packages/stack-supabase/__tests__/supabase-wasm-config.test-d.ts b/packages/stack-supabase/__tests__/supabase-wasm-config.test-d.ts new file mode 100644 index 000000000..4d90f4cba --- /dev/null +++ b/packages/stack-supabase/__tests__/supabase-wasm-config.test-d.ts @@ -0,0 +1,140 @@ +/** + * What `config` on the edge entry actually requires (#812). + * + * The shipped skill and the package README both said `config` on + * `@cipherstash/stack-supabase/wasm-inline` "must carry all four `CS_*` + * values", because there is no `~/.cipherstash` to discover credentials from + * on an edge runtime. That is true of exactly ONE of the three arms + * `WasmClientConfig` accepts (`packages/stack/src/wasm-inline.ts`): the + * access-key arm. On the `authStrategy` arm — the one an + * `OidcFederationStrategy` takes, i.e. every identity-aware edge deployment — + * `accessKey` is `never` and `workspaceCrn` is OPTIONAL, because a pre-built + * strategy already carries the CRN. A reader who believed the docs would + * either invent an access key they do not have, or conclude the edge entry + * cannot do per-user encryption at all. + * + * The claim is a claim about the TYPE, so it is pinned at the type level: the + * runtime never sees the difference until it tries to authenticate. Both arms + * must type-check through `encryptedSupabase`'s own `config` option, not + * merely through `WasmClientConfig` in isolation — + * `EncryptedSupabaseWasmOptions` re-declares that field and could narrow it. + * + * The negative is the floor the docs SHOULD describe: `clientId` + `clientKey` + * with neither an access key nor a strategy satisfies no arm. + * + * Runs under `pnpm --filter @cipherstash/stack-supabase test:types`. + * `@cipherstash/stack/wasm-inline` has no `paths` entry in + * `tsconfig.json`, so it resolves through the workspace `exports` map to + * `packages/stack/dist/wasm-inline.d.ts` — build `@cipherstash/stack` first. + */ + +import { encryptedTable, types } from '@cipherstash/stack/eql/v3' +import type { + AccessKeyStrategy, + OidcFederationStrategy, + WasmClientConfig, +} from '@cipherstash/stack/wasm-inline' +import { describe, expectTypeOf, it } from 'vitest' +import type { SupabaseClientLike } from '../src/types.js' +import { encryptedSupabase } from '../src/wasm-inline.js' + +declare const supabaseClient: SupabaseClientLike + +/** + * Strategy INSTANCES, not the classes. `config.authStrategy` takes a built + * strategy (`OidcFederationStrategy.create(…)`), and both classes are + * re-exported from `@cipherstash/stack/wasm-inline` precisely so an edge + * consumer needs no separate `@cipherstash/auth` import. + */ +declare const oidc: OidcFederationStrategy +declare const accessKeyStrategy: AccessKeyStrategy + +const users = encryptedTable('users', { + email: types.TextSearch('email'), +}) + +describe('the edge entry `config` accepts either auth arm', () => { + it('accepts the access-key arm — the one the docs described', async () => { + await encryptedSupabase(supabaseClient, { + schemas: { users }, + config: { + workspaceCrn: 'crn:ap-southeast-2.aws:my-workspace-id', + accessKey: 'CS_CLIENT_ACCESS_KEY', + clientId: 'CS_CLIENT_ID', + clientKey: 'CS_CLIENT_KEY', + }, + }) + }) + + it('accepts the authStrategy arm with NO workspaceCrn and NO accessKey', async () => { + await encryptedSupabase(supabaseClient, { + schemas: { users }, + config: { + authStrategy: oidc, + clientId: 'CS_CLIENT_ID', + clientKey: 'CS_CLIENT_KEY', + }, + }) + }) + + it('accepts an AccessKeyStrategy instance on that same arm', async () => { + await encryptedSupabase(supabaseClient, { + schemas: { users }, + config: { + authStrategy: accessKeyStrategy, + clientId: 'CS_CLIENT_ID', + clientKey: 'CS_CLIENT_KEY', + }, + }) + }) + + it('still allows workspaceCrn alongside a strategy — optional, not banned', async () => { + await encryptedSupabase(supabaseClient, { + schemas: { users }, + config: { + workspaceCrn: 'crn:ap-southeast-2.aws:my-workspace-id', + authStrategy: oidc, + clientId: 'CS_CLIENT_ID', + clientKey: 'CS_CLIENT_KEY', + }, + }) + }) + + it('rejects clientId + clientKey alone — the genuine required floor', async () => { + await encryptedSupabase(supabaseClient, { + schemas: { users }, + // @ts-expect-error — no accessKey and no authStrategy satisfies no arm + config: { + clientId: 'CS_CLIENT_ID', + clientKey: 'CS_CLIENT_KEY', + }, + }) + }) + + it('rejects mixing an access key with a strategy', async () => { + await encryptedSupabase(supabaseClient, { + schemas: { users }, + // @ts-expect-error — `accessKey` is `never` on the strategy arm + config: { + workspaceCrn: 'crn:ap-southeast-2.aws:my-workspace-id', + accessKey: 'CS_CLIENT_ACCESS_KEY', + authStrategy: oidc, + clientId: 'CS_CLIENT_ID', + clientKey: 'CS_CLIENT_KEY', + }, + }) + }) + + /** + * The positive control for the two `@ts-expect-error`s above. If + * `EncryptedSupabaseWasmOptions['config']` ever widened to `unknown` or the + * native `ClientConfig`, both directives would go unused and vitest's + * typecheck would report THAT — but only if this file still says which type + * is meant to be under test. + */ + it('is `WasmClientConfig`, not the native optional ClientConfig', () => { + expectTypeOf< + Parameters>[1]['config'] + >().toEqualTypeOf() + }) +}) diff --git a/packages/stack-supabase/src/wasm-inline.ts b/packages/stack-supabase/src/wasm-inline.ts index 78bc5a639..a54396604 100644 --- a/packages/stack-supabase/src/wasm-inline.ts +++ b/packages/stack-supabase/src/wasm-inline.ts @@ -14,11 +14,15 @@ import { adaptWasmEncryption } from './wasm-client-adapter' * Three differences from the default entry's options, each one a runtime * requirement made visible to the type checker (#708 review): * - * - **`schemas` is required.** This entry cannot introspect, so a client built - * without a declaration has no columns and nothing to encrypt. + * - **`schemas` is required.** This entry cannot introspect, so a declaration is + * the only way to discover the encrypted columns. Omitting it is a type + * error, and a construction-time throw for callers arriving from plain JS — + * not a client that quietly encrypts nothing. * - **`config` is required, and is a `WasmClientConfig`.** There is no - * `~/.cipherstash` to discover credentials from on an edge runtime, so all - * four `CS_*` values must be passed. Typing it as the native (optional) + * `~/.cipherstash` to discover credentials from on an edge runtime, so + * authentication is passed in: `clientId` and `clientKey` always, then + * either `workspaceCrn` + `accessKey` or a pre-built `authStrategy` (which + * carries the CRN itself). Typing it as the native (optional) * `ClientConfig` let a caller omit it and reach a `TypeError` from inside the * engine, and let native-only fields such as `keyset` type-check while being * silently ignored. diff --git a/scripts/__tests__/skills-supabase-edge-schema-entry.test.mjs b/scripts/__tests__/skills-supabase-edge-schema-entry.test.mjs new file mode 100644 index 000000000..72976b6c9 --- /dev/null +++ b/scripts/__tests__/skills-supabase-edge-schema-entry.test.mjs @@ -0,0 +1,159 @@ +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { describe, expect, it } from 'vitest' +import { packageReadmePathspecs } from './lib/package-readmes.mjs' +import { REPO_ROOT } from './lib/repo-root.mjs' + +/** + * A schema authored from `@cipherstash/stack/wasm-inline` cannot be handed to + * `encryptedSupabase` from `@cipherstash/stack-supabase/wasm-inline`. + * + * `@cipherstash/stack/wasm-inline` is a separate tsup dts bundle. It + * re-declares its own `EncryptedV3Column` / `EncryptedTextSearchColumn` + * classes, each carrying a `private readonly columnName`, and TypeScript + * compares classes with private fields NOMINALLY. The adapter types its + * `schemas` option as `Record` imported from + * `@cipherstash/stack/eql/v3` (`packages/stack-supabase/src/schema-builder.ts`), + * so pairing the two entries is a hard `tsc --strict` error: + * + * error TS2322: Type 'EncryptedTable<...>' is not assignable to type 'AnyV3Table'. + * ... Types have separate declarations of a private property 'columnName'. + * + * The rule is therefore NOT "edge project, edge entry, everywhere". It is: + * author the schema against the entry whose CLIENT TYPE consumes it. The raw + * `Encryption` client from `wasm-inline` consumes `wasm-inline` tables; + * `encryptedSupabase` consumes `eql/v3` tables on BOTH its entries, WASM engine + * or not. + * + * Nothing catches the pairing for us: + * + * - Nothing type-checks a SKILL.md or a README, and these are shipped text — + * `skills/` rides inside the `stash` npm tarball and `stash init` copies it + * into the customer's own repository, where their coding agent reads it as + * instruction. The drift lands in someone else's build, not in ours. + * - Runtime is unaffected, which is why it drifted silently in the first + * place: `packages/stack-supabase/src/column-map.ts` deliberately probes for + * v3 columns STRUCTURALLY rather than with `instanceof`, precisely because + * tsup emits the class twice. Copy-pasting the bad snippet produces working + * code that will not compile. + * - `e2e/wasm/deno.json` runs `deno test --no-check`, so the repo's own edge + * e2e would not report it either. + */ + +/** Modules and names that must not co-occur inside one TypeScript block. */ +const ADAPTER_MODULE = '@cipherstash/stack-supabase/wasm-inline' +const ADAPTER_NAMES = ['encryptedSupabase', 'encryptedSupabaseV3'] +const SCHEMA_MODULE = '@cipherstash/stack/wasm-inline' +const SCHEMA_NAMES = ['encryptedTable', 'types'] + +/** + * Files whose contents are SHIPPED — published to npm, copied into a user's + * repo, or written there by `stash init`. Deliberately not the whole tree: + * CHANGELOGs and `docs/**` are historical records, and rewriting history to + * appease a lint is worse than the drift it prevents. + */ +// `:(glob)` magic so `*` stops at a path separator — without it git's default +// wildmatch crosses `/` and sweeps in files a level deeper. +const SHIPPED_GLOBS = [ + ':(glob)skills/*/SKILL.md', + // Derived, not written down: two package roots sit deeper than one level and + // `:(glob)` does not cross `/`. See `lib/package-readmes.mjs`. + ...packageReadmePathspecs(), + 'README.md', + 'AGENTS.md', +] + +/** Tracked files matching the shipped globs, via git so it honours .gitignore. */ +function shippedFiles() { + const out = execFileSync('git', ['ls-files', '-z', ...SHIPPED_GLOBS], { + cwd: REPO_ROOT, + encoding: 'utf8', + }) + return out.split('\0').filter(Boolean) +} + +/** + * Fenced TypeScript blocks, as `{ line, body }`. + * + * Per BLOCK, not per file: a document may legitimately import + * `@cipherstash/stack/wasm-inline` in one snippet (the raw edge client, which + * really does want its own tables) and construct `encryptedSupabase` in + * another. Only the two appearing in the same snippet is the defect. + */ +function typescriptBlocks(body) { + const blocks = [] + const fence = /^```(ts|typescript)[^\n]*\n([\s\S]*?)^```/gm + for (const match of body.matchAll(fence)) { + blocks.push({ + line: body.slice(0, match.index).split('\n').length, + body: match[2], + }) + } + return blocks +} + +/** Named imports in one block, as `{ module, names }`. */ +function namedImports(block) { + const imports = [] + const stmt = /import\s+(?:type\s+)?\{([^}]*)\}\s*from\s*['"]([^'"]+)['"]/g + for (const match of block.matchAll(stmt)) { + imports.push({ + module: match[2], + names: match[1] + .split(',') + .map((name) => + name + .trim() + .split(/\s+as\s+/)[0] + .trim(), + ) + .filter(Boolean), + }) + } + return imports +} + +/** Does this block import any of `names` from `module`? */ +function importsAny(imports, module, names) { + return imports.some( + (imported) => + imported.module === module && + imported.names.some((name) => names.includes(name)), + ) +} + +describe('supabase edge snippets author schemas from @cipherstash/stack/eql/v3', () => { + const files = shippedFiles() + + it('finds the shipped file set (guards against a silently-empty glob)', () => { + expect(files.length).toBeGreaterThan(5) + expect(files).toContain('skills/stash-supabase/SKILL.md') + expect(files).toContain('skills/stash-managed-platforms/SKILL.md') + expect(files).toContain('skills/stash-edge/SKILL.md') + expect(files).toContain('packages/stack-supabase/README.md') + }) + + it.each(files)('%s', (file) => { + const body = readFileSync(resolve(REPO_ROOT, file), 'utf8') + const offenders = typescriptBlocks(body) + .filter((block) => { + const imports = namedImports(block.body) + return ( + importsAny(imports, ADAPTER_MODULE, ADAPTER_NAMES) && + importsAny(imports, SCHEMA_MODULE, SCHEMA_NAMES) + ) + }) + .map((block) => `${file}:${block.line}`) + + expect( + offenders, + `${offenders.join(', ')} pairs \`encryptedSupabase\` from ${ADAPTER_MODULE} with a schema ` + + `authored from ${SCHEMA_MODULE}. That does not compile: the adapter's \`schemas\` option is ` + + "typed from `@cipherstash/stack/eql/v3`, and the two entries' column classes carry private " + + 'fields TypeScript compares nominally (TS2322, "separate declarations of a private property ' + + "'columnName'\"). Import `encryptedTable` and `types` from `@cipherstash/stack/eql/v3` — the " + + 'engine stays WASM either way.', + ).toEqual([]) + }) +}) diff --git a/skills/stash-edge/SKILL.md b/skills/stash-edge/SKILL.md index 6964af2a1..3fa83dcd9 100644 --- a/skills/stash-edge/SKILL.md +++ b/skills/stash-edge/SKILL.md @@ -1,6 +1,6 @@ --- name: stash-edge -description: Run CipherStash encryption on edge and non-Node runtimes with the `@cipherstash/stack/wasm-inline` entry — Deno, Supabase Edge Functions, Cloudflare Workers, and Bun. Covers the import specifier per runtime, the four mandatory `CS_*` variables and minting them with `stash env`, how keysets and credentials interact on the edge (what must match is the keyset — `stash-zerokms` is canonical), how the WASM client surface differs from the native typed client, and why an EQL v3 schema module cannot be shared across the two entries. Use when adding encryption to a Supabase Edge Function, a Worker, or a Deno service; when a native module fails to load in a deployed runtime; when wiring `CS_*` secrets into an edge deploy; or when encrypted search returns zero rows on the edge but works locally. +description: Run CipherStash encryption on edge and non-Node runtimes with the `@cipherstash/stack/wasm-inline` entry — Deno, Supabase Edge Functions, Cloudflare Workers, and Bun. Covers the import specifier per runtime, which `CS_*` variables are mandatory and minting them with `stash env`, how keysets and credentials interact on the edge (what must match is the keyset — `stash-zerokms` is canonical), how the WASM client surface differs from the native typed client, and why an EQL v3 schema module cannot be shared across the two entries. Use when adding encryption to a Supabase Edge Function, a Worker, or a Deno service; when a native module fails to load in a deployed runtime; when wiring `CS_*` secrets into an edge deploy; or when encrypted search returns zero rows on the edge but works locally. --- # Encryption on the Edge (WASM entry) @@ -46,8 +46,11 @@ together. `@cipherstash/stack-supabase/wasm-inline` (not the package root, which pulls the native engine) and **declare your `schemas`** — the adapter's default behaviour is to introspect the database for its column config, which needs a -Postgres connection. Declaring skips it. See `stash-supabase` and -`stash-managed-platforms`. +Postgres connection. Declaring skips it. Those `schemas` are authored from +`@cipherstash/stack/eql/v3`, not from `@cipherstash/stack/wasm-inline` — the +one place the "use the edge entry for everything" reflex is wrong, and it +fails at `tsc`, not at runtime. See "Schema Modules Do Not Cross Entries" +below, plus `stash-supabase` and `stash-managed-platforms`. **`@cipherstash/protect` is not one of the options.** It is the deprecated predecessor of `@cipherstash/stack`; its native `@cipherstash/protect-ffi` @@ -118,9 +121,15 @@ that config does not apply here and can be left alone. ## Credentials -The edge client takes **all four** `CS_*` values explicitly. There is no -credential discovery: `~/.cipherstash` does not exist in a Worker or an Edge -Function container, and there is no device-code login to fall back on. +The edge client is passed its credentials explicitly. There is no credential +discovery: `~/.cipherstash` does not exist in a Worker or an Edge Function +container, and there is no device-code login to fall back on. + +`clientId` and `clientKey` are always required. Past those, `config` is a +union of two paths: the **access-key path** below adds `workspaceCrn` + +`accessKey` — the four `CS_*` values `stash env` mints — or you pass a +pre-built `config.authStrategy`, which already carries the CRN and so needs +neither `workspaceCrn` nor `accessKey` (see `config.authStrategy` below). ```ts const client = await Encryption({ @@ -243,7 +252,7 @@ Available: `encrypt`, `decrypt`, `isEncrypted`, `encryptQuery`, |---|---|---| | Factory | `Encryption({ schemas })` | `Encryption({ schemas, config })` — same name, different module | | Schema authoring | `encryptedTable` / `types` from `@cipherstash/stack/v3` | the entry's own re-exports (see below) | -| Config | discovered from env / `~/.cipherstash` | all four `CS_*` passed explicitly | +| Config | discovered from env / `~/.cipherstash` | passed explicitly — `clientId` + `clientKey`, then either `workspaceCrn` + `accessKey` or a pre-built `authStrategy` (see below) | | Typing | signatures derived from the schema | schema-aware, but not the full typed client | | `.audit()` | chainable on operations | **not available** | | `.withLockContext()` | chainable on operations | **not available** — see below | @@ -381,10 +390,11 @@ It works fine at runtime, which is the trap: the tempting fix is `as never` / `as any` on the schema, which silences a real signal and will keep silencing it after a genuine schema mismatch appears. -**Author the schema module against exactly one entry, and use that entry's -client with it.** For a project whose encryption runs on the edge, that means -importing `encryptedTable` and `types` from `@cipherstash/stack/wasm-inline` -in the shared schema module: +**Author the schema module against the entry whose CLIENT TYPE consumes it.** +Not "the entry your runtime uses" — the WASM engine is not what decides this, +the type of the thing you hand the schema to is. For a project that builds a +raw `Encryption` client from `@cipherstash/stack/wasm-inline`, that entry is +also where `encryptedTable` and `types` come from: ```ts // schema.ts — the single source of truth for this project's schema @@ -406,6 +416,25 @@ something to test, not something the type system will enforce for you. Column names and domains must match exactly — they are what the database and the stored payload's `i` identifier are keyed by. +### The exception: `@cipherstash/stack-supabase/wasm-inline` + +The Supabase adapter's edge entry runs the WASM engine but types its `schemas` +option from `@cipherstash/stack/eql/v3` — the same declaration its native +entry uses. So a Supabase edge project authors its schema module from +`eql/v3`, **not** from `@cipherstash/stack/wasm-inline`: + +```ts +// The engine is still WASM. Only the schema's declaration site differs. +import { encryptedSupabase } from '@cipherstash/stack-supabase/wasm-inline' +import { encryptedTable, types } from '@cipherstash/stack/eql/v3' +``` + +Get this one backwards and you get the same nominal-private-field error as +above, from the other direction — `schemas` rejected as not assignable to +`AnyV3Table`. Which way round it goes is a property of the client type, so +check what consumes the schema before you pick the import. `stash-supabase` +and `stash-managed-platforms` carry the full edge call shape. + ## Querying from the Edge Edge functions rarely have an ORM, so encrypted search is usually hand-written @@ -441,7 +470,7 @@ shared modules. entry is ESM-only. Move the consumer to ESM. **Missing `CS_*` at runtime** — the secret store was never populated, or the -function was served without `--env-file`. Validate all four at handler entry +function was served without `--env-file`. Validate the ones you pass at handler entry and return an actionable error rather than letting client construction fail opaquely; the example in `examples/supabase-worker` does exactly this. diff --git a/skills/stash-managed-platforms/SKILL.md b/skills/stash-managed-platforms/SKILL.md index 2e10e1647..a86e8af87 100644 --- a/skills/stash-managed-platforms/SKILL.md +++ b/skills/stash-managed-platforms/SKILL.md @@ -156,7 +156,10 @@ By default `encryptedSupabase` derives every column's encryption config by intro ```typescript import { encryptedSupabase } from '@cipherstash/stack-supabase/wasm-inline' -import { encryptedTable, types } from '@cipherstash/stack/wasm-inline' +// Schemas come from `eql/v3`, NOT `@cipherstash/stack/wasm-inline` — the +// adapter types `schemas` from that entry and the two entries' column classes +// do not cross. The engine is still WASM. +import { encryptedTable, types } from '@cipherstash/stack/eql/v3' const users = encryptedTable('users', { email: types.TextSearch('email'), @@ -169,10 +172,11 @@ const supabase = await encryptedSupabase(supabaseClient, { }) ``` -**Two things must both be right**, and each fails independently: +**Three things must all be right**, and each fails independently: 1. **The entry.** Import from `@cipherstash/stack-supabase/wasm-inline`, not the package root. The root statically imports the native engine, which loads on import whether or not you encrypt anything. 2. **The schemas.** Without them the wrapper still wants a connection. +3. **Where the schemas come from.** `encryptedTable` and `types` for the adapter come from `@cipherstash/stack/eql/v3` on **both** its entries — the adapter types `schemas` from that entry, and `@cipherstash/stack/wasm-inline` re-declares the same column classes with private fields TypeScript compares nominally. Author them from the WASM entry and `tsc` rejects the `schemas` object ("separate declarations of a private property `columnName`") while the code runs fine, so nothing but a typecheck tells you. Only a **raw** `Encryption` client from `@cipherstash/stack/wasm-inline` wants tables authored from that entry. What declared mode gives up, it gives up loudly rather than silently: diff --git a/skills/stash-supabase/SKILL.md b/skills/stash-supabase/SKILL.md index f7e9c9be9..41b4f8399 100644 --- a/skills/stash-supabase/SKILL.md +++ b/skills/stash-supabase/SKILL.md @@ -281,8 +281,8 @@ capabilities come from the introspected domains. ### 4. Optional declared schemas (compile-time types) Declaring tables is optional. Passing `schemas` — a record whose keys must -equal each table's name — adds compile-time types and verifies the declared -tables against the database at construction: +equal each table's name — adds compile-time types, and, when introspection +also runs, verifies the declared tables against the database at construction: ```typescript import { encryptedTable, types } from "@cipherstash/stack/eql/v3" @@ -305,9 +305,18 @@ A declared table gets a typed builder: rows infer each column's plaintext type (`types.IntegerOrd` → `number`, `types.TimestampOrd` → `Date`), storage-only columns are excluded from every filter method, and `order()` is narrowed to orderable columns. -Undeclared tables behave exactly as with no `schemas` at all. Every v3 column -is fully described by its `types.*` factory — there are no capability or -tuning chains on v3 columns. + +Whether undeclared tables still work turns on `databaseUrl`, **not** on the +absence of `schemas`. Introspection is gated on a resolved database URL, so +passing `databaseUrl` alongside `schemas` gets you both — declared tables keep +their types and are drift-checked, undeclared ones behave exactly as with no +`schemas` at all. Pass `schemas` on their own, as above, and even the native +entry is in declared mode: an ambient `DATABASE_URL` is deliberately ignored, +because declaring your tables says this client needs no connection, and +`from("orders")` on an undeclared table throws. + +Every v3 column is fully described by its `types.*` factory — there are no +capability or tuning chains on v3 columns. A JS property may map to a different DB column name (`joined: types.TimestampOrd("joined_at")`) — filters, selects, and results @@ -354,13 +363,32 @@ 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`. +- **`schemas` is required** — by the type, and by a construction-time throw + for callers who reach it from plain JS. Nothing introspects here, so there + is no other way to discover your encrypted columns, and `from()` on an + undeclared table throws for the same reason. The real hazard is one level + down: an undeclared **column** on a declared table never enters the encrypt + config and is treated as plaintext, so a filter on it sends your plaintext + value to PostgREST and a select on it hands back the raw EQL payload. Two + things limit the blast radius — `select('*')` is refused in declared mode, + so ciphertext only comes back if you name the column yourself, and a + plaintext write to a real `eql_v3_*` column fails that domain's CHECK + constraint. Do not wait for a warning: the native entry logs one about + unverified declarations, but it is gated on the introspector, so on this + entry it never fires. Declare every encrypted column of every table you + query. +- **`config` is required.** There is no `~/.cipherstash` on an edge runtime to + discover credentials from, so authentication is passed in. `clientId` and + `clientKey` are always needed; past those the type is a union of two paths. + The **access-key path** adds `workspaceCrn` + `accessKey` — the four `CS_*` + values shown above. Mint them with `stash env --name ` and set them + with `supabase secrets set`, or pass `--env-file` for `supabase functions + serve`. The **strategy path** takes a pre-built `config.authStrategy` + instead — `AccessKeyStrategy` or `OidcFederationStrategy`, both re-exported + from `@cipherstash/stack/wasm-inline` — and makes `workspaceCrn` optional, + since a built strategy already carries the CRN. So authenticating *as the + end user* over OIDC federation does work on the edge; what does not is + binding data to that user, two bullets below. - **`databaseUrl` is not accepted.** It is refused at runtime and absent from the type. - **`.withLockContext()` and `.audit()` throw.** Identity-bound encryption is From 578783ad7bb9d7ed29c7468bbf4326210fa7a61c Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 31 Aug 2026 10:33:41 +1000 Subject: [PATCH 3/3] fix(skills): the select('*') refusal is not a read backstop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adversarial verification of the previous commit found four problems in its own corrections. Three are fixed here; the fourth is pre-existing and noted below. The worst was a mitigation that does not hold. The bullet claimed `select('*')` being refused in declared mode means "ciphertext only comes back if you name the column yourself". The refusal is real and unconditional, but it guards the wrong door: a read awaited with no `.select()` call at all takes the raw-`*` branch in `query-builder.ts` and `decryptResults` passes it through on `!hasSelect`, so every column returns undecrypted — declared or not. `query-builder.ts:703-720` documents this as long-standing behaviour deliberately left alone; the docs just did not reflect it. So writes have a backstop (the domain CHECK, which a NULL still passes) and reads have none. Corrected in the skill, in the new test's header comment, and in the changeset. Also: `WasmClientConfig` has three arms, not two — the third is the deprecated `strategy` alias. Naming it in user docs would advertise what we are removing, so the fix is to stop counting rather than to document it. And `stash-edge`'s new exception section said the failure came "from the other direction", which contradicts the section above it that already establishes assignment is rejected both ways; it is the same rejection reported one level up, at `AnyV3Table` rather than at the column. Not fixed here, worth a follow-up: `e2e/wasm/supabase-declared.test.ts` pairs `encryptedSupabase` with a schema from `@cipherstash/stack/wasm-inline` — the exact pairing this PR documents as non-compiling — hidden by `deno test --no-check`. It is a genuine counterexample, but it is an internal test that ships to nobody, and at lines 255-284 one table object feeds both a raw wasm-inline `Encryption` and the adapter, so the fix needs two schema objects rather than a swapped import. The new guard cannot catch it either: its extractor reads fenced markdown blocks, so covering `.ts` files is a different test with its own scope question. Claude-Session: https://claude.ai/code/session_01UqnsDeKs8N9n9TStPAqzzw --- .changeset/supabase-docs-wasm-inline-entry.md | 13 ++++++++----- packages/stack-supabase/README.md | 2 +- .../__tests__/supabase-declared-mode.test.ts | 7 ++++--- skills/stash-edge/SKILL.md | 9 +++++---- skills/stash-supabase/SKILL.md | 15 +++++++++------ 5 files changed, 27 insertions(+), 19 deletions(-) diff --git a/.changeset/supabase-docs-wasm-inline-entry.md b/.changeset/supabase-docs-wasm-inline-entry.md index c174aee4c..b454f2f90 100644 --- a/.changeset/supabase-docs-wasm-inline-entry.md +++ b/.changeset/supabase-docs-wasm-inline-entry.md @@ -68,11 +68,14 @@ are here, three of them pre-dating this change: 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 two things that limit it - (`select('*')` is refused in declared mode, and a plaintext write to an - `eql_v3_*` column fails the domain CHECK) and the fact that the native - entry's warning about unverified declarations is gated on the introspector - and so never fires there. + 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 diff --git a/packages/stack-supabase/README.md b/packages/stack-supabase/README.md index 0dad431d6..e0524d2ae 100644 --- a/packages/stack-supabase/README.md +++ b/packages/stack-supabase/README.md @@ -135,7 +135,7 @@ required, because nothing introspects here; `config` is required, because there `.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 of two paths: pass +`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` diff --git a/packages/stack-supabase/__tests__/supabase-declared-mode.test.ts b/packages/stack-supabase/__tests__/supabase-declared-mode.test.ts index 93cc43106..c3ce46944 100644 --- a/packages/stack-supabase/__tests__/supabase-declared-mode.test.ts +++ b/packages/stack-supabase/__tests__/supabase-declared-mode.test.ts @@ -360,9 +360,10 @@ describe('constructing where there is no `process` global', () => { * DECLARED table is silently treated as plaintext. * * The two loud failures are already pinned above — an undeclared TABLE throws - * (`from()`, line ~148) and `select('*')` is refused (line ~115, which is what - * keeps this from being a blanket read leak: you cannot fetch a column you did - * not name). What is NOT loud, and is the real risk, is a table you declared + * (`from()`, line ~148) and `select('*')` is refused (line ~115). Neither is a + * blanket-read backstop: a query awaited with no `.select()` at all takes + * `query-builder.ts`'s raw-`*` branch and returns every column undecrypted. + * What is NOT loud, and is the real risk, is a table you declared * but declared incompletely. Nothing in the client can detect it: with no * introspection there is no column list to compare the declaration against, so * an `eql_v3_*` column missing from `schemas` never enters the encrypt config diff --git a/skills/stash-edge/SKILL.md b/skills/stash-edge/SKILL.md index 3fa83dcd9..814f8d2ea 100644 --- a/skills/stash-edge/SKILL.md +++ b/skills/stash-edge/SKILL.md @@ -126,7 +126,7 @@ discovery: `~/.cipherstash` does not exist in a Worker or an Edge Function container, and there is no device-code login to fall back on. `clientId` and `clientKey` are always required. Past those, `config` is a -union of two paths: the **access-key path** below adds `workspaceCrn` + +union: the **access-key path** below adds `workspaceCrn` + `accessKey` — the four `CS_*` values `stash env` mints — or you pass a pre-built `config.authStrategy`, which already carries the CRN and so needs neither `workspaceCrn` nor `accessKey` (see `config.authStrategy` below). @@ -429,9 +429,10 @@ import { encryptedSupabase } from '@cipherstash/stack-supabase/wasm-inline' import { encryptedTable, types } from '@cipherstash/stack/eql/v3' ``` -Get this one backwards and you get the same nominal-private-field error as -above, from the other direction — `schemas` rejected as not assignable to -`AnyV3Table`. Which way round it goes is a property of the client type, so +Get this one backwards and you hit the same nominal-private-field rejection, +reported one level up — `schemas` not assignable to `AnyV3Table`, because the +column classes inside it carry a private `columnName` from the other entry's +declarations. Which way round it goes is a property of the client type, so check what consumes the schema before you pick the import. `stash-supabase` and `stash-managed-platforms` carry the full edge call shape. diff --git a/skills/stash-supabase/SKILL.md b/skills/stash-supabase/SKILL.md index 41b4f8399..b7c96497a 100644 --- a/skills/stash-supabase/SKILL.md +++ b/skills/stash-supabase/SKILL.md @@ -369,17 +369,20 @@ checker: undeclared table throws for the same reason. The real hazard is one level down: an undeclared **column** on a declared table never enters the encrypt config and is treated as plaintext, so a filter on it sends your plaintext - value to PostgREST and a select on it hands back the raw EQL payload. Two - things limit the blast radius — `select('*')` is refused in declared mode, - so ciphertext only comes back if you name the column yourself, and a - plaintext write to a real `eql_v3_*` column fails that domain's CHECK - constraint. Do not wait for a warning: the native entry logs one about + value to PostgREST and a select on it hands back the raw EQL payload. Writes + have a backstop — a plaintext write to a real `eql_v3_*` column fails that + domain's CHECK constraint, though a NULL still passes. **Reads have none.** + `select('*')` is refused in declared mode, but that is not a safety net: a + query awaited with no `.select()` call at all sends a raw `*`, and every + column comes back undecrypted, declared or not. Name the columns you want. + Do not wait for a warning: the native entry logs one about unverified declarations, but it is gated on the introspector, so on this entry it never fires. Declare every encrypted column of every table you query. - **`config` is required.** There is no `~/.cipherstash` on an edge runtime to discover credentials from, so authentication is passed in. `clientId` and - `clientKey` are always needed; past those the type is a union of two paths. + `clientKey` are always needed; past those the type is a union, with two + supported paths. The **access-key path** adds `workspaceCrn` + `accessKey` — the four `CS_*` values shown above. Mint them with `stash env --name ` and set them with `supabase secrets set`, or pass `--env-file` for `supabase functions