Skip to content

Commit ee9667e

Browse files
committed
docs(supabase): correct the stash-supabase skill
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
1 parent 02750c6 commit ee9667e

2 files changed

Lines changed: 110 additions & 30 deletions

File tree

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
---
2+
"stash": patch
3+
---
4+
5+
Correct the bundled `stash-supabase` agent skill. The skills directory ships
6+
inside the `stash` tarball and is copied into the user's `.claude/skills/` /
7+
`.codex/skills/` (or inlined into `AGENTS.md`) at handoff time, so a stale skill
8+
becomes stale guidance in the user's project.
9+
10+
- **`order()` on Supabase.** The skill said `ORDER BY` on encrypted columns is
11+
unsupported on Supabase. That is still true for EQL v2 (operator families need
12+
superuser), but v3 `order()` now works on OPE-backed ordering columns
13+
(`*_ord`, `text_ord`, `text_search`) via the `col->op` jsonb path. Scoped the
14+
v2 statement to v2, documented the v3 support matrix (ORE-only ordering
15+
columns and columns with no ordering term are rejected with a clear error),
16+
and corrected the typed-narrowing note (`order()` accepts plaintext **and**
17+
OPE ordering columns, not plaintext only).
18+
- **Exports.** Corrected the v3 type list to the real surface:
19+
`EncryptedSupabaseV3Options` (not `EncryptedSupabaseV3Config`), plus
20+
`TypedEncryptedSupabaseV3Instance`, `EncryptedQueryBuilderV3Untyped`, and
21+
`V3FreeTextSearchableKeys`, keeping `EncryptedSupabaseResponse` /
22+
`EncryptedSupabaseError` and `V3FilterableKeys`.
23+
- **Dropped the `include_original: false` substring workaround.** `protect-ffi`
24+
ignores the flag, so setting it does not enable substring search; the skill
25+
now states the honest limitation (`contains()` matches exact values, not
26+
general substrings — tracked upstream in EQL).

skills/stash-supabase/SKILL.md

Lines changed: 84 additions & 30 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
@@ -276,27 +292,39 @@ These are passed through to Supabase directly:
276292

277293
### Ordering by Encrypted Columns
278294

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.
282296

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.
284298

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.
288300

289301
## Identity-Aware Encryption
290302

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.
292306

293307
```typescript
308+
import { Encryption, OidcFederationStrategy } from "@cipherstash/stack"
294309
import { LockContext } from "@cipherstash/stack/identity"
295310

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
311+
// 1. Authenticate the client as the end user. `getJwt` returns the current
312+
// Supabase access token and is re-invoked on every (re-)federation.
313+
const strategy = OidcFederationStrategy.create(
314+
process.env.CS_WORKSPACE_CRN!,
315+
() => getSupabaseAccessToken(),
316+
)
317+
if (strategy.failure) {
318+
throw new Error(`[auth] ${strategy.failure.type}: ${strategy.failure.error.message}`)
319+
}
320+
321+
const client = await Encryption({
322+
schemas: [users],
323+
config: { authStrategy: strategy.data },
324+
})
325+
326+
// 2. Bind the data key to the user's `sub` claim. No `identify()` call.
327+
const lockContext = new LockContext() // defaults to the "sub" claim
300328

301329
const { data, error } = await eSupabase
302330
.from("users", users)
@@ -305,6 +333,14 @@ const { data, error } = await eSupabase
305333
.select("id")
306334
```
307335

336+
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+
308344
## Audit Logging
309345

310346
Chain `.audit()` to attach metadata for ZeroKMS audit logging:
@@ -369,7 +405,9 @@ type EncryptedSupabaseResponse<T> = {
369405
}
370406
```
371407
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`.
373411
374412
The full `EncryptedSupabaseError` type:
375413
@@ -401,7 +439,8 @@ type EncryptedSupabaseError = {
401439
- `EncryptedQueryBuilder`
402440
- `PendingOrCondition`
403441
- `SupabaseClientLike`
404-
- `EncryptedSupabaseV3Options`, `EncryptedSupabaseV3Instance`, `TypedEncryptedSupabaseV3Instance`, `EncryptedQueryBuilderV3`, `EncryptedQueryBuilderV3Untyped`, `V3Schemas` (EQL v3)
442+
- `EncryptedSupabaseResponse`, `EncryptedSupabaseError`
443+
- `EncryptedSupabaseV3Options`, `EncryptedSupabaseV3Instance`, `TypedEncryptedSupabaseV3Instance`, `EncryptedQueryBuilderV3`, `EncryptedQueryBuilderV3Untyped`, `V3FilterableKeys`, `V3FreeTextSearchableKeys`, `V3Schemas` (EQL v3)
405444
406445
## EQL v3 (native `public.eql_v3_*` domains)
407446
@@ -462,7 +501,8 @@ const { data } = await es.from("users").select("id, email, joined").eq("email",
462501
A declared table gets a typed builder: rows infer each column's plaintext
463502
type (`types.IntegerOrd``number`, `types.TimestampOrd``Date`),
464503
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.
466506
Undeclared tables behave exactly as with no `schemas` at all. Every v3 column
467507
is fully described by its `types.*` factory — there are no capability or
468508
tuning chains on v3 columns.
@@ -506,6 +546,8 @@ and `eql_v3_internal` (SEM internals). Without the grants, encrypted queries
506546
fail loudly with a permission error (e.g. `permission denied for schema
507547
eql_v3_internal`).
508548

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+
509551
No **Exposed schemas** change is needed for v3: the column domains and their
510552
operators live in `public`, so bare `col = term` filters resolve under
511553
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`.
543585
in GET query strings — so these envelopes can land in URL logs,
544586
intermediate proxies, and Supabase request logs. The remaining gap is
545587
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.
552600
- **Storage-only domains are not filterable** (e.g. `types.Boolean`,
553601
`types.Text`): a filter (including `.match()`) on one is a type error on a
554602
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
562610

563611
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.
564612

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.
566614
567615
> **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.
568616
@@ -630,7 +678,13 @@ export const encryptionClient = await Encryption({ schemas: [users] })
630678
> stash db push
631679
> ```
632680
>
633-
> 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.
634688
>
635689
> **SDK users:** Skip this step. Your encryption config lives in app code.
636690
@@ -706,7 +760,7 @@ export const users = encryptedTable('users', {
706760
})
707761
```
708762
709-
> **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.
710764
>
711765
> **Using CipherStash Proxy?** Re-push the encryption config so EQL has a pending row that points at `email` (no `_encrypted` suffix):
712766
>

0 commit comments

Comments
 (0)