You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
Encrypted columns must be stored as JSONB in your Supabase database:
26
+
Encrypted columns are stored as JSONB in your Supabase database:
27
27
28
28
```sql
29
29
CREATETABLEusers (
30
30
id SERIALPRIMARY KEY,
31
-
email jsonbNOT NULL,-- encrypted column
32
-
name jsonbNOT NULL,-- encrypted column
31
+
email jsonb, -- encrypted column
32
+
name jsonb, -- encrypted column
33
33
age jsonb, -- encrypted column (numeric)
34
34
role VARCHAR(50), -- regular column (not encrypted)
35
35
created_at TIMESTAMPTZ DEFAULT NOW()
36
36
);
37
37
```
38
38
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`.
40
40
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
43
45
```
44
46
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
+
45
53
## Setup
46
54
47
55
### 1. Define Encrypted Schema
@@ -251,6 +259,14 @@ Both forms encrypt values for encrypted columns automatically.
251
259
.filter("email", "eq", "alice@example.com")
252
260
```
253
261
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
+
254
270
## Delete
255
271
256
272
```typescript
@@ -288,15 +304,31 @@ Operator family support is currently being developed in collaboration with the S
288
304
289
305
## Identity-Aware Encryption
290
306
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.
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
+
308
348
## Audit Logging
309
349
310
350
Chain `.audit()` to attach metadata for ZeroKMS audit logging:
@@ -369,7 +409,9 @@ type EncryptedSupabaseResponse<T> = {
369
409
}
370
410
```
371
411
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`.
373
415
374
416
The full `EncryptedSupabaseError` type:
375
417
@@ -401,7 +443,8 @@ type EncryptedSupabaseError = {
> **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
+
475
520
This installs the opclass-stripped v3 bundle (operator classes need superuser,
476
521
which Supabase does not grant) and applies the grants for the
477
522
`anon` / `authenticated` / `service_role` roles. The vendored bundle is the
@@ -498,7 +543,7 @@ and receives — the role grants; grants and exposure are independent.
498
543
All envelopes (stored payloads and filter operands) are versioned `v: 3`.
499
544
500
545
-**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
502
547
`eql_v3.*` domain CHECK requires the storage keys (`v`/`i`/`c` plus the
503
548
domain's index terms), and the SQL operators coerce their operand into the
504
549
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`.
522
567
the full-envelope operand's bloom carries the whole pattern as an extra
523
568
token that only matches when the pattern equals the stored value. This is a
524
569
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.
526
571
-**Storage-only domains are not filterable** (e.g. `types.Boolean`,
527
572
`types.Text`): a filter (including `.match()`) on one is a type error, and
528
573
always a clear runtime error.
@@ -543,7 +588,7 @@ The hard case: a Supabase table that already exists with live data in a plaintex
543
588
544
589
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.
545
590
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.
547
592
548
593
> **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.
> 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.
615
666
>
616
667
>**SDK users:** Skip this step. Your encryption config lives in app code.
> **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.
691
742
>
692
743
> **Using CipherStash Proxy?** Re-push the encryption config so EQL has a pending row that points at `email` (no `_encrypted` suffix):
0 commit comments