-
Notifications
You must be signed in to change notification settings - Fork 4
docs(supabase): correct the stash-supabase skill #606
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 | ||
| 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 | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Defining the schema explicitly is not necessary (optional). |
||
|
|
@@ -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. | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
|
@@ -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) | ||
|
|
@@ -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: | ||
|
|
@@ -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`. | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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: | ||
|
|
||
|
|
@@ -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) | ||
|
|
||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
|
@@ -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. | ||
|
|
@@ -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. | ||
|
|
||
|
|
@@ -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. | ||
|
|
||
|
|
@@ -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): | ||
| > | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Update this to use the EQL 3.0.0 types. See https://github.com/cipherstash/encrypt-query-language/releases/tag/eql-3.0.0