From 015e8daac42db753ad19e77bdbceaf8943dd8b42 Mon Sep 17 00:00:00 2001 From: Dan Draper Date: Thu, 9 Jul 2026 19:56:50 +1000 Subject: [PATCH] docs(supabase): correct the stash-supabase skill (checkpoint before restructure) Same defect classes as the drizzle and encryption skills, plus two of its own. - `CREATE EXTENSION IF NOT EXISTS eql_v2;` -- no such extension. EQL is a schema plus a composite type, installed by `stash eql install --supabase`. The skill omitted the v2 install path entirely; added it, with `--migration` preferred over `--direct` because a direct install does not survive `supabase db reset`. - Encrypted columns declared `jsonb NOT NULL` in the headline schema, which the doctrine forbids -- and which the skill itself forbids 550 lines later. - An additive `db push` is promoted by `stash db activate`, not by cutover. The skill said "Cutover (later) will promote it". It won't, and the cutover-time push calls `discardPendingConfig()` first, so the additive pending is discarded and Proxy serves a stale config for the whole dual-write window. `db activate` appeared nowhere. - Identity-aware encryption taught the deprecated `LockContext.identify()` ceremony. Replaced with `OidcFederationStrategy` on `config.authStrategy` plus a directly-constructed `LockContext`. Note that the Supabase builder's `.withLockContext()` is typed `LockContext` only, so unlike the core operations it will not take a bare `{ identityClaim }`. - `error.encryptionError` is always `undefined` -- `query-builder.ts:371-374` hardcodes it when building the error, discarding the populated value. The skill told users to branch on it. Documented the working discriminator (`status === 500 && statusText === 'Encryption Error'`). - `.filter(col, op, value)` always encrypts an `equality` operand regardless of `op`, so `.filter('age','gt',21)` cannot match. Documented, with the contrast that a wrong index on a *declared* column errors (good), while an *undeclared* column silently compares plaintext (bad). - Two Linear issue IDs (`CIP-3402`) were shipping to customers in this skill. Removed -- it is a public artifact copied into user repos. - Repointed closed #447 at open #585; noted the known #602 authStrategy type error; flagged that EQL v3 on Supabase installs direct-only (`--migration` is rejected), so `supabase db reset` drops it. --- skills/stash-supabase/SKILL.md | 87 +++++++++++++++++++++++++++------- 1 file changed, 69 insertions(+), 18 deletions(-) diff --git a/skills/stash-supabase/SKILL.md b/skills/stash-supabase/SKILL.md index a77f3cbeb..53618d253 100644 --- a/skills/stash-supabase/SKILL.md +++ b/skills/stash-supabase/SKILL.md @@ -23,25 +23,33 @@ npm install @cipherstash/stack @supabase/supabase-js ## Database Schema -Encrypted columns must be stored as JSONB in your Supabase database: +Encrypted columns are stored as JSONB in your Supabase database: ```sql CREATE TABLE users ( id SERIAL PRIMARY KEY, - email jsonb NOT NULL, -- encrypted column - name jsonb NOT NULL, -- encrypted column + email jsonb, -- encrypted column + name jsonb, -- encrypted column age jsonb, -- encrypted column (numeric) role VARCHAR(50), -- regular column (not encrypted) created_at TIMESTAMPTZ DEFAULT NOW() ); ``` -For searchable encryption (equality, range, text search), install the EQL extension: +> **Encrypted columns are nullable.** Never add `NOT NULL` at creation. The application writes ciphertext *after* the column exists, so a `NOT NULL` constraint breaks inserts during a rollout. Never declare them `text`, `varchar`, or `bytea`. -```sql -CREATE EXTENSION IF NOT EXISTS eql_v2; +For searchable encryption (equality, range, text search) you need EQL. **EQL is not a PostgreSQL extension — do not `CREATE EXTENSION eql_v2`.** It is a schema (`eql_v2`) plus a composite type (`public.eql_v2_encrypted`), installed by the CLI: + +```bash +npx stash eql install --supabase --migration ``` +`--migration` writes `supabase/migrations/00000000000000_cipherstash_eql.sql`. The all-zero timestamp guarantees it runs before any migration that references `eql_v2_encrypted`. Apply it with `supabase db reset` (local) or `supabase migration up` (remote). + +`--supabase` installs a Supabase-compatible variant: no PostgreSQL operator families, and it grants the `anon`, `authenticated` and `service_role` roles. + +> Prefer `--migration` over `--direct`. A direct install does **not** survive `supabase db reset` — the reset drops the database and replays only the files in `supabase/migrations/`. + ## Setup ### 1. Define Encrypted Schema @@ -251,6 +259,14 @@ Both forms encrypt values for encrypted columns automatically. .filter("email", "eq", "alice@example.com") ``` +> **`.filter()` always encrypts the operand as an `equality` term**, whatever operator you name. `.filter("age", "gt", 21)` therefore builds an equality-indexed operand for a range operator and will not match. Use the dedicated `.gt()` / `.gte()` / `.lt()` / `.lte()` methods for range comparisons. `.match()` is equality-only for the same reason. + +### Two failure modes worth knowing + +**Wrong index on a declared column → it errors.** Filtering `.gt()` on a column declared only with `.equality()` throws `Index type "..." is not configured on column "..."`, surfaced as an encryption error. That is the good case — unlike the Drizzle adapter, Supabase does not silently degrade here. + +**Column missing from the schema → it silently compares plaintext.** If a column isn't declared in the `encryptedTable` passed to `.from(table, schema)` — a typo, or a column you forgot to add — the adapter treats it as a plaintext column, skips encryption, and sends your raw value to PostgREST to compare against a JSONB ciphertext. No error; no rows. If a filter mysteriously returns nothing, check the column is actually in the schema. + ## Delete ```typescript @@ -288,15 +304,31 @@ Operator family support is currently being developed in collaboration with the S ## Identity-Aware Encryption -Chain `.withLockContext()` to tie encryption to a specific user's JWT: +Bind a data key to a claim from the end user's JWT, so only that user can decrypt. + +Two parts: **authenticate the client as the user** with `OidcFederationStrategy`, then chain **`.withLockContext()`** on the query. ```typescript +import { Encryption, OidcFederationStrategy } from "@cipherstash/stack" import { LockContext } from "@cipherstash/stack/identity" -const lc = new LockContext() -const identified = await lc.identify(userJwt) -if (identified.failure) throw new Error(identified.failure.message) -const lockContext = identified.data +// 1. Authenticate the client as the end user. `getJwt` returns the current +// Supabase access token and is re-invoked on every (re-)federation. +const strategy = OidcFederationStrategy.create( + process.env.CS_WORKSPACE_CRN!, + () => getSupabaseAccessToken(), +) +if (strategy.failure) { + throw new Error(`[auth] ${strategy.failure.type}: ${strategy.failure.error.message}`) +} + +const client = await Encryption({ + schemas: [users], + config: { authStrategy: strategy.data }, +}) + +// 2. Bind the data key to the user's `sub` claim. No `identify()` call. +const lockContext = new LockContext() // defaults to the "sub" claim const { data, error } = await eSupabase .from("users", users) @@ -305,6 +337,14 @@ const { data, error } = await eSupabase .select("id") ``` +The **same** lock context must be supplied when reading the row back — the claim is baked into the data key's tag, so decrypting without it fails. + +> **Known type error (runtime is fine).** `authStrategy: strategy.data` does not currently typecheck: `@cipherstash/auth` 0.41 strategies declare `getToken(): Promise>`, while `@cipherstash/protect-ffi`'s exported `AuthStrategy` type still says `Promise<{ token: string }>`. protect-ffi accepts **both** shapes at runtime; only its TypeScript declaration lagged. Until it's widened, add `as unknown as AuthStrategy`. Tracked in [issue #602](https://github.com/cipherstash/stack/issues/602). + +> **Don't call `LockContext.identify()`.** Per-operation CTS tokens were removed in `protect-ffi` 0.25. `identify()` still exists for backwards compatibility, but the token it fetches is no longer used by encryption. Construct the `LockContext` directly and authenticate the client with `OidcFederationStrategy` instead. + +> **The Supabase builder wants a `LockContext` instance.** Core operations (`encryptModel`, `encrypt`, …) also accept a plain `{ identityClaim: ["sub"] }`, but `.withLockContext()` on the Supabase query builder is typed as `LockContext` only. Pass `new LockContext({ context: { identityClaim: ["sub", "org_id"] } })` for a custom claim set. + ## Audit Logging Chain `.audit()` to attach metadata for ZeroKMS audit logging: @@ -369,7 +409,9 @@ type EncryptedSupabaseResponse = { } ``` -Errors can come from Supabase (API errors) or from encryption operations. Check `error.encryptionError` for encryption-specific failures. +Errors can come from Supabase (API errors) or from encryption operations. + +> **Don't branch on `error.encryptionError` — it is always `undefined`.** The builder's catch block hardcodes `encryptionError: undefined` when constructing the error, so the populated value is discarded even for a genuine encryption failure. Distinguish encryption failures by `status === 500 && statusText === 'Encryption Error'` instead, or use `.throwOnError()` and catch `EncryptionFailedError`. The full `EncryptedSupabaseError` type: @@ -401,7 +443,8 @@ type EncryptedSupabaseError = { - `EncryptedQueryBuilder` - `PendingOrCondition` - `SupabaseClientLike` -- `EncryptedSupabaseV3Config`, `EncryptedSupabaseV3Instance`, `EncryptedQueryBuilderV3` (EQL v3) +- `EncryptedSupabaseResponse`, `EncryptedSupabaseError` +- `EncryptedSupabaseV3Config`, `EncryptedSupabaseV3Instance`, `EncryptedQueryBuilderV3`, `V3FilterableKeys` (EQL v3) ## EQL v3 (native `eql_v3.*` domains) @@ -472,6 +515,8 @@ CREATE TABLE users ( stash eql install --eql-version 3 --supabase ``` +> **v3 installs via the direct path only.** `--migration` (and `--drizzle`, `--latest`, `--migrations-dir`) are rejected under `--eql-version 3`. That means there is no `supabase/migrations/` file for EQL v3 — so **`supabase db reset` drops it**, because the reset replays only the files in that directory. Re-run the install after every reset, and be aware this differs from the v2 path, where `--migration` is available and preferred. + This installs the opclass-stripped v3 bundle (operator classes need superuser, which Supabase does not grant) and applies the grants for the `anon` / `authenticated` / `service_role` roles. The vendored bundle is the @@ -498,7 +543,7 @@ and receives — the role grants; grants and exposure are independent. All envelopes (stored payloads and filter operands) are versioned `v: 3`. - **INTERIM — filter operands are full envelopes.** This is a workaround, not - the design (tracked as Linear **CIP-3402**). Why it is required today: every + the design; a term-only query envelope is planned. Why it is required today: every `eql_v3.*` domain CHECK requires the storage keys (`v`/`i`/`c` plus the domain's index terms), and the SQL operators coerce their operand into the domain — so the adapter encrypts each filter value with the full storage @@ -522,7 +567,7 @@ All envelopes (stored payloads and filter operands) are versioned `v: 3`. the full-envelope operand's bloom carries the whole pattern as an extra token that only matches when the pattern equals the stored value. This is a symptom of the same full-envelope interim mechanism above and goes away with - the term-only query envelope (CIP-3402). + the term-only query envelope planned above. - **Storage-only domains are not filterable** (e.g. `types.Boolean`, `types.Text`): a filter (including `.match()`) on one is a type error, and always a clear runtime error. @@ -543,7 +588,7 @@ The hard case: a Supabase table that already exists with live data in a plaintex CipherStash splits this into two named steps with a hard production-deploy gate between them: an **encryption rollout** (schema-add + dual-write code) and an **encryption cutover** (backfill + rename + drop). The `stash-encryption` skill is the canonical reference for the lifecycle; this section walks the Supabase-specific shape. -> **Using CipherStash Proxy?** If you query encrypted data through [CipherStash Proxy](https://github.com/cipherstash/proxy) instead of the SDK, also run `stash db push` after schema-add and again before cutover to register the encrypted column shape with EQL. +> **Using CipherStash Proxy?** If you query encrypted data through [CipherStash Proxy](https://github.com/cipherstash/proxy) instead of the SDK, also run `stash db push` after schema-add (then `stash db activate` to promote it) and again before cutover, to register the encrypted column shape with EQL. SDK users skip both commands. > **Runner note.** `stash init` adds `stash` to the project as a dev dependency, so `stash ` runs through whichever package manager the project uses (Bun, pnpm, Yarn, or npm) — examples below show this bare form. Before init has run, prefix with your package manager's one-shot runner: `bunx`, `pnpm dlx`, `yarn dlx`, or `npx`. The CLI's behaviour is identical across all of them. @@ -611,7 +656,13 @@ export const encryptionClient = await Encryption({ schemas: [users] }) > stash db push > ``` > -> If this is the project's first encrypted column, `db push` writes directly to the active EQL config. If an active config already exists, it writes the new config as `pending` — that's expected. Cutover (later) will promote it. +> If this is the project's first encrypted column, `db push` writes directly to the active EQL config and you're done. If an active config already exists, it writes the new config as `pending` — **promote it now with `stash db activate`.** +> +> ```bash +> stash db activate +> ``` +> +> This step is easy to skip and the failure is silent. `stash encrypt cutover` promotes only the *rename* pending, later in the cutover step — it will not promote this additive one. Worse, the cutover-time `db push` calls `discardPendingConfig()` before writing its own pending, so an un-activated rollout pending is thrown away. Proxy would keep serving the old active config, which knows nothing about `email_encrypted`, for the whole dual-write window. > > **SDK users:** Skip this step. Your encryption config lives in app code. @@ -687,7 +738,7 @@ export const users = encryptedTable('users', { }) ``` -> **Known gap (SDK-only users):** `stash encrypt cutover` currently requires a pending EQL configuration, which is set by `stash db push`. If you're using the SDK without Proxy, you'll hit a "No pending EQL configuration" error from cutover. **Workaround:** run `stash db push` once before `stash encrypt cutover`. This will be decoupled in a future release — see [issue #447](https://github.com/cipherstash/stack/issues/447). +> **Known gap (SDK-only users):** `stash encrypt cutover` currently requires a pending EQL configuration, which is set by `stash db push`. If you're using the SDK without Proxy, you'll hit a "No pending EQL configuration" error from cutover. **Workaround:** run `stash db push` once before `stash encrypt cutover`. Decoupling this is tracked in [issue #585](https://github.com/cipherstash/stack/issues/585) — under EQL v3 there is no configuration table at all, so the precondition disappears. > > **Using CipherStash Proxy?** Re-push the encryption config so EQL has a pending row that points at `email` (no `_encrypted` suffix): >