From e36f1a0664ab5828af9f2577fe004f8b7b04a8a7 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Thu, 27 Aug 2026 09:20:59 +1000 Subject: [PATCH 1/7] 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 efff50643..b1fa0f91f 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. Introspection is not the only thing keeping this out of a browser. On the WASM entry, `config.clientKey` is a workspace secret and is required on @@ -316,6 +320,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 d35c792f25cf8defd09f36f490d065e2394e3cf0 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 31 Aug 2026 09:42:40 +1000 Subject: [PATCH 2/7] 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 ce3d7e763..e8c73ad5c 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 4df34ad04..8f4fe9594 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, why the entry is server-side only and never belongs in a browser bundle, 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, why the entry is server-side only and never belongs in a browser bundle, 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). > [!IMPORTANT] > **Server-side only — this entry never goes in a browser bundle.** @@ -253,7 +262,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 | @@ -391,10 +400,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 @@ -416,6 +426,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 @@ -451,7 +480,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 b1fa0f91f..0544f3ac7 100644 --- a/skills/stash-supabase/SKILL.md +++ b/skills/stash-supabase/SKILL.md @@ -287,8 +287,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" @@ -311,9 +311,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 @@ -360,13 +369,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 0abd7be8857fa54ed5043afd015b352568d43517 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 31 Aug 2026 10:33:41 +1000 Subject: [PATCH 3/7] 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 8f4fe9594..8b84367e4 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). @@ -439,9 +439,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 0544f3ac7..70cfa95f1 100644 --- a/skills/stash-supabase/SKILL.md +++ b/skills/stash-supabase/SKILL.md @@ -375,17 +375,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 From 88264b793052a375e2e5b3f2e6e6f404c952adcf Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 31 Aug 2026 11:40:25 +1000 Subject: [PATCH 4/7] fix(supabase,skills): refuse databaseUrl in the type, and carry the read-backstop correction across MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of #951 found four things. One is a change to a published type; three are corrections to shipped text, two of them in the copy the previous commit did not touch. `EncryptedSupabaseWasmOptions` enforced less than three documents claimed it did. Leaving `databaseUrl` off the interface is policed by excess-property checking, which fires on FRESH object literals alone — so an options object assembled as a `const` and passed by variable, which is what a Node-to-edge port actually holds, type-checked clean and reached the construction-time throw in `create.ts` instead. The README, `stash-supabase` and the interface's own doc comment all said the type checker enforced it. The field is now declared `databaseUrl?: never`, which is the same gap and the same fix as `WasmClientConfig.eqlVersion?: never` one package along, whose comment describes this exact shared-config-const path. The runtime throw stays: it is the only thing a plain JS caller meets. New type tests cover both the inline and the by-variable shapes on both call forms, and a positive control asserts the same options object still compiles once the field is dropped — without it, a `never` that poisoned the options type would read as four passing expect-errors. `578783ad` corrected the `select('*')` claim in `stash-supabase` and left the same claim standing in `stash-managed-platforms`, which the same PR edited. That skill still framed declared mode as giving things up "loudly rather than silently" over a bullet naming the refusal — precisely the inference the correction exists to kill. Its audience is an agent on Lovable, v0, Bolt or Replit reading it as instruction, and the failure it mispromised is silent: `await supabase.from('users').eq('id', 1)` with no `.select()` takes the raw-`*` branch (`query-builder.ts:703-725`), `decryptResults` returns it untouched on `!hasSelect` (`query-results.ts:126-138`), and every column comes back as a raw EQL payload with no error. Shipping a correction to one of two copies is worse than shipping neither, because the copy left behind now looks reviewed — so `scripts/__tests__/skills-select-star-not-a-read-backstop.test.mjs` now fails if any shipped document states the refusal without the caveat in the same markdown section. It scans per section rather than per sentence, so the wording can move; it blanks fenced code, so a snippet is not read as a claim; and it carries a liveness test against the canonical skill, because a decayed regex is a guard that always passes. "Everything after construction is the same wrapper" was false in the first way a reader hits it, in both the README and `stash-supabase`. The edge entry is always in declared mode, where `select('*')` is refused and `from()` on an undeclared table throws — and the README said it twenty-five lines under a paragraph telling the same reader `select('*')` just works, immediately above the quick-start snippet it tells you to port into an Edge Function. Both sentences now name the two exceptions. `stash-managed-platforms` also promised a diagnostic that cannot fire on the entry its section is about: an ambient `DATABASE_URL` ignored "with a warning that the declaration is unverified". Both the ambient read (`create.ts:304`) and the warning (`create.ts:330`) are gated on `introspector`, which is `null` for the wasm-inline build. Scoped to Node, with the edge case stated. This is the same correction `stash-supabase` already carried and this file did not. The changeset gains `@cipherstash/wizard`: `skills/` ships in that tarball too (`packages/wizard/tsup.config.ts` copies it to `dist/skills`, listed under `files`), so without the bump the published wizard keeps the old text until an unrelated change moves its version. The recent skills-editing changesets were `stash`-only; the skills-adding ones bumped both. The ambient-warning claim is deliberately left unguarded — "a warning is logged" has no stable phrasing to key on and "introspector" appears freely in any section about introspection, so every detector for it either misses rewordings or fires on unrelated prose. The header comment says so. Verification: `@cipherstash/stack-supabase` 570 tests + 56 type tests, 0 type errors; `pnpm run test:scripts` 887 passed (51 files), including both doc guards; `skill-supabase-apply` 29 passed; `biome check` 0 errors. The new guard was confirmed red against the unmodified `stash-managed-platforms` and against a README with the caveat clause removed, before either was fixed. Claude-Session: https://claude.ai/code/session_01TvoE76yuuLn2Bmdaz8yFYy --- .changeset/supabase-docs-wasm-inline-entry.md | 57 +++- packages/stack-supabase/README.md | 7 +- .../__tests__/supabase-wasm-config.test-d.ts | 122 +++++++++ packages/stack-supabase/src/wasm-inline.ts | 25 +- ...s-select-star-not-a-read-backstop.test.mjs | 256 ++++++++++++++++++ skills/stash-managed-platforms/SKILL.md | 8 +- skills/stash-supabase/SKILL.md | 16 +- 7 files changed, 477 insertions(+), 14 deletions(-) create mode 100644 scripts/__tests__/skills-select-star-not-a-read-backstop.test.mjs diff --git a/.changeset/supabase-docs-wasm-inline-entry.md b/.changeset/supabase-docs-wasm-inline-entry.md index b454f2f90..d0568b59f 100644 --- a/.changeset/supabase-docs-wasm-inline-entry.md +++ b/.changeset/supabase-docs-wasm-inline-entry.md @@ -1,6 +1,7 @@ --- '@cipherstash/stack-supabase': patch 'stash': patch +'@cipherstash/wizard': patch --- Stop telling customers the Supabase wrapper cannot run in a Worker. @@ -97,7 +98,7 @@ 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 +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` @@ -105,4 +106,56 @@ 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. +Review found four more, one of them a change to a published type: + +- **`databaseUrl` was only refused for callers who wrote the options inline.** + `EncryptedSupabaseWasmOptions` left the field out, and omission is policed by + excess-property checking, which fires on fresh object literals alone. An + options object assembled as a `const` and passed by variable — which is what + a Node-to-edge port actually holds — type-checked clean and reached the + construction-time throw instead, from documents saying the type checker + enforced it. The field is now declared `databaseUrl?: never`, mirroring + `WasmClientConfig.eqlVersion?: never` in `@cipherstash/stack`, which exists + for the identical reason one package along. The runtime throw stays as the + backstop for plain JS. New type tests cover the inline and by-variable + shapes on both call forms, plus a positive control that the same options + object still compiles once `databaseUrl` is dropped. +- **The `select('*')` correction had landed in only one of its two shipped + copies.** `skills/stash-supabase/SKILL.md` carried it; + `skills/stash-managed-platforms/SKILL.md`, edited in the same change, still + framed declared mode as giving things up "loudly rather than silently" over a + bullet naming the refusal — precisely the inference the correction exists to + kill. That skill is read as instruction by an agent on Lovable, v0, Bolt or + Replit, and the failure it mispromised is silent: raw EQL payloads returned + as `data`, no error. The wording is carried across, and a new guard + (`scripts/__tests__/skills-select-star-not-a-read-backstop.test.mjs`) fails + if any shipped document states the refusal without the caveat in the same + section. Two copies of one fact drift the moment one of them is edited, and + no reviewer diff shows the copy nobody touched. +- **"Everything after construction is the same wrapper" was false in the first + way a reader hits it.** Both `packages/stack-supabase/README.md` and + `skills/stash-supabase/SKILL.md` said `from()`, the filters and the response + shape are identical across the two entries — the README saying so twenty-five + lines under a paragraph telling the same reader `select('*')` just works. The + edge entry is always in declared mode, where `select('*')` is refused and + `from()` on an undeclared table throws. Both sentences now name the two + exceptions, so the quick-start snippet the section tells you to port no + longer arrives with a promise it breaks. +- **The ambient-`DATABASE_URL` warning cannot fire on the edge entry.** + `skills/stash-managed-platforms/SKILL.md` said an ambient `DATABASE_URL` is + ignored when `schemas` are passed, "with a warning that the declaration is + unverified" — in a section whose subject is `wasm-inline`. Both the ambient + read and the warning are gated on the introspector, which that build does not + have, so nothing there ever tells you a declaration is incomplete. Now scoped + to Node, with the edge case stated. + +`@cipherstash/wizard` is bumped alongside `stash` because `skills/` ships in +both tarballs — `packages/wizard/tsup.config.ts` copies it into `dist/skills` +and `package.json` lists that under `files`. Without the bump the published +wizard keeps shipping the old text until some unrelated change moves its +version. + +No runtime behaviour changes. The one non-documentation change is the +`databaseUrl?: never` field on `EncryptedSupabaseWasmOptions`, which is +type-level: it rejects at compile time a call that already threw at +construction. diff --git a/packages/stack-supabase/README.md b/packages/stack-supabase/README.md index e0524d2ae..de76c84fc 100644 --- a/packages/stack-supabase/README.md +++ b/packages/stack-supabase/README.md @@ -109,8 +109,11 @@ a WASM blob inlined into the bundle, and you declare your tables instead of intr | `@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. +After construction the wrapper behaves the same — the filters and the response shape are +identical — with two exceptions. Declared mode refuses `select('*')` and bare `select()`, so name +the columns you want; that refusal is not a read backstop, because a query awaited with no +`.select()` at all still returns every column undecrypted. And `from()` on a table you did not +declare throws, because there is no introspected table list to fall back on. ```ts import { encryptedTable, types } from '@cipherstash/stack/eql/v3' diff --git a/packages/stack-supabase/__tests__/supabase-wasm-config.test-d.ts b/packages/stack-supabase/__tests__/supabase-wasm-config.test-d.ts index 4d90f4cba..db3d76b9c 100644 --- a/packages/stack-supabase/__tests__/supabase-wasm-config.test-d.ts +++ b/packages/stack-supabase/__tests__/supabase-wasm-config.test-d.ts @@ -22,6 +22,10 @@ * The negative is the floor the docs SHOULD describe: `clientId` + `clientKey` * with neither an access key nor a strategy satisfies no arm. * + * The file has since grown a second claim about the same options object — that + * `databaseUrl` cannot be passed at all. See the second `describe` for why + * leaving the field out was not, on its own, enough to enforce that. + * * 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 @@ -138,3 +142,121 @@ describe('the edge entry `config` accepts either auth arm', () => { >().toEqualTypeOf() }) }) + +/** + * `databaseUrl` is not merely ABSENT from the edge entry's options — it is + * declared `?: never` (`src/wasm-inline.ts`), and the difference between those + * two is the whole reason this block exists. + * + * Absence is enforced by excess-property checking alone, which fires on FRESH + * object literals only. An options object assembled as a `const` and passed by + * variable — which is what a Node → edge port actually holds, since the native + * entry's options are typically built once and reused — loses freshness at the + * declaration, carries no excess-property check at the call, and so + * type-checked clean before reaching the runtime throw in + * `makeEncryptedSupabase` (`src/create.ts`). The docs meanwhile claimed the + * type checker enforced it. + * + * Same failure mode and same fix as `WasmClientConfig.eqlVersion?: never` in + * `packages/stack/src/wasm-inline.ts`, whose comment describes exactly this + * shared-config-const path; this is its sibling one package along. + * + * The runtime throw stays regardless — it is the backstop for plain JS, where + * there is no type to consult. What is pinned here is the half a type CAN + * enforce, in both the shapes a caller writes it in. + */ +describe('the edge entry refuses `databaseUrl`', () => { + it('rejects it on a fresh object literal — the excess-property case', 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', + }, + // @ts-expect-error — this entry carries no Postgres driver and cannot introspect + databaseUrl: 'postgres://user:pass@localhost:5432/postgres', + }) + }) + + it('rejects it on a fresh object literal in the (url, key, options) form', async () => { + await encryptedSupabase( + 'https://project.supabase.co', + 'SUPABASE_ANON_KEY', + { + 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', + }, + // @ts-expect-error — this entry carries no Postgres driver and cannot introspect + databaseUrl: 'postgres://user:pass@localhost:5432/postgres', + }, + ) + }) + + it('rejects it when the options are passed by variable, not as a literal', async () => { + // Declared, not inlined: the literal's freshness is spent here, so the call + // below gets no excess-property check. This is the shape the `?: never` is + // for — without it this call type-checks and fails at run time instead. + const options = { + 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', + }, + databaseUrl: 'postgres://user:pass@localhost:5432/postgres', + } + + await encryptedSupabase( + supabaseClient, + // @ts-expect-error — `databaseUrl?: never` is what rejects this; absence would not + options, + ) + }) + + it('rejects it by variable in the (url, key, options) form too', async () => { + const options = { + 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', + }, + databaseUrl: 'postgres://user:pass@localhost:5432/postgres', + } + + await encryptedSupabase( + 'https://project.supabase.co', + 'SUPABASE_ANON_KEY', + // @ts-expect-error — `databaseUrl?: never` is what rejects this; absence would not + options, + ) + }) + + /** + * The positive control for the four `@ts-expect-error`s above: the same + * by-variable call, with the offending field removed, must still compile. A + * `?: never` that accidentally poisoned the whole options type would make + * every call above fail for the wrong reason and this one fail outright. + */ + it('still accepts the same options object once `databaseUrl` is dropped', async () => { + const options = { + 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', + }, + } + + await encryptedSupabase(supabaseClient, options) + }) +}) diff --git a/packages/stack-supabase/src/wasm-inline.ts b/packages/stack-supabase/src/wasm-inline.ts index e8c73ad5c..2c8772d9d 100644 --- a/packages/stack-supabase/src/wasm-inline.ts +++ b/packages/stack-supabase/src/wasm-inline.ts @@ -26,12 +26,33 @@ import { adaptWasmEncryption } from './wasm-client-adapter' * `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. - * - **`databaseUrl` is absent.** Introspection is the thing this entry cannot - * do; the option is refused at runtime, and this stops it being written. + * - **`databaseUrl` is refused, and refused by the type.** Introspection is the + * thing this entry cannot do — it carries no Postgres driver — so the field + * is declared `never` rather than merely left out. Omission is policed by + * excess-property checking alone, which fires on FRESH object literals: an + * options object assembled as a `const` and passed by variable, which is what + * a Node → edge port actually holds, type-checked clean and then hit the + * runtime throw in `makeEncryptedSupabase`. Mirrors + * `WasmClientConfig.eqlVersion?: never` in `packages/stack/src/wasm-inline.ts` + * — same gap, same fix, one package along. */ export interface EncryptedSupabaseWasmOptions { schemas: S config: WasmClientConfig + /** + * Declared only to be refused: this entry has no introspector to hand a + * connection string to. The type is defence in depth, not a replacement for + * the throw in `makeEncryptedSupabase` (`./create`), which is still the only + * thing a plain JS caller meets — see the third bullet above for why both + * are needed. + * + * As with `eqlVersion` on `WasmClientConfig`, `?: never` still admits an + * explicit `databaseUrl: undefined` without `exactOptionalPropertyTypes` (not + * enabled in this repo) and cannot be made to reject it. That is harmless + * here: the runtime guard tests the VALUE, so `undefined` is exactly the case + * it means to let through. + */ + databaseUrl?: never } /** diff --git a/scripts/__tests__/skills-select-star-not-a-read-backstop.test.mjs b/scripts/__tests__/skills-select-star-not-a-read-backstop.test.mjs new file mode 100644 index 000000000..e38177b11 --- /dev/null +++ b/scripts/__tests__/skills-select-star-not-a-read-backstop.test.mjs @@ -0,0 +1,256 @@ +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' + +/** + * The declared-mode `select('*')` refusal is NOT a read backstop, and any + * shipped document that states the refusal has to say so. + * + * ## The property + * + * `encryptedSupabase` built from declared `schemas` refuses `select('*')` and + * bare `select()` — `expandStarOrThrow()`, `packages/stack-supabase/src/ + * query-builder.ts:159-166`. It reads like a safety net: name your columns or + * get nothing. It is not one. A query awaited with **no `.select()` call at + * all** takes the other branch — `query-builder.ts:703-725` sends a raw `*` — + * and `decryptResults` returns it untouched on its `!hasSelect` passthrough + * (`packages/stack-supabase/src/query-results.ts:127-130`). Every column comes + * back undecrypted, declared or not. That is long-standing behaviour the + * source deliberately leaves alone; only the docs were wrong about it. + * + * So an undeclared encrypted column on a declared table has a backstop on + * WRITE (the `eql_v3_*` domain CHECK, which a NULL still passes) and none on + * READ. A document that names the refusal without that caveat invites exactly + * the inference this repo has already made once in print. + * + * ## Why nothing else catches it + * + * - 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 repository, where their coding agent reads it as + * instruction. `packages/stack-supabase/README.md` renders on the npm + * package page. + * - The runtime tests pin the two behaviours separately and correctly + * (`packages/stack-supabase/__tests__/supabase-declared-mode.test.ts`), but a + * passing test says nothing about what a document claims. + * - The specific failure was a CORRECTION THAT LANDED IN ONE OF TWO COPIES. + * `578783ad` fixed the claim in `skills/stash-supabase/SKILL.md` and left the + * same claim standing in `skills/stash-managed-platforms/SKILL.md`, in the + * same PR that edited both. Two shipped copies of one fact drift the moment + * one of them is edited, and no reviewer diff shows the copy that was not + * touched. + * + * ## What this catches, and what it does not + * + * Catches: a section of a shipped markdown document whose prose states that + * `select('*')` (or bare `select()`) is refused/rejected/throws, where no unit + * in that same section pairs "a call omitted" with "comes back undecrypted". + * + * Scope is the markdown SECTION (nearest preceding ATX heading), not the + * sentence: the caveat may be reworded, moved between bullets, or split off + * into its own paragraph without failing, but it cannot migrate to a different + * part of the document from the claim it corrects. Fenced code blocks are + * blanked before scanning, so a snippet demonstrating `select('*')` is not a + * claim about it. + * + * Does NOT catch: + * + * - The same claim in a `.ts`, `.tsx` or `.sql` file, or in a comment. Prose in + * shipped markdown is the surface that misled a reader here. + * - A document that omits the refusal entirely and separately implies reads are + * safe. There is no phrase to key on for that. + * - A caveat that is present but WRONG (say, one claiming the passthrough only + * applies to mutations). This asserts the caveat is stated, not that it is + * accurate — the accuracy check is reading `query-results.ts`. + * - The second false claim fixed alongside this one, that an ambient + * `DATABASE_URL` is ignored "with a warning" on the edge entry. Both the + * ambient read and the warning are gated on `introspector` + * (`packages/stack-supabase/src/create.ts:304,330`), which is `null` on the + * `wasm-inline` build. It is left unguarded deliberately: "a warning is + * logged" has no stable phrasing to key on, and the correction ("gated on the + * introspector") is a word that appears freely in any section discussing + * introspection, so every detector for it either misses rewordings or passes + * on unrelated prose. + */ + +/** + * 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, accurate for their dates. + * + * Identical to the set in `skills-supabase-edge-schema-entry.test.mjs`, and + * derived the same way — `:(glob)` magic so `*` stops at a path separator, and + * `packageReadmePathspecs()` for the two package roots that sit deeper than one + * level. + */ +const SHIPPED_GLOBS = [ + ':(glob)skills/*/SKILL.md', + ...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) +} + +/** + * Blank out fenced code blocks, preserving line numbering. + * + * A snippet is a demonstration, not a claim, and snippets legitimately contain + * both `select('*')` and the word "throws". Blanking rather than deleting keeps + * the reported line numbers pointing at the real file, and stops a `#` comment + * inside a snippet from being read as a heading. + */ +function blankFences(body) { + let inFence = false + return body + .split('\n') + .map((line) => { + if (/^\s*(?:`{3,}|~{3,})/.test(line)) { + inFence = !inFence + return '' + } + return inFence ? '' : line + }) + .join('\n') +} + +/** Sections split at ATX headings, as `{ heading, line, body }`. */ +function sections(body) { + const found = [] + let current = { heading: '(before the first heading)', line: 1, lines: [] } + body.split('\n').forEach((line, index) => { + if (/^#{1,6}\s/.test(line)) { + found.push(current) + current = { heading: line.trim(), line: index + 1, lines: [] } + } + current.lines.push(line) + }) + found.push(current) + return found.map(({ heading, line, lines }) => ({ + heading, + line, + body: lines.join('\n'), + })) +} + +/** + * A section's prose as whitespace-normalised units — one per paragraph and one + * per list item, so a claim and the bullet below it are not read as one + * sentence. + */ +function units(sectionBody) { + const collected = [] + let current = [] + const flush = () => { + const text = current.join(' ').replace(/\s+/g, ' ').trim() + if (text) collected.push(text) + current = [] + } + for (const line of sectionBody.split('\n')) { + if ( + line.trim() === '' || + /^\s*(?:[-*+]|\d+[.)])\s/.test(line) || + /^#{1,6}\s/.test(line) + ) { + flush() + } + current.push(line) + } + flush() + return collected +} + +/** `select('*')`, `select("*")` or a bare `select()`, backticked or not. */ +const SELECT_STAR = /select\(\s*(?:['"`]\*['"`])?\s*\)/i + +/** + * The claim that it is refused. Deliberately narrow: "unavailable" and "does + * not work" are excluded because neighbouring bullets in + * `skills/stash-supabase/SKILL.md` use them about PostgREST's operator surface, + * and a unit is a bullet. + */ +const REFUSAL = + /\brefus\w*\b|\breject\w*\b|\bthrows?\b|\bthrowing\b|does not support|not supported|\bunsupported\b/i + +/** The caveat, in two halves that must appear in the same unit. */ +const OMITTED_CALL = + /(?:\bno\b|\bwithout\b|\bnever\b|\bomitt?\w*\b|\bbare\b)[\s\S]{0,40}?`?\.?select\(\s*\)/i +const UNDECRYPTED = + /\bundecrypted\b|\bnot decrypted\b|\bnever decrypted\b|\bnothing is decrypted\b|\bwithout decrypting\b/i + +function statesRefusal(unit) { + return SELECT_STAR.test(unit) && REFUSAL.test(unit) +} + +function statesCaveat(unit) { + return OMITTED_CALL.test(unit) && UNDECRYPTED.test(unit) +} + +/** Sections that claim the refusal without carrying the caveat. */ +function offendingSections(file, body) { + return sections(blankFences(body)) + .filter((section) => { + const parts = units(section.body) + return parts.some(statesRefusal) && !parts.some(statesCaveat) + }) + .map((section) => `${file}:${section.line} (${section.heading})`) +} + +describe("the select('*') refusal is documented as not a read backstop", () => { + 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('packages/stack-supabase/README.md') + }) + + /** + * A detector that stops matching anything is a guard that always passes. + * `skills/stash-supabase/SKILL.md` is the canonical adapter skill and carries + * both halves in one bullet — if this stops finding them, the regexes have + * decayed, not the docs. + */ + it('still recognises both halves in the canonical adapter skill', () => { + const parts = sections( + blankFences( + readFileSync( + resolve(REPO_ROOT, 'skills/stash-supabase/SKILL.md'), + 'utf8', + ), + ), + ).flatMap((section) => units(section.body)) + + expect(parts.filter(statesRefusal).length).toBeGreaterThan(0) + expect(parts.filter(statesCaveat).length).toBeGreaterThan(0) + }) + + it.each(files)('%s', (file) => { + const offenders = offendingSections( + file, + readFileSync(resolve(REPO_ROOT, file), 'utf8'), + ) + + expect( + offenders, + `${offenders.join(', ')} states that \`select('*')\` is refused in declared mode without ` + + 'the caveat that goes with it. The refusal is real, but it is not a read backstop: a query ' + + "awaited with no `.select()` call at all takes `query-builder.ts`'s raw-`*` branch and " + + '`decryptResults` passes it through on `!hasSelect`, so every column comes back undecrypted, ' + + 'declared or not. Say so in the same section — writes have a backstop (the `eql_v3_*` domain ' + + 'CHECK, though a NULL still passes) and reads have none. ' + + '`skills/stash-supabase/SKILL.md` carries the wording to match.', + ).toEqual([]) + }) +}) diff --git a/skills/stash-managed-platforms/SKILL.md b/skills/stash-managed-platforms/SKILL.md index a86e8af87..aacdc6834 100644 --- a/skills/stash-managed-platforms/SKILL.md +++ b/skills/stash-managed-platforms/SKILL.md @@ -178,15 +178,15 @@ const supabase = await encryptedSupabase(supabaseClient, { 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: +What declared mode gives up: -- **`select('*')` and bare `select()` are refused.** Nothing enumerated the table's plaintext columns, and an unexpanded `*` reaches PostgREST without the `::jsonb` casts encrypted columns need. List columns explicitly. +- **`select('*')` and bare `select()` are refused.** Nothing enumerated the table's plaintext columns, and an unexpanded `*` reaches PostgREST without the `::jsonb` casts encrypted columns need. List columns explicitly. **The refusal is not a read backstop.** A query awaited with no `.select()` call at all takes a different path: it sends a raw `*`, nothing is cast, and every column comes back undecrypted — declared or not, raw EQL payloads returned as `data` with no error. Writes have a backstop, in that a plaintext write to a real `eql_v3_*` column fails that domain's CHECK constraint, though a NULL still passes. Reads have none. - **`from()` on an undeclared table throws.** There is no introspected table list to fall back on. - **The drift check is gone** — nothing compared your declaration against the real column domains, so a wrong domain surfaces as a `23514` CHECK violation on the first write instead of at construction. On Node you can have both: pass `databaseUrl` **as well as** `schemas`. -⚠️ **The one tradeoff that is not loud — declare every encrypted column of every table you query.** Nothing introspects, so a column carrying an `eql_v3` domain in the database but missing from your `schemas` is treated as an ordinary plaintext column: a `select` naming it hands you the raw EQL payload as data, and a filter on it sends your **plaintext** value to PostgREST. There is no error, because nothing knows the column is encrypted. If you cannot guarantee the declaration is complete, pass `databaseUrl` so introspection fills the gaps. +⚠️ **The other silent failure — declare every encrypted column of every table you query.** Nothing introspects, so a column carrying an `eql_v3` domain in the database but missing from your `schemas` is treated as an ordinary plaintext column: a `select` naming it hands you the raw EQL payload as data, and a filter on it sends your **plaintext** value to PostgREST. There is no error, because nothing knows the column is encrypted. If you cannot guarantee the declaration is complete, pass `databaseUrl` so introspection fills the gaps. -An ambient `DATABASE_URL` will not overrule your declaration — it is ignored when `schemas` are passed, with a warning that the declaration is unverified. Pass `databaseUrl` explicitly if you want introspection. +On Node, an ambient `DATABASE_URL` will not overrule your declaration — it is ignored when `schemas` are passed, and the native entry logs a warning that the declaration is unverified. Pass `databaseUrl` explicitly if you want introspection. Neither the ambient read nor that warning exists on the `wasm-inline` entry: both are gated on the introspector, which the edge build does not have, so nothing there will ever tell you a declaration is incomplete. Passing `databaseUrl` to the `wasm-inline` entry is refused outright — it carries no Postgres driver, and saying so beats ignoring the option. diff --git a/skills/stash-supabase/SKILL.md b/skills/stash-supabase/SKILL.md index 70cfa95f1..a73959810 100644 --- a/skills/stash-supabase/SKILL.md +++ b/skills/stash-supabase/SKILL.md @@ -341,8 +341,14 @@ has neither: | `@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. +After construction the wrapper behaves the same — the filters, the transforms +and the response shape are identical — with two exceptions, both of them +declared mode rather than the engine. `select('*')` and bare `select()` are +refused, so name the columns you want; that refusal is not a read backstop, +because a query awaited with no `.select()` at all still returns every column +undecrypted. And `from()` on a table you did not declare throws, because there +is no introspected table list to fall back on. Both are expanded in the bullets +below. ```typescript import { encryptedTable, types } from "@cipherstash/stack/eql/v3" @@ -398,8 +404,10 @@ checker: 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. +- **`databaseUrl` is not accepted.** The options type declares it + `databaseUrl?: never`, so passing one is a type error whether you write the + options inline or build them as a `const` first, and a construction-time + throw for callers arriving from plain JS. - **`.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 From 3979948b3153da79c720b5da3098605645f4d06c Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 31 Aug 2026 12:37:43 +1000 Subject: [PATCH 5/7] docs(supabase): attribute the Node-only restriction to the engine, not introspection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The README and the Supabase skill both wrote "introspection needs a direct Postgres connection, so ... this entry runs on Node only". Introspection is not what makes the entry Node-only — it binds the native engine, and declaring schemas does not move that. The stated cause invites the conclusion that declaring tables makes the root entry edge-capable, which is the wrong turn the wasm-inline entry exists to prevent. This is the same causal inversion #952 corrected in the package's .d.ts. Two smaller instances in skills/stash-supabase/SKILL.md go with it: a bare "Worker" where the surrounding paragraph means any edge runtime, and the co-cause "and the engine is a native module, so ..." split into its own sentence so the engine carries the claim alone. Claude-Session: https://claude.ai/code/session_01133kxi8qXu6UJPiKJDwDt6 --- .changeset/supabase-docs-wasm-inline-entry.md | 11 +++++++++++ packages/stack-supabase/README.md | 5 +++-- skills/stash-supabase/SKILL.md | 4 ++-- 3 files changed, 16 insertions(+), 4 deletions(-) diff --git a/.changeset/supabase-docs-wasm-inline-entry.md b/.changeset/supabase-docs-wasm-inline-entry.md index d0568b59f..96f52f038 100644 --- a/.changeset/supabase-docs-wasm-inline-entry.md +++ b/.changeset/supabase-docs-wasm-inline-entry.md @@ -155,6 +155,17 @@ and `package.json` lists that under `files`. Without the bump the published wizard keeps shipping the old text until some unrelated change moves its version. +One more wording correction, of the kind #952 fixed in this package's `.d.ts`: +`packages/stack-supabase/README.md` and `skills/stash-supabase/SKILL.md` both +derived "runs on Node only" from introspection — "introspection needs a direct +Postgres connection, **so** … this entry runs on Node only". Introspection is +not the cause. The entry binds the native engine, so it is Node-only whether or +not you declare `schemas`; a reader who took the stated cause at face value +would conclude that declaring tables makes the root entry edge-capable, which is +the exact wrong turn the `wasm-inline` entry exists to prevent. Both sentences +now attribute the restriction to the engine and say that declaring `schemas` +does not move it. + No runtime behaviour changes. The one non-documentation change is the `databaseUrl?: never` field on `EncryptedSupabaseWasmOptions`, which is type-level: it rejects at compile time a call that already threw at diff --git a/packages/stack-supabase/README.md b/packages/stack-supabase/README.md index de76c84fc..e55e2e0d2 100644 --- a/packages/stack-supabase/README.md +++ b/packages/stack-supabase/README.md @@ -87,8 +87,9 @@ Full guide: [Supabase quickstart →][supabase-docs] config to maintain — `select('*')` just works, inserts and updates encrypt automatically, and reads decrypt automatically. -Introspection needs a direct Postgres connection (`DATABASE_URL`), so `pg` is an optional peer -dependency and this entry runs on Node only — construct it in your server-side code. For an +Introspection needs a direct Postgres connection (`DATABASE_URL`), which is why `pg` is an +optional peer dependency. This entry runs on Node only either way — it binds the native engine, +and declaring `schemas` doesn't move that — so construct it in your server-side code. For an edge runtime, see the second entry point below. It runs alongside Supabase Auth and RLS, and supports diff --git a/skills/stash-supabase/SKILL.md b/skills/stash-supabase/SKILL.md index a73959810..773f224bb 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. Step 5 of Setup has the call shape. +> **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 an edge runtime, 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. @@ -268,7 +268,7 @@ 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`), and the engine is a native module, so **this entry runs on +`DATABASE_URL`). 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. From 1a7db49201d36500a0ed57d86c071bf8ebe6a835 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 31 Aug 2026 12:40:54 +1000 Subject: [PATCH 6/7] test(supabase): enrol the README and the Supabase skill in the runtime-claims guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit supabase-runtime-claims.test.mjs shipped with its own instruction: add packages/stack-supabase/README.md when #951 lands, held off only because this branch was rewriting the same lines. Both sentences now attribute the Node-only restriction to the engine, so the README goes on the list, and so does skills/stash-supabase/SKILL.md — the two copies that reach a customer, one as the npm package page and one copied into their repo by stash init. Enrolling the skill needed one sentence changed: "Removing the pg dependency would unblock Workers, not browsers" left "Workers" unqualified, and its opener made introspection the thing keeping the entry out of a browser. Neither is true of the browser, which is blocked by the workspace clientKey (#804) regardless. stash-managed-platforms is left off, with the reason recorded in the docblock: its causal claims are correct, but it trips the unqualified-"Worker" detector in its frontmatter description, where a reword changes what the skill matches on. Claude-Session: https://claude.ai/code/session_01133kxi8qXu6UJPiKJDwDt6 --- .changeset/supabase-docs-wasm-inline-entry.md | 9 +++++- .../supabase-runtime-claims.test.mjs | 31 +++++++++++++------ skills/stash-supabase/SKILL.md | 11 ++++--- 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/.changeset/supabase-docs-wasm-inline-entry.md b/.changeset/supabase-docs-wasm-inline-entry.md index 96f52f038..69f54db68 100644 --- a/.changeset/supabase-docs-wasm-inline-entry.md +++ b/.changeset/supabase-docs-wasm-inline-entry.md @@ -164,7 +164,14 @@ not you declare `schemas`; a reader who took the stated cause at face value would conclude that declaring tables makes the root entry edge-capable, which is the exact wrong turn the `wasm-inline` entry exists to prevent. Both sentences now attribute the restriction to the engine and say that declaring `schemas` -does not move it. +does not move it. Both files are enrolled in #952's +`scripts/__tests__/supabase-runtime-claims.test.mjs`, which is what its own +comment said to do once this branch stopped rewriting the same lines — the +README and this skill are the two copies that reach a customer, and the guard +now fails if either grows the claim back. `skills/stash-managed-platforms/SKILL.md` +stays out, with the reason written down: its causal claims are correct, but +rewording the unqualified "Worker" in its frontmatter `description` changes +what the skill matches on, which is not a rider on a documentation fix. No runtime behaviour changes. The one non-documentation change is the `databaseUrl?: never` field on `EncryptedSupabaseWasmOptions`, which is diff --git a/scripts/__tests__/supabase-runtime-claims.test.mjs b/scripts/__tests__/supabase-runtime-claims.test.mjs index c60ee582a..4ec96d4b5 100644 --- a/scripts/__tests__/supabase-runtime-claims.test.mjs +++ b/scripts/__tests__/supabase-runtime-claims.test.mjs @@ -48,19 +48,32 @@ import { REPO_ROOT } from './lib/repo-root.mjs' */ /** - * The reference doc, plus the three sources whose TSDoc ships in `.d.ts`. + * The reference doc, the two shipped documents, and the three sources whose + * TSDoc ships in `.d.ts`. * - * `packages/stack-supabase/README.md` belongs on this list and is NOT on it - * yet. It ships in the tarball and carries defect 1 verbatim — "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" - * — but the same lines are being rewritten on the branch behind #951, which - * keeps the false `so` while dropping the browser half. Editing them from two - * branches is a conflict for no gain. **Add the path here when #951 lands**; - * the guard will name whatever survives the merge. + * `packages/stack-supabase/README.md` and `skills/stash-supabase/SKILL.md` + * were both held off this list while #951 rewrote the same lines — the README + * carried defect 1 verbatim, and #951's first draft kept the false `so` while + * dropping the browser half. This comment is part of #951: both sentences now + * attribute the restriction to the engine, so both files are enrolled. They + * are the two that reach a customer — the README is the npm package page, and + * `stash init` copies the skill into the customer's own repository, where + * their coding agent reads it as instruction. + * + * `skills/stash-managed-platforms/SKILL.md` is deliberately NOT enrolled. Its + * causal claims are correct, but it trips the unqualified-"Worker" detector + * four times: twice in its YAML frontmatter `description`, once in a section + * heading (`## encryptedSupabase in a Worker`), and once on "**An edge / + * Workers runtime** for server code" — the last a false positive, since the + * qualifier is there and only the slash keeps it out of the context pattern. + * The frontmatter is load-bearing for skill selection, so rewording it is its + * own change with its own review rather than a rider on this one. Enrol the + * file then. */ const GUARDED = [ 'docs/reference/supabase-sdk.md', + 'packages/stack-supabase/README.md', + 'skills/stash-supabase/SKILL.md', 'packages/stack-supabase/src/index.ts', 'packages/stack-supabase/src/create.ts', 'packages/stack-supabase/src/wasm-inline.ts', diff --git a/skills/stash-supabase/SKILL.md b/skills/stash-supabase/SKILL.md index 773f224bb..55c1c785a 100644 --- a/skills/stash-supabase/SKILL.md +++ b/skills/stash-supabase/SKILL.md @@ -272,11 +272,12 @@ needs a direct Postgres connection (`options.databaseUrl`, defaulting to Node only**. On an edge runtime, import the `wasm-inline` entry instead — step 5 below. -Introspection is not the only thing keeping this out of a browser. On the -WASM entry, `config.clientKey` is a workspace secret and is required on -*every* auth path — supplying a per-user `config.authStrategy` does not -remove it ([#804](https://github.com/cipherstash/stack/issues/804)). Removing -the `pg` dependency would unblock Workers, not browsers. +The native engine and `pg` are not the only things keeping this out of a +browser. On the WASM entry, `config.clientKey` is a workspace secret and is +required on *every* auth path — supplying a per-user `config.authStrategy` +does not remove it ([#804](https://github.com/cipherstash/stack/issues/804)). +Dropping the native engine and the `pg` dependency is what unblocks Deno, +Supabase Edge Functions and Cloudflare Workers; nothing unblocks a browser. Options: `{ schemas?, databaseUrl?, config? }` — `config` is the encryption client config (e.g. `config.authStrategy`, see Authentication below). From 4d7174680ef19aaac1c76a32f44963e235f98ee2 Mon Sep 17 00:00:00 2001 From: Toby Hede Date: Mon, 31 Aug 2026 12:56:08 +1000 Subject: [PATCH 7/7] docs(skills): the undeclared-column remedy was the option this entry refuses stash-managed-platforms told readers to pass databaseUrl "so introspection fills the gaps" when they cannot guarantee their schemas are complete. That sentence sits under "## encryptedSupabase in a Worker", four lines above the sentence saying databaseUrl is refused outright on that entry. Same fault as the ambient-DATABASE_URL sentence corrected in 981f86a3, in the adjacent paragraph, and it lands on the readers this skill is written for: an agent on Lovable, v0, Bolt or Replit has only the edge entry, so following the remedy gets a construction throw. It now says introspection is unavailable there, that the declaration is the only thing between a schema change and a silent plaintext write, and that the Node entry is where databaseUrl covers you. Claude-Session: https://claude.ai/code/session_01133kxi8qXu6UJPiKJDwDt6 --- .changeset/supabase-docs-wasm-inline-entry.md | 7 ++++++- skills/stash-managed-platforms/SKILL.md | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/.changeset/supabase-docs-wasm-inline-entry.md b/.changeset/supabase-docs-wasm-inline-entry.md index 69f54db68..c494f6652 100644 --- a/.changeset/supabase-docs-wasm-inline-entry.md +++ b/.changeset/supabase-docs-wasm-inline-entry.md @@ -147,7 +147,12 @@ Review found four more, one of them a change to a published type: unverified" — in a section whose subject is `wasm-inline`. Both the ambient read and the warning are gated on the introspector, which that build does not have, so nothing there ever tells you a declaration is incomplete. Now scoped - to Node, with the edge case stated. + to Node, with the edge case stated. The sentence immediately above it had the + same fault and is fixed with it: the ⚠️ callout on undeclared columns offered + "pass `databaseUrl` so introspection fills the gaps" as the remedy, inside a + section about the entry that refuses `databaseUrl` — and it is the remedy a + Lovable or Replit agent, which has only the edge entry, would have reached + for. It now says introspection is unavailable there and what to do instead. `@cipherstash/wizard` is bumped alongside `stash` because `skills/` ships in both tarballs — `packages/wizard/tsup.config.ts` copies it into `dist/skills` diff --git a/skills/stash-managed-platforms/SKILL.md b/skills/stash-managed-platforms/SKILL.md index aacdc6834..799d2f08e 100644 --- a/skills/stash-managed-platforms/SKILL.md +++ b/skills/stash-managed-platforms/SKILL.md @@ -184,7 +184,7 @@ What declared mode gives up: - **`from()` on an undeclared table throws.** There is no introspected table list to fall back on. - **The drift check is gone** — nothing compared your declaration against the real column domains, so a wrong domain surfaces as a `23514` CHECK violation on the first write instead of at construction. On Node you can have both: pass `databaseUrl` **as well as** `schemas`. -⚠️ **The other silent failure — declare every encrypted column of every table you query.** Nothing introspects, so a column carrying an `eql_v3` domain in the database but missing from your `schemas` is treated as an ordinary plaintext column: a `select` naming it hands you the raw EQL payload as data, and a filter on it sends your **plaintext** value to PostgREST. There is no error, because nothing knows the column is encrypted. If you cannot guarantee the declaration is complete, pass `databaseUrl` so introspection fills the gaps. +⚠️ **The other silent failure — declare every encrypted column of every table you query.** Nothing introspects, so a column carrying an `eql_v3` domain in the database but missing from your `schemas` is treated as an ordinary plaintext column: a `select` naming it hands you the raw EQL payload as data, and a filter on it sends your **plaintext** value to PostgREST. There is no error, because nothing knows the column is encrypted. Introspection is what fills that gap, and it is not available here — `databaseUrl` is refused on this entry (below), so the declaration is the only thing standing between a schema change and a silent plaintext write. Re-check it whenever a column becomes encrypted. On Node, passing `databaseUrl` alongside `schemas` covers you instead. On Node, an ambient `DATABASE_URL` will not overrule your declaration — it is ignored when `schemas` are passed, and the native entry logs a warning that the declaration is unverified. Pass `databaseUrl` explicitly if you want introspection. Neither the ambient read nor that warning exists on the `wasm-inline` entry: both are gated on the introspector, which the edge build does not have, so nothing there will ever tell you a declaration is incomplete.