Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 69 additions & 18 deletions skills/stash-supabase/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +26 to +32

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Defining the schema explicitly is not necessary (optional).
See #588

Expand Down Expand Up @@ -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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automatic schema loading mitigates this problem hence that approach is preferred.


## Delete

```typescript
Expand Down Expand Up @@ -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)
Expand All @@ -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<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).

> **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:
Expand Down Expand Up @@ -369,7 +409,9 @@ type EncryptedSupabaseResponse<T> = {
}
```

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems like a bug. We should fix and update this.


The full `EncryptedSupabaseError` type:

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand All @@ -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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has now been fixed.

`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
Expand All @@ -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.
Expand All @@ -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 <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.

Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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):
>
Expand Down
Loading