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
Rebased onto the EQL v3 integration branch (#616). Reconciled against CJ's
eql-3.0.0 GA re-baseline of the skill, which #606 (branched from main) had not
seen, and corrected the order() guidance now that #616 makes order() work on
Supabase v3 ordering columns.
- order(): the skill said ORDER BY on encrypted columns is unsupported on
Supabase. That is still true for EQL v2 (operator families need superuser),
but v3 order() now works on OPE-backed ordering columns (*_ord, text_ord,
text_search) via the col->op jsonb path. Scoped the v2 statement, documented
the v3 support matrix (ORE-only ordering columns and no-ordering-term columns
are rejected), and fixed the typed-narrowing note (order() accepts plaintext
AND OPE ordering columns, not plaintext only).
- Exports: corrected the v3 type list to the real surface
(EncryptedSupabaseV3Options not V3Config; added TypedEncryptedSupabaseV3Instance,
EncryptedQueryBuilderV3Untyped, V3FreeTextSearchableKeys; kept
EncryptedSupabaseResponse/Error and V3FilterableKeys).
- Kept the newer re-baseline content (single v3 SQL artifact, ORE self-skip,
--supabase grants, honest contains() exact-match limitation) and preserved the
additive `supabase db reset` warning.
- Dropped the include_original:false substring workaround: protect-ffi ignores
the flag (proven by this branch's match-bloom tests), so it does not enable
substring search.
Claude-Session: https://claude.ai/code/session_01MxTTPaPP16m6br7Hoab94w
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
@@ -276,27 +292,39 @@ These are passed through to Supabase directly:
276
292
277
293
### Ordering by Encrypted Columns
278
294
279
-
**`ORDER BY` on encrypted columns is not currently supported** on databases without operator family support (including Supabase).
280
-
281
-
Without operator families installed in PostgreSQL, the database cannot sort on `eql_v2_encrypted` columns. This affects all clients — the Supabase JS SDK, Drizzle, raw SQL, and any other ORM.
295
+
**EQL v2:**`ORDER BY` on an `eql_v2_encrypted` column is **not** supported on databases without operator family support (including Supabase). Without operator families PostgreSQL cannot sort on the encrypted type — this affects all clients (the Supabase JS SDK, Drizzle, raw SQL, any ORM). Workaround: sort application-side after decrypting. Operator family support is being developed in collaboration with the Supabase and CipherStash teams.
282
296
283
-
**Workaround:**Sort application-side after decrypting the results.
297
+
**EQL v3 is different:**`order()` works on OPE-backed ordering columns (`*_ord`, `text_ord`, `text_search`) via the `col->op` jsonb path — no operator families required. See "v3-specific behaviour" below for the exact support matrix.
284
298
285
-
Operator family support is currently being developed in collaboration with the Supabase and CipherStash teams and will be available in a future release.
286
-
287
-
`.order()` on non-encrypted columns works normally.
299
+
`.order()` on non-encrypted columns works normally in both.
288
300
289
301
## Identity-Aware Encryption
290
302
291
-
Chain `.withLockContext()` to tie encryption to a specific user's JWT:
303
+
Bind a data key to a claim from the end user's JWT, so only that user can decrypt.
304
+
305
+
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.
337
+
338
+
> **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).
339
+
340
+
> **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.
341
+
342
+
> **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.
343
+
308
344
## Audit Logging
309
345
310
346
Chain `.audit()` to attach metadata for ZeroKMS audit logging:
@@ -369,7 +405,9 @@ type EncryptedSupabaseResponse<T> = {
369
405
}
370
406
```
371
407
372
-
Errors can come from Supabase (API errors) or from encryption operations. Check `error.encryptionError` for encryption-specific failures.
408
+
Errors can come from Supabase (API errors) or from encryption operations.
409
+
410
+
> **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
411
374
412
The full `EncryptedSupabaseError` type:
375
413
@@ -401,7 +439,8 @@ type EncryptedSupabaseError = {
A declared table gets a typed builder: rows infer each column's plaintext
463
502
type (`types.IntegerOrd` → `number`, `types.TimestampOrd` → `Date`),
464
503
storage-only columns are excluded from every filter method, `contains()` is
465
-
narrowed to match-indexed columns, and `order()` to plaintext columns.
504
+
narrowed to match-indexed columns, and `order()` to plaintext and OPE-backed
505
+
ordering columns.
466
506
Undeclared tables behave exactly as with no `schemas` at all. Every v3 column
467
507
is fully described by its `types.*` factory — there are no capability or
468
508
tuning chains on v3 columns.
@@ -506,6 +546,8 @@ and `eql_v3_internal` (SEM internals). Without the grants, encrypted queries
506
546
fail loudly with a permission error (e.g. `permission denied for schema
507
547
eql_v3_internal`).
508
548
549
+
> **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.
550
+
509
551
No **Exposed schemas** change is needed for v3: the column domains and their
510
552
operators live in `public`, so bare `col = term` filters resolve under
511
553
Supabase's default PostgREST configuration. Do not expose `eql_v3_internal`.
@@ -543,12 +585,18 @@ All envelopes (stored payloads and filter operands) are versioned `v: 3`.
543
585
in GET query strings — so these envelopes can land in URL logs,
544
586
intermediate proxies, and Supabase request logs. The remaining gap is
545
587
PostgREST operand casting; an adapter-side fix is tracked.
546
-
-**No `ORDER BY` on encrypted v3 columns** — including the range-capable
547
-
ones. PostgREST cannot emit `ORDER BY eql_v3.ord_term(col)`, and a bare
548
-
`ORDER BY` would silently sort the raw ciphertext envelope, so the builder
549
-
rejects `order()` on any encrypted column with a clear error. Range
550
-
*filtering* (`gte`/`lte`/…) works. Order by a plaintext column, or sort
551
-
application-side after decrypting.
588
+
-**`order()` works on OPE-backed v3 ordering columns.** PostgREST cannot emit
589
+
the canonical `ORDER BY eql_v3.ord_term(col)`, but it can emit the jsonb path
590
+
`col->op`, which selects the same order-preserving OPE term — so the builder
591
+
rewrites an encrypted ordering column to `col->op` and the sort reproduces
592
+
the plaintext order. Supported on every `*_ord` domain plus `text_ord` and
593
+
`text_search` (all carry an `ope` term). Rejected with a clear error on
594
+
ORE-only ordering columns (`*_ord_ore` — their `ob` term needs the
595
+
superuser-only ORE operator class, unreachable through a jsonb path, and such
596
+
a column cannot hold data on Supabase anyway) and on columns with no ordering
597
+
term. A bare `ORDER BY col` on an encrypted column would silently sort the
598
+
raw ciphertext envelope, which is exactly why the builder emits the term path
599
+
instead. Order by a plaintext column normally.
552
600
-**Storage-only domains are not filterable** (e.g. `types.Boolean`,
553
601
`types.Text`): a filter (including `.match()`) on one is a type error on a
554
602
declared table, and always a clear runtime error. `.is(column, null)`
@@ -562,7 +610,7 @@ The hard case: a Supabase table that already exists with live data in a plaintex
562
610
563
611
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.
564
612
565
-
> **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.
613
+
> **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.
566
614
567
615
> **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.
681
+
> 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`.**
682
+
>
683
+
>```bash
684
+
> stash db activate
685
+
>```
686
+
>
687
+
> 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.
634
688
>
635
689
>**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).
763
+
> **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.
710
764
>
711
765
> **Using CipherStash Proxy?** Re-push the encryption config so EQL has a pending row that points at `email` (no `_encrypted` suffix):
0 commit comments