Skip to content

Commit 015e8da

Browse files
committed
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.
1 parent a91c0db commit 015e8da

1 file changed

Lines changed: 69 additions & 18 deletions

File tree

skills/stash-supabase/SKILL.md

Lines changed: 69 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -23,25 +23,33 @@ npm install @cipherstash/stack @supabase/supabase-js
2323

2424
## Database Schema
2525

26-
Encrypted columns must be stored as JSONB in your Supabase database:
26+
Encrypted columns are stored as JSONB in your Supabase database:
2727

2828
```sql
2929
CREATE TABLE users (
3030
id SERIAL PRIMARY KEY,
31-
email jsonb NOT NULL, -- encrypted column
32-
name jsonb NOT NULL, -- encrypted column
31+
email jsonb, -- encrypted column
32+
name jsonb, -- encrypted column
3333
age jsonb, -- encrypted column (numeric)
3434
role VARCHAR(50), -- regular column (not encrypted)
3535
created_at TIMESTAMPTZ DEFAULT NOW()
3636
);
3737
```
3838

39-
For searchable encryption (equality, range, text search), install the EQL extension:
39+
> **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`.
4040
41-
```sql
42-
CREATE EXTENSION IF NOT EXISTS eql_v2;
41+
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:
42+
43+
```bash
44+
npx stash eql install --supabase --migration
4345
```
4446

47+
`--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).
48+
49+
`--supabase` installs a Supabase-compatible variant: no PostgreSQL operator families, and it grants the `anon`, `authenticated` and `service_role` roles.
50+
51+
> 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/`.
52+
4553
## Setup
4654

4755
### 1. Define Encrypted Schema
@@ -251,6 +259,14 @@ Both forms encrypt values for encrypted columns automatically.
251259
.filter("email", "eq", "alice@example.com")
252260
```
253261

262+
> **`.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.
263+
264+
### Two failure modes worth knowing
265+
266+
**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.
267+
268+
**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.
269+
254270
## Delete
255271

256272
```typescript
@@ -288,15 +304,31 @@ Operator family support is currently being developed in collaboration with the S
288304

289305
## Identity-Aware Encryption
290306

291-
Chain `.withLockContext()` to tie encryption to a specific user's JWT:
307+
Bind a data key to a claim from the end user's JWT, so only that user can decrypt.
308+
309+
Two parts: **authenticate the client as the user** with `OidcFederationStrategy`, then chain **`.withLockContext()`** on the query.
292310

293311
```typescript
312+
import { Encryption, OidcFederationStrategy } from "@cipherstash/stack"
294313
import { LockContext } from "@cipherstash/stack/identity"
295314

296-
const lc = new LockContext()
297-
const identified = await lc.identify(userJwt)
298-
if (identified.failure) throw new Error(identified.failure.message)
299-
const lockContext = identified.data
315+
// 1. Authenticate the client as the end user. `getJwt` returns the current
316+
// Supabase access token and is re-invoked on every (re-)federation.
317+
const strategy = OidcFederationStrategy.create(
318+
process.env.CS_WORKSPACE_CRN!,
319+
() => getSupabaseAccessToken(),
320+
)
321+
if (strategy.failure) {
322+
throw new Error(`[auth] ${strategy.failure.type}: ${strategy.failure.error.message}`)
323+
}
324+
325+
const client = await Encryption({
326+
schemas: [users],
327+
config: { authStrategy: strategy.data },
328+
})
329+
330+
// 2. Bind the data key to the user's `sub` claim. No `identify()` call.
331+
const lockContext = new LockContext() // defaults to the "sub" claim
300332

301333
const { data, error } = await eSupabase
302334
.from("users", users)
@@ -305,6 +337,14 @@ const { data, error } = await eSupabase
305337
.select("id")
306338
```
307339

340+
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.
341+
342+
> **Known type error (runtime is fine).** `authStrategy: strategy.data` does not currently typecheck: `@cipherstash/auth` 0.41 strategies declare `getToken(): Promise<Result<TokenResult, AuthFailure>>`, 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).
343+
344+
> **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.
345+
346+
> **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.
347+
308348
## Audit Logging
309349

310350
Chain `.audit()` to attach metadata for ZeroKMS audit logging:
@@ -369,7 +409,9 @@ type EncryptedSupabaseResponse<T> = {
369409
}
370410
```
371411
372-
Errors can come from Supabase (API errors) or from encryption operations. Check `error.encryptionError` for encryption-specific failures.
412+
Errors can come from Supabase (API errors) or from encryption operations.
413+
414+
> **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`.
373415
374416
The full `EncryptedSupabaseError` type:
375417
@@ -401,7 +443,8 @@ type EncryptedSupabaseError = {
401443
- `EncryptedQueryBuilder`
402444
- `PendingOrCondition`
403445
- `SupabaseClientLike`
404-
- `EncryptedSupabaseV3Config`, `EncryptedSupabaseV3Instance`, `EncryptedQueryBuilderV3` (EQL v3)
446+
- `EncryptedSupabaseResponse`, `EncryptedSupabaseError`
447+
- `EncryptedSupabaseV3Config`, `EncryptedSupabaseV3Instance`, `EncryptedQueryBuilderV3`, `V3FilterableKeys` (EQL v3)
405448
406449
## EQL v3 (native `eql_v3.*` domains)
407450
@@ -472,6 +515,8 @@ CREATE TABLE users (
472515
stash eql install --eql-version 3 --supabase
473516
```
474517

518+
> **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.
519+
475520
This installs the opclass-stripped v3 bundle (operator classes need superuser,
476521
which Supabase does not grant) and applies the grants for the
477522
`anon` / `authenticated` / `service_role` roles. The vendored bundle is the
@@ -498,7 +543,7 @@ and receives — the role grants; grants and exposure are independent.
498543
All envelopes (stored payloads and filter operands) are versioned `v: 3`.
499544

500545
- **INTERIM — filter operands are full envelopes.** This is a workaround, not
501-
the design (tracked as Linear **CIP-3402**). Why it is required today: every
546+
the design; a term-only query envelope is planned. Why it is required today: every
502547
`eql_v3.*` domain CHECK requires the storage keys (`v`/`i`/`c` plus the
503548
domain's index terms), and the SQL operators coerce their operand into the
504549
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`.
522567
the full-envelope operand's bloom carries the whole pattern as an extra
523568
token that only matches when the pattern equals the stored value. This is a
524569
symptom of the same full-envelope interim mechanism above and goes away with
525-
the term-only query envelope (CIP-3402).
570+
the term-only query envelope planned above.
526571
- **Storage-only domains are not filterable** (e.g. `types.Boolean`,
527572
`types.Text`): a filter (including `.match()`) on one is a type error, and
528573
always a clear runtime error.
@@ -543,7 +588,7 @@ The hard case: a Supabase table that already exists with live data in a plaintex
543588

544589
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.
545590

546-
> **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.
591+
> **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.
547592
548593
> **Runner note.** `stash init` adds `stash` to the project as a dev dependency, so `stash <command>` 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.
549594
@@ -611,7 +656,13 @@ export const encryptionClient = await Encryption({ schemas: [users] })
611656
> stash db push
612657
> ```
613658
>
614-
> 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.
659+
> 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`.**
660+
>
661+
> ```bash
662+
> stash db activate
663+
> ```
664+
>
665+
> 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.
615666
>
616667
> **SDK users:** Skip this step. Your encryption config lives in app code.
617668
@@ -687,7 +738,7 @@ export const users = encryptedTable('users', {
687738
})
688739
```
689740
690-
> **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).
741+
> **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.
691742
>
692743
> **Using CipherStash Proxy?** Re-push the encryption config so EQL has a pending row that points at `email` (no `_encrypted` suffix):
693744
>

0 commit comments

Comments
 (0)