diff --git a/IA.md b/IA.md
index 7aad829..622bae1 100644
--- a/IA.md
+++ b/IA.md
@@ -252,7 +252,6 @@ so it lives as facets and links, never as a tree section or a hub.
| CTS | Identity service in the Platform | `platform` | `/security/cts`, `/reference/auth/*` |
| `@cipherstash/auth` | Stack package (identity-aware encryption) | `[encryption, platform]` | `/reference/stack/auth` |
| Proxy stack-auth | How auth works inside the Proxy | `[proxy, platform]` | `/reference/proxy/*`, `/security/proxy` |
-| Next.js adapter | Framework integration | `[encryption, platform]` | `/integrations/nextjs` |
| Clerk / Auth0 / Okta | Auth-provider integrations | `[platform]` | `/integrations/*` (`category: auth-provider`) |
None of these is filed under an "auth" section, because there isn't one. Each is
@@ -302,21 +301,20 @@ Two notes:
## Integrations â CIP-3328 (Supabase), CIP-3330 (auth), CIP-3336 (rest)
- [x] Section scaffold đ§ (index + supabase stub with facet exemplar)
-- [ ] `/integrations` index â category grid w/ setup badges
-- [ ] `/integrations/supabase` â flagship tutorial (CIP-3328)
-- [ ] `/integrations/supabase/database`
-- [ ] `/integrations/supabase/auth`
-- [ ] `/integrations/supabase/dashboard-experience` â Table Editor, expose eql schema
+- [x] `/integrations` index â category grid w/ setup badges
+- [x] `/integrations/supabase` â flagship tutorial (CIP-3328)
+- [x] `/integrations/supabase/database`
+- [x] `/integrations/supabase/auth`
+- [x] `/integrations/supabase/dashboard-experience` â Table Editor, expose eql schema
- [ ] â `/integrations/supabase/edge-functions` â pending Deno/FFI answer
- [ ] â `/integrations/supabase/realtime` â pending product verification
-- [ ] `/integrations/drizzle` â merge the two divergent Drizzle pages
-- [ ] `/integrations/prisma-next`
+- [x] `/integrations/drizzle`
+- [x] `/integrations/prisma-next` â EQL v2 today; revisit when v3 support lands
- [ ] `/integrations/aws/rds-aurora` â Proxy path
- [ ] `/integrations/aws/dynamodb`
- [ ] `/integrations/clerk`
- [ ] `/integrations/auth0` â end-to-end example (Clerk parity)
- [ ] `/integrations/okta` â end-to-end example (Clerk parity)
-- [ ] `/integrations/nextjs`
- [ ] `/integrations/typescript` â thin router to Stack SDK reference
- [ ] `/integrations/serverless` â Vercel/Lambda, bundling, CS_CONFIG_PATH
- [ ] `/integrations/docker`
@@ -420,7 +418,7 @@ and the old `/compare/*` paths redirect there (`v2-redirects.mjs`).
- [ ] `/reference/stack` â client + configuration (port encryption/* pages)
- [ ] `/reference/stack/schema`
- [ ] `/reference/stack/encrypt-decrypt` (+ bulk, models)
-- [ ] `/reference/stack/supabase` â THE canonical `encryptedSupabase` page, ONE signature (CIP-3328)
+- [x] `/reference/stack/supabase` â THE canonical `encryptedSupabase` page, ONE signature (CIP-3328)
- [ ] `/reference/stack/drizzle-operators`
- [ ] `/reference/stack/errors` â port error-handling; miette catalog later (CIP-3338)
- [ ] `/reference/stack/upgrading-from-protect` (retitled package-rename guide)
@@ -453,5 +451,9 @@ and the old `/compare/*` paths redirect there (`v2-redirects.mjs`).
documents the release as decided, ahead of the eql_v3 branch: payload `v: 3`,
OPE SEM specifier, Docker tag `:17-3.0.0`, `version()` output, schema files.
Each must land upstream or be walked back in the docs before merge
+- [ ] â Stack SDK Supabase-wrapper v3 alignment (CIP-3355, blocks CIP-3335) â the
+ Supabase section documents the 0.18 wrapper API with v3 wire semantics; the
+ wrapper itself is still v2 (composite type, `like` wire op, v2 payloads) and
+ the SDK's v3 branches don't touch `src/supabase/` yet
- [ ] Flip `ENABLE_V2_REDIRECTS=1`, delete `content/stack` + `/stack` routes + legacy loader (CIP-3335)
- [ ] Consistency sweep + Supabase listing v3 revision (CIP-3335)
diff --git a/content/docs/concepts/identity-aware-encryption.mdx b/content/docs/concepts/identity-aware-encryption.mdx
new file mode 100644
index 0000000..3f11530
--- /dev/null
+++ b/content/docs/concepts/identity-aware-encryption.mdx
@@ -0,0 +1,9 @@
+---
+title: Identity-aware encryption
+description: "Lock contexts and CTS: binding encrypted values to a user identity so only that identity can decrypt them."
+type: concept
+---
+
+This page is being built as part of the docs V2 overhaul ([CIP-3330](https://linear.app/cipherstash/issue/CIP-3330)). Track progress in [IA.md](https://github.com/cipherstash/docs/blob/v2/IA.md).
+
+Until it lands, the current version lives in the [existing docs](/stack/cipherstash/encryption/identity).
diff --git a/content/docs/get-started/choose-your-stack.mdx b/content/docs/get-started/choose-your-stack.mdx
index 94f9e5c..8ec8264 100644
--- a/content/docs/get-started/choose-your-stack.mdx
+++ b/content/docs/get-started/choose-your-stack.mdx
@@ -44,7 +44,7 @@ Managed platforms usually block custom operator class creation. That's why the d
| Query layer | Path | What it looks like |
|---|---|---|
| Drizzle | [Drizzle integration](/stack/cipherstash/encryption/drizzle) | An `encryptedType` column and typed query operators. Your Drizzle code keeps its shape. |
-| Supabase JS | [Supabase integration](/integrations/supabase) | `encryptedSupabase` wraps the client. `.eq()`, `.like()`, `.gt()` keep working. |
+| Supabase JS | [Supabase integration](/integrations/supabase) | `encryptedSupabase` wraps the client. `.eq()`, `.gt()`, `.order()` keep working. |
| Prisma | [Prisma integration](/stack/cipherstash/encryption/prisma-next) | Encrypt in the data layer around Prisma calls. |
| Raw SQL | [Quickstart](/get-started/quickstart) | Encrypt the value, insert it, cast the query operand. |
| An ORM we don't list | The SDK | Encrypt before write, decrypt after read. Every ORM supports that. |
diff --git a/content/docs/get-started/quickstart.mdx b/content/docs/get-started/quickstart.mdx
index 37a43d9..330514d 100644
--- a/content/docs/get-started/quickstart.mdx
+++ b/content/docs/get-started/quickstart.mdx
@@ -5,8 +5,8 @@ type: tutorial
components: [encryption, eql, cli, platform]
audience: [developer]
verifiedAgainst:
- stack: "0.19.0"
- cli: "0.17.1"
+ stack: "1.0.0-rc.0"
+ cli: "1.0.0-rc.0"
eql: "3.0.0"
---
@@ -33,38 +33,20 @@ npx stash init
This opens a browser for device-based authentication, so local development needs no environment variables and no shared secrets. `init` then:
1. Connects to your workspace.
-2. Resolves your database connection and runs `stash eql install`, which installs [EQL](/reference/eql), the Postgres surface that makes encrypted columns queryable.
+2. Resolves your database connection and runs `stash eql install`, which installs [EQL](/reference/eql) v3, the Postgres surface that makes encrypted columns queryable.
3. Installs `@cipherstash/stack` if it isn't already present.
4. Scaffolds an encryption module at `src/encryption/index.ts`.
5. Writes `.cipherstash/context.json` with what it detected about your project.
See the [CLI reference](/reference/cli/init) for the flags, including `--supabase`, `--drizzle`, and `--prisma-next`.
-
-
-
-
-## Install EQL v3
-
-This guide, and the rest of the EQL reference, targets **EQL v3**. `stash init` installs EQL v2 by default today, so ask for v3 explicitly:
-
-```bash example-id="quickstart-eql-v3"
-npx stash eql install --eql-version 3 --force
-```
-
-`--force` reinstalls over the v2 schema that `init` just laid down. On a new database with no encrypted data, that is safe: there is nothing to migrate.
-
-Check what you ended up with:
+EQL v3 is the default, so `init` installs it directly against the database. Confirm what you ended up with:
-```bash
+```bash example-id="quickstart-eql-status"
npx stash eql status
```
-
-`--eql-version` accepts `2` or `3` and **defaults to `2`** in `stash` 0.17.1. The default becomes `3` in a future release, at which point this step goes away. Pass the flag until then, so you know which generation you are on rather than inheriting a default that is about to change.
-
-v3 installs directly against the database, so the `--drizzle`, `--migration`, and `--latest` flags do not apply to it. See [installing EQL](/reference/eql) for the full options, including installing across several databases.
-
+This guide, and the rest of the EQL reference, targets EQL v3.
diff --git a/content/docs/guides/deployment/index.mdx b/content/docs/guides/deployment/index.mdx
index f269e42..c2c6d21 100644
--- a/content/docs/guides/deployment/index.mdx
+++ b/content/docs/guides/deployment/index.mdx
@@ -1,8 +1,95 @@
---
title: Deployment
-description: "Deployment documentation â being built as part of the docs V2 overhaul."
+description: "Deploy CipherStash changes safely across environments, with explicit credential, migration, rollout, monitoring, and rollback gates."
+type: guide
+components: [encryption, eql, cli]
+audience: [developer]
---
-This section is being built as part of the docs V2 overhaul ([CIP-3307](https://linear.app/cipherstash/issue/CIP-3307)). Track progress in [IA.md](https://github.com/cipherstash/docs/blob/v2/IA.md).
+CipherStash deployments coordinate application code, database schema, and credentials. Treat each independently deployable stage as a release with its own verification and rollback gate.
-Until it lands, current documentation lives in the [existing docs](/stack).
+For the detailed dual-write, backfill, and cutover procedure for populated columns, follow [Encrypt existing data](/guides/migration/encrypt-existing-data). This page covers the production rollout around that migration.
+
+## Before the first deployment
+
+- Create a separate CipherStash workspace or machine credential set for each environment.
+- Store `CS_*` values in the environment's secret manager rather than copying a developer's local profile.
+- Confirm the production database connection used by migrations and backfills.
+- Test encryption, decryption, and the queries you rely on against a non-production database with the same EQL version.
+- Inventory all application versions and background workers that may remain live during a rolling deployment.
+- Decide who can approve backfill, read cutover, and plaintext removal.
+
+## Credentials at build and runtime
+
+An encryption client constructed at module load may authenticate when a build tool imports that module. In that case, `CS_WORKSPACE_CRN`, `CS_CLIENT_ID`, `CS_CLIENT_KEY`, and `CS_CLIENT_ACCESS_KEY` must be available during both the build and runtime phases.
+
+Do not let a local `stash auth login` session mask missing deployment credentials. Reproduce the production build in a clean environment that has only the secrets explicitly supplied by the deployment platform.
+
+Backfills must use credentials for the same workspace and keyset as the deployed application. `CS_*` environment variables take precedence over a local CipherStash profile.
+
+## Environment order
+
+Promote each stage through environments in this order:
+
+1. Local or ephemeral development database
+2. Shared development or test
+3. Staging with production-like data volume and connection behavior
+4. Production
+
+Run schema compatibility and application tests after each promotion. Do not use a successful local backfill as evidence that production credentials, row counts, locks, or query plans are safe.
+
+## Production rollout
+
+| Stage | Change | Verification gate | Rollback |
+| --- | --- | --- | --- |
+| Install EQL | Database-only, additive | EQL status and application connectivity | Leave installed |
+| Add encrypted mirrors | Nullable columns plus dual-writes | Every writer populates both columns | Revert writers; keep columns |
+| Backfill | Out-of-band data job | Zero uncovered rows and sampled decryptions match | Stop and resume later |
+| Build indexes | Database-only | Expected indexes engage under `EXPLAIN` | Drop or rebuild the index |
+| Cut reads over | Application-only | Known filters, ordering, nulls, and RLS behavior are correct | Revert reads to plaintext |
+| Remove plaintext | Application and destructive schema change | Soak complete; no old application versions remain | Restore or migrate from backup |
+
+Keep schema changes backward-compatible with both the old and new application version during rolling deployments. In particular, encrypted mirror columns must remain nullable until every historical row is covered.
+
+## Release gates
+
+### Before backfill
+
+- Dual-writes are deployed in every process that can mutate the table.
+- Known inserts, updates, upserts, imports, and background jobs populate both columns.
+- The backfill is using production's CipherStash credentials.
+- The primary key and chunk size are suitable for paging through the table.
+
+### Before read cutover
+
+- Coverage queries return zero missing encrypted values.
+- Encrypted indexes have been created and verified using [EQL indexes](/reference/eql/indexes).
+- Equality, range, ordering, null, and authorization tests pass against representative data.
+- The old plaintext read path remains deployable as a rollback.
+
+### Before dropping plaintext
+
+- The encrypted read path has soaked under real traffic.
+- No supported application version reads or writes the plaintext column.
+- Coverage has been checked again.
+- Required backups and restore procedures have been tested.
+- The destructive migration has a named approver and maintenance window where required.
+
+## Monitor after cutover
+
+Monitor ordinary database and application signals alongside encryption-specific failures:
+
+- Encryption and decryption errors
+- ZeroKMS request latency and availability
+- Query latency, sequential scans, and index use
+- Missing or unexpectedly null values
+- Authorization and Row Level Security regressions
+- Backfill progress, retry rate, and uncovered-row count
+
+Test the correctness of returned values, not only request success rates. An incorrectly scoped filter or ordering query may return plausible but incomplete data without raising an error.
+
+## Rollback principles
+
+Prefer application rollbacks while both plaintext and encrypted columns are being maintained. Before plaintext removal, a read cutover can be reversed without changing data: point reads back to the original columns and leave dual-writes running.
+
+After plaintext is dropped, rollback becomes a data-recovery operation. Avoid reaching that stage until operational evidence shows the encrypted path is stable and every supported application version has moved forward.
diff --git a/content/docs/guides/development/schema-design.mdx b/content/docs/guides/development/schema-design.mdx
new file mode 100644
index 0000000..b11397f
--- /dev/null
+++ b/content/docs/guides/development/schema-design.mdx
@@ -0,0 +1,9 @@
+---
+title: Schema design
+description: "Choosing the right encrypted type and capability for each column."
+type: guide
+---
+
+This page is being built as part of the docs V2 overhaul ([CIP-3327](https://linear.app/cipherstash/issue/CIP-3327)). Track progress in [IA.md](https://github.com/cipherstash/docs/blob/v2/IA.md).
+
+Until it lands, [EQL core concepts](/reference/eql/core-concepts) covers the capability model, and the per-type pages ([numbers](/reference/eql/numbers), [dates & times](/reference/eql/dates-and-times), [text](/reference/eql/text), [JSON](/reference/eql/json)) cover choosing variants.
diff --git a/content/docs/guides/migration/encrypt-existing-data.mdx b/content/docs/guides/migration/encrypt-existing-data.mdx
new file mode 100644
index 0000000..a613f6f
--- /dev/null
+++ b/content/docs/guides/migration/encrypt-existing-data.mdx
@@ -0,0 +1,216 @@
+---
+title: Encrypt existing data
+description: "Move populated plaintext columns to searchable encryption with dual-writes, a resumable backfill, and a reversible read cutover."
+type: guide
+components: [encryption, eql, cli]
+audience: [developer]
+---
+
+Encrypting a populated column is a staged application and database migration. Ciphertext must be produced in your application, so a database migration cannot convert existing plaintext by itself.
+
+The safe pattern is to add an encrypted mirror column, dual-write new changes, backfill historical rows, and then move reads to the mirror. The plaintext column remains authoritative until the final cleanup, making every earlier stage reversible.
+
+This guide uses `encryptedSupabase` for its examples. The same sequence applies when the application uses [Drizzle](/integrations/drizzle), [Prisma Next](/integrations/prisma-next), or the [Stack SDK](/reference/stack): change the adapter-specific code, not the rollout order.
+
+## Migration sequence
+
+```text
+DEPLOY 1 OUT OF BAND DEPLOY 2 DEPLOY 3
+Add encrypted mirror Backfill existing rows Read encrypted mirror Drop plaintext
+Begin dual-writes Verify coverage Keep dual-writes Stop dual-writes
+Reads stay plaintext Build indexes Soak and verify
+```
+
+Do not start the backfill until dual-writes are running in every deployed writer. Otherwise, a row created after the backfill passes it can remain permanently uncovered.
+
+## Before you start
+
+- Install EQL and configure your encryption client. For Supabase, complete the [Quickstart](/integrations/supabase/quickstart) against a development table first.
+- Inventory every writer of the columns: services, jobs, Edge Functions, imports, seeds, RPC functions, webhooks, and administrative tools.
+- Make the same CipherStash machine credentials available to the deployed application and the backfill job.
+- Decide which encrypted capability each column needs by following [EQL core concepts](/reference/eql/core-concepts).
+
+The examples migrate `patients.name` and `patients.date_of_birth`:
+
+```sql
+CREATE TABLE patients (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ email text NOT NULL,
+ name text,
+ date_of_birth date,
+ plan text
+);
+```
+
+
+
+
+### Add nullable encrypted mirrors
+
+Add one encrypted column beside each plaintext column:
+
+```sql
+ALTER TABLE patients
+ ADD COLUMN name_encrypted public.eql_v3_text_eq,
+ ADD COLUMN date_of_birth_encrypted public.eql_v3_date_ord;
+```
+
+The new columns must be nullable because existing rows do not have ciphertext yet. The `_encrypted` suffix is the convention used by `stash encrypt backfill`; use `--encrypted-column` if your names differ.
+
+Ship this schema change together with the dual-write code in the next step. Do not rename or remove the plaintext columns.
+
+
+
+
+### Dual-write every mutation
+
+Update every insert, update, and upsert path to write the plaintext column and its encrypted mirror in the same statement:
+
+```typescript title="lib/patients.ts"
+import { db } from "./db"
+
+export async function createPatient(input: {
+ email: string
+ name: string
+ dateOfBirth: string
+ plan: string
+}) {
+ return db.from("patients").insert({
+ email: input.email,
+ name: input.name,
+ name_encrypted: input.name,
+ date_of_birth: input.dateOfBirth,
+ date_of_birth_encrypted: input.dateOfBirth,
+ plan: input.plan,
+ })
+}
+
+export async function updatePatientName(id: number, name: string) {
+ return db
+ .from("patients")
+ .update({ name, name_encrypted: name })
+ .eq("id", id)
+}
+```
+
+The encrypted client encrypts the mirror values while passing the original values through unchanged. Keeping both values in one database statement prevents one half of the write from succeeding without the other.
+
+Search the codebase for every writer before deploying. A missed importer, background job, conflict handler, or administrative path can create uncovered rows.
+
+
+
+
+### Deploy and verify dual-writes
+
+Deploy the schema and application changes to the environment that owns the database. The backfill is not safe while dual-writes exist only locally or in CI.
+
+After the deployment, create and update known rows through each important write path. Confirm that both the plaintext and encrypted columns are populated, then inspect the migration state:
+
+```bash
+npx stash encrypt status
+```
+
+The application is now in a safe steady state: new writes are mirrored, old rows remain plaintext-only, and reads still use the original columns.
+
+
+
+
+### Backfill historical rows
+
+Run one backfill per plaintext column:
+
+```bash
+npx stash encrypt backfill --table patients --column name
+npx stash encrypt backfill --table patients --column date_of_birth
+```
+
+The command pages by primary key, encrypts rows in chunks, and records checkpoints. It is resumable and idempotent. For a non-interactive job, acknowledge the deployment gate with `--confirm-dual-writes-deployed`.
+
+
+Run the backfill with the same CipherStash workspace and credentials as the deployed application. A local CipherStash profile may point to a different workspace, producing values the production application cannot decrypt.
+
+
+
+
+
+### Verify complete coverage
+
+Check that every populated plaintext value has an encrypted mirror:
+
+```sql
+SELECT
+ count(*) FILTER (
+ WHERE name IS NOT NULL AND name_encrypted IS NULL
+ ) AS name_uncovered,
+ count(*) FILTER (
+ WHERE date_of_birth IS NOT NULL
+ AND date_of_birth_encrypted IS NULL
+ ) AS dob_uncovered
+FROM patients;
+```
+
+Both counts must be zero. A non-zero result usually means a writer is not dual-writing. Fix and deploy that path, then run the backfill again before continuing.
+
+Also sample known records through the encrypted application client and verify their decrypted values against the plaintext originals.
+
+
+
+
+### Build encrypted indexes
+
+Build the functional indexes required by the queries you will move to the encrypted columns. Do this after the bulk backfill and before changing reads.
+
+Index definitions, supported query shapes, `EXPLAIN` verification, and guidance for large tables are maintained in [EQL indexes](/reference/eql/indexes). Use concurrent index creation where that guide recommends it for a live table.
+
+
+
+
+### Cut reads over
+
+Deploy an application-only change that reads and filters on the encrypted mirror columns:
+
+```typescript
+const { data } = await db
+ .from("patients")
+ .select("id, email, name_encrypted, date_of_birth_encrypted")
+ .eq("name_encrypted", "Alice Chen")
+ .gt("date_of_birth_encrypted", "1980-01-01")
+ .order("date_of_birth_encrypted", { ascending: false })
+```
+
+Keep dual-writes enabled. If the new read path misbehaves, reverting this deployment restores plaintext reads while both sets of columns remain current.
+
+Verify correctness rather than checking only for non-empty results. Test known equality matches, range boundaries, ordering, nulls, and authorization behavior.
+
+
+
+
+### Soak, then remove plaintext
+
+Let production traffic exercise the encrypted read path. Monitor query errors, latency, decryption failures, and uncovered-row counts for an appropriate period.
+
+Once you no longer need a plaintext rollback, remove the dual-write code and generate the destructive migration:
+
+```bash
+npx stash encrypt drop --table patients --column name
+```
+
+Review and apply the generated migration through your normal deployment process. Repeat per column rather than combining unrelated migrations into one irreversible change.
+
+
+Dropping the plaintext column is the first irreversible stage. Take any required backup, verify coverage again at apply time, and confirm that no old application version still reads or writes the column.
+
+
+
+
+
+## Rollback by stage
+
+| Current stage | Safe rollback |
+| --- | --- |
+| Mirrors added, before backfill | Revert application writes; leave or remove the empty mirrors |
+| Backfill in progress | Stop it and resume later; keep plaintext reads and dual-writes |
+| Reads cut over | Revert reads to plaintext; keep dual-writes |
+| Plaintext dropped | Restore from an approved backup or perform a new decrypting migration |
+
+For environment ordering, credential handling, release gates, and production monitoring, continue with the [deployment guide](/guides/deployment).
diff --git a/content/docs/guides/migration/index.mdx b/content/docs/guides/migration/index.mdx
index cd728e5..6741cf4 100644
--- a/content/docs/guides/migration/index.mdx
+++ b/content/docs/guides/migration/index.mdx
@@ -1,8 +1,18 @@
---
title: Data migration
-description: "Data migration documentation â being built as part of the docs V2 overhaul."
+description: "Safely introduce CipherStash encryption to existing schemas and populated production data."
+type: guide
+components: [encryption, eql, cli]
+audience: [developer]
---
-This section is being built as part of the docs V2 overhaul ([CIP-3307](https://linear.app/cipherstash/issue/CIP-3307)). Track progress in [IA.md](https://github.com/cipherstash/docs/blob/v2/IA.md).
+Encryption migrations coordinate schema changes, application writes, historical data, and read cutovers. Keep each stage independently verifiable and reversible until the plaintext data is intentionally removed.
-Until it lands, current documentation lives in the [existing docs](/stack).
+
+
+ Add encrypted mirrors, deploy dual-writes, backfill historical rows, cut reads over, and remove plaintext safely.
+
+
+ Plan environment promotion, credentials, release gates, monitoring, and rollback.
+
+
diff --git a/content/docs/integrations/drizzle.mdx b/content/docs/integrations/drizzle.mdx
new file mode 100644
index 0000000..57b6698
--- /dev/null
+++ b/content/docs/integrations/drizzle.mdx
@@ -0,0 +1,179 @@
+---
+title: Drizzle
+description: "Encrypted columns with Drizzle ORM: pick a concrete EQL column type, and capability-checked operators encrypt your query operands for you."
+type: tutorial
+components: [encryption, eql]
+audience: [developer]
+integration:
+ category: orm
+ setup: code-only
+ pairsWith: [supabase, prisma-next]
+verifiedAgainst:
+ stack: "1.0.0-rc.0"
+ eql: "3.0.0"
+---
+
+Drizzle keeps its shape. You declare each encrypted column with a concrete EQL type, and a set of query operators encrypt their operands before Drizzle builds the SQL. Your `select`, `where`, and `orderBy` read as they always did, and the database only ever compares ciphertext.
+
+## Install
+
+```bash cta cta-type="install" example-id="install-drizzle"
+npm install @cipherstash/stack @cipherstash/stack-drizzle drizzle-orm
+```
+
+Then install EQL into your database:
+
+```bash
+npx stash eql install
+```
+
+
+The v3 install (the default) runs directly against the database and cannot generate a Drizzle migration: `--drizzle` is rejected for a v3 install. EQL is therefore not part of your migration history, so a database rebuilt from migrations will not have it. Re-run the install after a rebuild.
+
+
+## Declare the table
+
+The column type *is* the capability. There are no flags to configure. `TextEq` answers equality and nothing else, `IntegerOrd` answers ranges and ordering, `TextMatch` answers free-text containment, and a bare `Text` or `Bigint` is storage-only.
+
+```typescript filename="src/schema.ts"
+import { pgTable, integer } from "drizzle-orm/pg-core"
+import { types } from "@cipherstash/stack-drizzle/v3"
+
+export const users = pgTable("users", {
+ id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
+ email: types.TextEq("email"), // equality
+ age: types.IntegerOrd("age"), // ranges, ordering, and equality
+ bio: types.TextMatch("bio"), // free-text containment
+ balance: types.Bigint("balance"), // storage only
+})
+```
+
+Each factory maps to the matching EQL domain, so `types.TextEq("email")` types the column as `public.eql_v3_text_eq`. The variant model behind those names is in [core concepts](/reference/eql/core-concepts).
+
+| Factory | Capability | EQL domain |
+| --- | --- | --- |
+| `types.Text(name)` | Store and decrypt only | `public.eql_v3_text` |
+| `types.TextEq(name)` | `eq`, `ne`, `inArray`, `notInArray` | `public.eql_v3_text_eq` |
+| `types.TextOrd(name)` | Ranges, ordering, plus equality | `public.eql_v3_text_ord` |
+| `types.TextMatch(name)` | `contains` | `public.eql_v3_text_match` |
+| `types.TextSearch(name)` | All of the above, on text | `public.eql_v3_text_search` |
+| `types.IntegerOrd(name)` | Ranges, ordering, plus equality | `public.eql_v3_integer_ord` |
+
+The same bare / `Eq` / `Ord` pattern exists for `Smallint`, `Bigint`, `Real`, `Double`, `Numeric`, `Date`, and `Timestamp`. `Boolean` is storage-only by design, because a two-value column has too little cardinality for any searchable index to be safe.
+
+Declare only the capability you query on. Each one stores an extra index term alongside the ciphertext, with a bounded, published leakage profile: see [searchable encryption](/concepts/searchable-encryption).
+
+## Wire up the client
+
+Derive the encryption schema from the Drizzle table, build a typed client, and create the operators:
+
+```typescript filename="src/db.ts"
+import { drizzle } from "drizzle-orm/postgres-js"
+import {
+ createEncryptionOperatorsV3,
+ extractEncryptionSchemaV3,
+} from "@cipherstash/stack-drizzle/v3"
+import { EncryptionV3 } from "@cipherstash/stack/v3"
+import { users } from "./schema"
+
+export const usersSchema = extractEncryptionSchemaV3(users)
+
+export const client = await EncryptionV3({ schemas: [usersSchema] })
+export const ops = createEncryptionOperatorsV3(client)
+
+export const db = drizzle({ client: sqlClient })
+```
+
+`extractEncryptionSchemaV3` reads the encryption config straight off the column types, so there is no second schema to keep in sync with the first.
+
+## Query
+
+Every where-clause operator is **async**, because it encrypts its operand before Drizzle sees it. `await` each one. `ops.asc` and `ops.desc` are synchronous.
+
+```typescript filename="src/queries.ts"
+import { db, ops } from "./db"
+import { users } from "./schema"
+
+// Equality â email is TextEq
+const exact = await db.select().from(users)
+ .where(await ops.eq(users.email, "alice@example.com"))
+
+// Ranges and ordering â age is IntegerOrd
+const adults = await db.select().from(users)
+ .where(await ops.gte(users.age, 18))
+ .orderBy(ops.asc(users.age))
+
+const midBand = await db.select().from(users)
+ .where(await ops.between(users.age, 25, 40))
+
+// Set membership, built on equality
+const listed = await db.select().from(users)
+ .where(await ops.inArray(users.email, ["alice@example.com", "bob@example.com"]))
+
+// Free-text token containment â bio is TextMatch
+const coffee = await db.select().from(users)
+ .where(await ops.contains(users.bio, "coffee"))
+```
+
+The full operator set is `eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `between`, `notBetween`, `inArray`, `notInArray`, `contains`, `exists`, `notExists`, `and`, `or`, `not`, `isNull`, `isNotNull`, `asc`, and `desc`. Null checks need no encryption, because a SQL `NULL` is never encrypted.
+
+Applying an operator the column's type cannot answer throws `EncryptionOperatorError` rather than silently returning wrong rows. Ordering a `TextEq` column, for instance, has no ordering term to compare.
+
+
+There is no `like` or `ilike`. SQL pattern matching is meaningless on ciphertext, so free-text search is `ops.contains`, which compares encrypted token sets. It matches whole indexed tokens rather than arbitrary substrings.
+
+
+## Insert and read
+
+Rows are encrypted **before** they reach Drizzle, so Drizzle never sees plaintext. Encrypt a batch in one call, which is also one ZeroKMS round trip:
+
+```typescript filename="src/insert.ts"
+import { client, db, usersSchema } from "./db"
+import { users } from "./schema"
+
+const rows = await client.bulkEncryptModels(
+ [
+ { email: "alice@example.com", age: 30, bio: "climbing and coffee", balance: 100_000n },
+ { email: "bob@example.com", age: 41, bio: "cycling and coffee", balance: 250_000n },
+ ],
+ usersSchema,
+)
+
+if (rows.failure) {
+ throw new Error(rows.failure.message)
+}
+
+await db.insert(users).values(rows.data)
+```
+
+`Bigint` columns take a native JS `bigint`. Decrypt what comes back with `client.bulkDecryptModels(rows)`, which is likewise one round trip for the batch.
+
+## Indexes
+
+At real row counts, add a functional index for each capability you query. The encrypted operators inline into these extractor functions, so an ordinary `CREATE INDEX` is all it takes:
+
+```sql filename="indexes.sql"
+CREATE INDEX users_email_eq ON users USING hash (eql_v3.eq_term(email));
+CREATE INDEX users_age_ord ON users USING btree (eql_v3.ord_term(age));
+CREATE INDEX users_bio_match ON users USING gin (eql_v3.match_term(bio));
+ANALYZE users;
+```
+
+Index selection, `EXPLAIN` verification, and large-table build guidance are in [EQL indexes](/reference/eql/indexes).
+
+## Where to next
+
+
+
+ Drizzle over a Supabase Postgres connection.
+
+
+ The variant model behind each column type.
+
+
+ What each capability reveals, and how to choose.
+
+
+ The functional-index recipes behind every query on this page.
+
+
diff --git a/content/docs/integrations/index.mdx b/content/docs/integrations/index.mdx
index 6148388..b2ee232 100644
--- a/content/docs/integrations/index.mdx
+++ b/content/docs/integrations/index.mdx
@@ -1,9 +1,46 @@
---
title: Integrations
-description: "Set up CipherStash with your platform, ORM, framework, auth provider, and runtime."
-type: tutorial
+description: "Every CipherStash integration, by category: the Postgres platforms encrypted data lives in, and the query layers that read and write it."
+type: concept
+components: [encryption, eql, platform]
+audience: [developer]
---
-This section is being built as part of the docs V2 overhaul ([CIP-3307](https://linear.app/cipherstash/issue/CIP-3307)). Track progress in [IA.md](https://github.com/cipherstash/docs/blob/v2/IA.md).
+Every integration below writes the same ciphertext into the same [EQL](/reference/eql) columns, so none of them is a one-way door. You can move a table from one to another without re-encrypting it, and use more than one at once â an ORM for application queries, raw SQL for migrations.
-Until it lands, current documentation lives in the [existing docs](/stack).
+## Database
+
+Where the encrypted data lives. EQL installs into any Postgres you can connect to, with no extension and no superuser.
+
+
+
+ Install EQL with the `stash` CLI, then query encrypted columns through PostgREST. Row Level Security, Supabase Auth, and Edge Functions all apply unchanged.
+
+
+ Prisma's hosted Postgres. EQL is installed by the Prisma Next migration system, in the same sweep that creates your tables.
+
+
+
+## ORM
+
+How your application queries it.
+
+
+
+ `encryptedSupabase` wraps your existing client so `.eq()`, `.in()`, `.gt()`, and `.order()` keep working on encrypted columns.
+
+
+ Encrypted columns declared in `schema.prisma`. Targets EQL v2. Not classic Prisma.
+
+
+ Concrete encrypted column types and capability-checked operators. Targets EQL v3.
+
+
+
+
+Not on this list? Any ORM works without an integration â encrypt before a write and decrypt after a read with the [Stack SDK](/reference/stack), which is what the integrations do for you. [CipherStash Proxy](/reference/proxy) covers the case where you cannot change the application at all, and the SDK also encrypts values that never reach Postgres, including non-Postgres stores like [DynamoDB](/stack/cipherstash/encryption/dynamodb).
+
+
+## Still deciding
+
+[Choose your stack](/get-started/choose-your-stack) walks the four decisions in order: SDK or Proxy, your Postgres, your ORM, and your identity provider â including which identity providers can bind decryption to the signed-in user.
diff --git a/content/docs/integrations/meta.json b/content/docs/integrations/meta.json
index 13995d5..eb155bd 100644
--- a/content/docs/integrations/meta.json
+++ b/content/docs/integrations/meta.json
@@ -1,5 +1,5 @@
{
"title": "Integrations",
"icon": "Blocks",
- "pages": ["..."]
+ "pages": ["!index", "supabase", "..."]
}
diff --git a/content/docs/integrations/prisma-next.mdx b/content/docs/integrations/prisma-next.mdx
new file mode 100644
index 0000000..a5333fa
--- /dev/null
+++ b/content/docs/integrations/prisma-next.mdx
@@ -0,0 +1,146 @@
+---
+title: Prisma Next
+description: "Encrypted columns with Prisma Next: declare them in schema.prisma, and the migration system installs EQL for you."
+type: tutorial
+components: [encryption, eql]
+audience: [developer]
+integration:
+ category: orm
+ setup: code-only
+ pairsWith: [supabase, drizzle]
+verifiedAgainst:
+ prismaNext: "0.3.2"
+ eql: "2"
+---
+
+`@cipherstash/prisma-next` adds searchable, field-level encryption to [Prisma Next](https://www.npmjs.com/package/prisma-next). You declare encrypted columns in `schema.prisma`, and the framework's migration system installs the EQL bundle in the same sweep that creates your tables. There is no separate "install EQL" step.
+
+
+This integration targets **Prisma Next**, not the classic `@prisma/client` ORM. If you are on classic Prisma, there is no extension: use the [Stack SDK](/reference/stack) directly, encrypting before a write and decrypting after a read. Every ORM supports that.
+
+It also targets **EQL v2**, while the rest of these docs target [EQL v3](/reference/eql). Encrypted columns are `eql_v2_encrypted`, and free-text search is `cipherstashIlike` rather than v3's token containment.
+
+
+## Install
+
+```bash cta cta-type="install" example-id="install-prisma-next"
+npm install @cipherstash/stack @cipherstash/prisma-next
+```
+
+## Declare encrypted columns
+
+Six column types cover the encryptable surface: string, double, bigint, date, boolean, and JSON.
+
+```prisma filename="prisma/schema.prisma"
+model User {
+ id String @id
+ email cipherstash.EncryptedString()
+ salary cipherstash.EncryptedDouble()
+ birthday cipherstash.EncryptedDate()
+ preferences cipherstash.EncryptedJson()
+}
+```
+
+Register the extension pack so the framework knows how to plan migrations and encode values:
+
+```typescript filename="prisma-next.config.ts"
+import cipherstash from "@cipherstash/prisma-next/control"
+
+export default defineConfig({
+ // family, target, adapter, contract
+ extensionPacks: [cipherstash],
+})
+```
+
+## Wire up the client
+
+`cipherstashFromStack` builds the encryption client from your emitted contract, so there is no second schema to maintain alongside `schema.prisma`:
+
+```typescript filename="src/db.ts"
+import "dotenv/config"
+import { cipherstashFromStack } from "@cipherstash/prisma-next/stack"
+import postgres from "@prisma-next/postgres/runtime"
+import type { Contract } from "./prisma/contract.d"
+import contractJson from "./prisma/contract.json" with { type: "json" }
+
+const cipherstash = await cipherstashFromStack({ contractJson })
+
+export const db = postgres({
+ contractJson,
+ extensions: cipherstash.extensions,
+ middleware: cipherstash.middleware,
+})
+```
+
+## Migrate
+
+`migration apply` installs the EQL bundle alongside your schema:
+
+```bash
+npx stash auth login # once per developer
+npx prisma-next contract emit
+npx prisma-next migration plan --name initial
+npx prisma-next migration apply # installs EQL and creates your tables
+```
+
+This is the one place Prisma Next has an advantage over the other integrations: EQL lands inside your migration history, so a rebuilt database has it.
+
+## Read and write
+
+Values are wrapped in an envelope class on the way in, and decrypted explicitly on the way out. Plaintext is redacted from every implicit serialisation path (`toJSON`, `toString`, `util.inspect`), so a stray `console.log` cannot leak it.
+
+```typescript filename="src/queries.ts"
+import { EncryptedString, decryptAll } from "@cipherstash/prisma-next/runtime"
+import { db } from "./db"
+
+await db.orm.User.create({
+ id: "user-0",
+ email: EncryptedString.from("alice@example.com"),
+})
+
+const rows = await db.orm.User
+ .where((u) => u.email.cipherstashIlike("%@example.com"))
+ .all()
+
+await decryptAll(rows)
+console.log(await rows[0]?.email.decrypt())
+```
+
+`decryptAll` decrypts a whole result set in one round trip. A single field decrypts with `await row.email.decrypt()`.
+
+## Query operators
+
+Each operator is a method on the encrypted field, and the search config the column was declared with decides which ones it can answer:
+
+| Operator | Answers |
+| --- | --- |
+| `cipherstashEq`, `cipherstashNe` | Equality |
+| `cipherstashInArray`, `cipherstashNotInArray` | Set membership |
+| `cipherstashGt`, `cipherstashGte`, `cipherstashLt`, `cipherstashLte` | Ranges |
+| `cipherstashBetween`, `cipherstashNotBetween` | Bounded ranges |
+| `cipherstashIlike`, `cipherstashNotIlike` | Free-text match |
+| `cipherstashAsc`, `cipherstashDesc` | Ordering |
+| `cipherstashJsonbGet`, `cipherstashJsonbPathExists`, `cipherstashJsonbPathQueryFirst` | Encrypted JSON path access |
+
+Writes are coalesced: one SDK round trip per `(table, column)` group per query, rather than one per value.
+
+## Authenticating
+
+Local development uses your `stash` profile after `npx stash auth login`. In production, supply the `CS_*` environment variables instead. See [going to production](/stack/deploy/going-to-production).
+
+## Where to next
+
+
+
+ The EQL v3 ORM integration, with concrete encrypted column types.
+
+
+ Prisma Next over a Supabase Postgres connection.
+
+
+ Encrypt and decrypt directly, for classic Prisma or any other ORM.
+
+
+ What each capability reveals, and how to choose.
+
+
diff --git a/content/docs/integrations/supabase/auth.mdx b/content/docs/integrations/supabase/auth.mdx
new file mode 100644
index 0000000..cd3ad17
--- /dev/null
+++ b/content/docs/integrations/supabase/auth.mdx
@@ -0,0 +1,142 @@
+---
+title: Supabase Auth
+description: "Federate the Supabase Auth session into CipherStash so encryption authenticates as the signed-in user â then, optionally, lock decryption to that user's identity."
+type: guide
+components: [encryption, platform]
+audience: [developer]
+verifiedAgainst:
+ stack: "1.0.0"
+ auth: "0.42.0"
+---
+
+Supabase Auth already knows who your user is. This page connects that identity to CipherStash in two layers you can adopt independently:
+
+1. **Federation** â exchange the user's Supabase session for a CipherStash token, so every encryption and decryption request authenticates *as that user* instead of as a shared service credential. This is the foundation.
+2. **Identity-bound encryption (lock context)** â an optional layer on top of federation that binds a value to the user's identity claim, so only that user can decrypt it â enforced by ZeroKMS, not by your application code.
+
+Federation is useful on its own. Lock context requires it. Start with federation; add lock context where per-user secrecy matters.
+
+## Register Supabase as an OIDC provider
+
+CipherStash needs to trust your Supabase project as an OIDC issuer before it will accept a Supabase session token. Add the provider once, in the CipherStash dashboard at [dashboard.cipherstash.com/workspaces/_/oidc-providers](https://dashboard.cipherstash.com/workspaces/_/oidc-providers) (the `_` resolves to whichever workspace you select). A Supabase project's issuer is:
+
+```
+https://.supabase.co/auth/v1
+```
+
+> **Good to know**: the [CipherStash dashboard](https://dashboard.cipherstash.com) can register this for you in one click while it is connected to your Supabase project over OAuth. Manual OIDC configuration is covered in the [auth reference](/reference/auth).
+
+## Federate the Supabase session
+
+Authenticate the `Encryption` client with an `OidcFederationStrategy`. It takes your workspace CRN and a `getJwt` callback that returns the user's **current** Supabase access token â the strategy calls it on first use and again on every re-federation, so return a fresh token each time rather than capturing one:
+
+```typescript title="lib/db.ts"
+import { createClient } from "@supabase/supabase-js"
+import { OidcFederationStrategy } from "@cipherstash/stack"
+import { encryptedSupabase } from "@cipherstash/stack-supabase"
+
+const supabaseClient = createClient(
+ process.env.SUPABASE_URL!,
+ process.env.SUPABASE_ANON_KEY!,
+)
+
+// Return the current Supabase session token â re-invoked on every re-federation.
+const getJwt = async () => {
+ const { data: { session } } = await supabaseClient.auth.getSession()
+ if (!session) throw new Error("No active Supabase session")
+ return session.access_token
+}
+
+const federation = OidcFederationStrategy.create(
+ process.env.CS_WORKSPACE_CRN!,
+ getJwt,
+)
+if (federation.failure) throw new Error(federation.failure.error.message)
+
+export const db = await encryptedSupabase(supabaseClient, {
+ config: {
+ authStrategy: federation.data,
+ },
+})
+```
+
+That's the whole integration. Every `db.from(...)` query now encrypts and decrypts under the signed-in user's identity, and your Supabase Auth session and RLS policies apply to the underlying request exactly as before.
+
+
+Run the federation strategy **server-side only**: a Next.js Route Handler or Server Action, or an Edge Function. Never in the browser, where it would expose the session token. For the Edge runtime, see [Edge Functions](/integrations/supabase/edge-functions).
+
+The federation endpoint issues no refresh token. When the CipherStash token expires, the strategy re-federates by calling `getJwt` again, and because `getJwt` reads from `supabaseClient.auth.getSession()`, supabase-js's own token refresh keeps it valid.
+
+
+## Identity-bound encryption (lock context)
+
+Federation authenticates *as* the user. **Lock context** goes further: it bakes a claim from the user's JWT (by default `sub`, which for Supabase Auth is the user's UUID) into the data key, so a value can only be decrypted by presenting the same identity. Even your own backend â holding valid workspace credentials â cannot decrypt another user's locked values.
+
+
+Lock context requires the client to be authenticated with `OidcFederationStrategy` (above). It cannot be used with `AccessKeyStrategy`, which authenticates a *service*, not a user â there is no user `sub` claim to bind to. Lock context also requires a Business or Enterprise workspace plan.
+
+
+Attach a lock context to any [`encryptedSupabase`](/reference/stack/supabase) query with `.withLockContext()`. `sub` is the Supabase user's UUID:
+
+```typescript
+import { db } from "./lib/db"
+
+const lockContext = { identityClaim: ["sub"] }
+
+// Insert â the value is bound to the signed-in user's `sub` claim
+await db.from("patients")
+ .insert({ email: "alice@example.com", name: "Alice Chen" })
+ .withLockContext(lockContext)
+
+// Read â the same user must be authenticated
+const { data } = await db.from("patients")
+ .select("id, email, name")
+ .eq("email", "alice@example.com")
+ .withLockContext(lockContext)
+```
+
+
+The query builder accepts either the plain `{ identityClaim: ["sub"] }` shape shown above or a `LockContext` instance. The plain shape is sufficient for new code.
+
+
+ZeroKMS resolves the claim's *value* from the token that authenticated the request â the federated Supabase identity â so no separate identify step or per-operation token is needed. Reading a locked value without a matching context, or as a different user, fails with an encryption error regardless of what RLS allows.
+
+
+Earlier releases used `new LockContext().identify(jwt)` to fetch a per-operation token. `identify()` and `getLockContext()` are **deprecated** â per-operation CTS tokens were removed in protect-ffi 0.25. The `LockContext` constructor itself is not deprecated. Authenticate the client with `OidcFederationStrategy` and pass the context straight to `.withLockContext()`, as above.
+
+
+## Where it fits
+
+- **Per-user data** (a user's own medical history, messages, documents): lock to `sub`. The row is useless to anyone but its owner â including operators with database access and your own service role.
+- **Shared / tenant data** (a support queue, org-wide records): don't lock it, or you'll be unable to decrypt it outside a user session. Federation alone still authenticates access as the user; RLS scopes which rows they see.
+- **Server-side jobs** that must read locked data need the owning user's session â by design. If a background job must read a field, that field shouldn't be identity-locked.
+
+## Errors
+
+Both federation and lock context surface failures as values, not throws.
+
+| Scenario | What you see |
+| --- | --- |
+| Provider not registered with the workspace | Federation fails â register the Supabase OIDC issuer first |
+| Expired or invalid Supabase session | `getJwt` throws / returns no session; federation cannot proceed |
+| CipherStash token expired mid-request | Strategy re-federates automatically via `getJwt` |
+| Decrypting another user's locked value | `encryptionError` on the [query response](/reference/stack/supabase#responses-and-errors) |
+
+The strategy's own factory and token errors follow the `@cipherstash/auth` `Result` shape (`failure.type`); see the [auth reference](/reference/auth).
+
+## Where to next
+
+
+
+ The concept: federation, lock contexts, and what identity binding proves.
+
+
+ Run the federation strategy server-side in Supabase Edge Functions.
+
+
+ `.withLockContext()` and `.audit()` on the query builder.
+
+
+ OIDC providers, federation strategies, and access keys.
+
+
diff --git a/content/docs/integrations/supabase/edge-functions.mdx b/content/docs/integrations/supabase/edge-functions.mdx
new file mode 100644
index 0000000..485ff2c
--- /dev/null
+++ b/content/docs/integrations/supabase/edge-functions.mdx
@@ -0,0 +1,137 @@
+---
+title: Edge Functions
+description: "Run CipherStash encryption inside Supabase Edge Functions using the @cipherstash/stack WebAssembly build, with the CipherStash token cached across invocations in an HTTP-only cookie."
+type: guide
+components: [encryption, platform]
+audience: [developer]
+verifiedAgainst:
+ stack: "1.0.0"
+ auth: "0.42.0"
+---
+
+Encryption and decryption must run somewhere your keys and credentials are safe â never in the browser. Supabase Edge Functions are that place: server-side compute next to your database. CipherStash ships a **WebAssembly build** of the SDK for exactly this runtime, so the same encrypt/decrypt code runs in Deno with no native bindings.
+
+## Why the wasm build
+
+Supabase Edge Functions run on Deno, which loads npm packages through the `node` compatibility layer. The SDK's default entry resolves to native NAPI bindings that don't exist in that runtime, so the edge uses a dedicated entry: `@cipherstash/stack/wasm-inline`. It embeds the WebAssembly module as base64 inside the JS bundle â no separate `.wasm` fetch, no bundler plugins, no `static_files` config. The same applies to the auth package (`@cipherstash/auth/wasm-inline`).
+
+## Set up the function
+
+Map the `wasm-inline` entries in your function's `deno.json`:
+
+```jsonc title="supabase/functions/encrypt/deno.json"
+{
+ "imports": {
+ "@cipherstash/stack/wasm-inline": "npm:@cipherstash/stack@^0.18/wasm-inline",
+ "@cipherstash/auth/wasm-inline": "npm:@cipherstash/auth@^0.41/wasm-inline",
+ "@cipherstash/auth/cookies": "npm:@cipherstash/auth@^0.41/cookies"
+ }
+}
+```
+
+
+**The developer profile is not supported in Edge Functions.** `stash auth login` creates a profile for the native Node.js SDK on your development machine. An Edge Function runs the WebAssembly client inside an isolated Deno runtime, where that profile and automatic credential discovery are unavailable.
+
+Provide the four `CS_*` values explicitly through an env file when serving locally and through Supabase secrets when deployed.
+
+
+Provide the `CS_*` credentials when you serve the function. Generate them with `stash env` rather than assembling the file by hand â it prints the deployment variables for the workspace you are authenticated against, so redirect it straight to the env file:
+
+```bash example-id="supabase-edge-stash-env"
+npx stash env > ./supabase/functions/.env.local
+```
+
+`--write` writes to a file instead of stdout if you would rather not redirect. The command is marked experimental, so read what it emits before committing to it â and never commit the file itself. The [CLI reference](/reference/cli/env) has the full flag set.
+
+Then serve the function against that file:
+
+```bash
+supabase functions serve --env-file ./supabase/functions/.env.local
+```
+
+```sh title="supabase/functions/.env.local"
+CS_WORKSPACE_CRN=crn:ap-southeast-2.aws:
+CS_CLIENT_ID=...
+CS_CLIENT_KEY=...
+CS_CLIENT_ACCESS_KEY=...
+```
+
+For deployed functions, set the same values as [Edge Function secrets](https://supabase.com/dashboard/project/_/settings/functions).
+
+## Encrypt and decrypt in a function
+
+Each invocation builds a fresh client, but the CipherStash service token is cached in an HTTP-only cookie via `cookieStore`, so only the first request per session pays the full round-trip to CipherStash:
+
+```typescript title="supabase/functions/encrypt/index.ts"
+import { Encryption, AccessKeyStrategy, encryptedTable, encryptedColumn } from "@cipherstash/stack/wasm-inline"
+import { cookieStore } from "@cipherstash/auth/cookies"
+
+const patients = encryptedTable("patients", {
+ email: encryptedColumn("email").equality(),
+})
+
+Deno.serve(async (req) => {
+ const responseHeaders = new Headers({ "content-type": "application/json" })
+
+ // Cache the CipherStash token across invocations in an HTTP-only cookie
+ const authStrategy = AccessKeyStrategy.create(
+ Deno.env.get("CS_WORKSPACE_CRN")!,
+ Deno.env.get("CS_CLIENT_ACCESS_KEY")!,
+ { store: cookieStore({ request: req, responseHeaders }) },
+ )
+ if (authStrategy.failure) {
+ return Response.json({ error: authStrategy.failure.type }, { status: 500, headers: responseHeaders })
+ }
+
+ const client = await Encryption({
+ schemas: [patients],
+ config: {
+ authStrategy: authStrategy.data,
+ clientId: Deno.env.get("CS_CLIENT_ID")!,
+ clientKey: Deno.env.get("CS_CLIENT_KEY")!,
+ },
+ })
+
+ const encrypted = await client.encrypt("alice@example.com", { column: patients.email, table: patients })
+ const decrypted = await client.decrypt(encrypted.data)
+
+ return Response.json({ ok: decrypted.data === "alice@example.com" }, { headers: responseHeaders })
+})
+```
+
+Note the `.failure` / `.data` result shape: on the wasm entry, `AccessKeyStrategy.create` returns a `Result` you unwrap (unlike the Node build, where it returns the strategy directly).
+
+## Per-user identity on the edge
+
+To encrypt as the signed-in Supabase user rather than a service credential, swap `AccessKeyStrategy` for `OidcFederationStrategy` â its `getJwt` callback returns the current Supabase session token. The mechanics and the identity-lock layer are covered in [Supabase Auth](/integrations/supabase/auth); the only edge-specific difference is the `wasm-inline` import and the `Result` unwrap:
+
+```typescript
+import { OidcFederationStrategy } from "@cipherstash/stack/wasm-inline"
+import { cookieStore } from "@cipherstash/auth/cookies"
+
+const authStrategy = OidcFederationStrategy.create(
+ Deno.env.get("CS_WORKSPACE_CRN")!,
+ () => getSupabaseSessionToken(req), // return the current session JWT
+ { store: cookieStore({ request: req, responseHeaders }) },
+)
+```
+
+## Caveats
+
+- **Cold start.** The first request pays the full path: WebAssembly compile, auth round-trip to CipherStash, and ZeroKMS dataset-key initialization. Subsequent requests reuse the cookie-cached token and only pay for the encrypt/decrypt work.
+- **Bundle size.** The inline wasm build is larger than a native binding (the module ships as base64 in the JS). That's the trade for zero runtime config â acceptable for an edge function that boots once per worker.
+- **Server-side only.** These credentials and the session token must never reach the browser. Keep this code in the Edge Function.
+
+## Where to next
+
+
+
+ Federation and identity-locked encryption â the full identity story.
+
+
+ The standard (Node) setup with the encryptedSupabase wrapper.
+
+
+ The core Encryption client API used here.
+
+
diff --git a/content/docs/integrations/supabase/index.mdx b/content/docs/integrations/supabase/index.mdx
index 5ce53db..854ae70 100644
--- a/content/docs/integrations/supabase/index.mdx
+++ b/content/docs/integrations/supabase/index.mdx
@@ -1,20 +1,56 @@
---
-title: Supabase
-description: "Searchable, application-level encryption for your Supabase project â encrypt in your app, query in Postgres."
-type: tutorial
+title: Overview
+description: "Add searchable application-level encryption to Supabase while keeping the Supabase client, Auth, Row Level Security, and your choice of Postgres ORM."
+type: concept
components: [encryption, eql, platform]
-audience: [developer]
+audience: [developer, ciso]
integration:
category: platform
- setup: dashboard-required
- pairsWith: [drizzle, prisma-next, clerk, nextjs]
+ setup: code-only
+ pairsWith: [drizzle, prisma-next, clerk]
+verifiedAgainst:
+ eql: "3.0.4"
+ stack: "1.0.0"
---
-CipherStash adds application-level encryption to your Supabase project:
-sensitive fields are encrypted in your application before they reach Postgres,
-and stay queryable with the same Supabase.js calls you already use.
+CipherStash adds searchable, field-level encryption to Supabase. Your application encrypts sensitive values before they reach Postgres, so the database, backups, replication streams, and Supabase dashboard contain ciphertext rather than plaintext.
-This page is being rebuilt as part of the docs V2 overhaul
-([CIP-3328](https://linear.app/cipherstash/issue/CIP-3328)). Until it lands,
-the current Supabase integration guide lives at
-[CipherStash + Supabase](/stack/cipherstash/supabase).
+Unlike ordinary application-level encryption, protected columns remain queryable. Equality filters, range queries, and ordering use the same Supabase query-builder methods as plaintext columns, while CipherStash encrypts query values and decrypts results around each request.
+
+## How it works
+
+CipherStash has two parts:
+
+1. [Encrypt Query Language (EQL)](/reference/eql) adds encrypted column types, operators, and functions to your Supabase Postgres database.
+2. [`encryptedSupabase`](/reference/stack/supabase) wraps the Supabase JavaScript client and performs encryption and decryption in your application.
+
+The EQL type assigned to a column declares what can be queried. For example, an equality column supports `.eq()` and `.in()`, while an ordered column also supports ranges and `.order()`. Choose only the capabilities you need, because searchable encryption has defined leakage characteristics. See [EQL core concepts](/reference/eql/core-concepts) for the complete model.
+
+With EQL 3.0.4, the Supabase wrapper supports encrypted equality, range, and ordering queries. Encrypted free-text and JSON queries require an adapter such as Drizzle or Prisma Next because PostgREST cannot express the typed operands those operations require. The [wrapper reference](/reference/stack/supabase#free-text-and-json-need-a-different-adapter) documents the limitation.
+
+## Choose your path
+
+| What you want to do | Start here |
+| --- | --- |
+| Encrypt and query your first Supabase column | [Supabase Quickstart](/integrations/supabase/quickstart) |
+| Use the Supabase JavaScript client | [supabase-js guide](/integrations/supabase/supabase-js) |
+| Look up the complete wrapper API | [`encryptedSupabase` reference](/reference/stack/supabase) |
+| Bind decryption to the signed-in user | [Supabase Auth](/integrations/supabase/auth) |
+| Encrypt inside Supabase Edge Functions | [Edge Functions](/integrations/supabase/edge-functions) |
+| Use an ORM against Supabase Postgres | [Drizzle](/integrations/drizzle) or [Prisma Next](/integrations/prisma-next) |
+| Encrypt columns that already contain data | [Encrypt existing data](/guides/migration/encrypt-existing-data) |
+| Plan a production rollout or rollback | [Deployment guide](/guides/deployment) |
+
+## Supabase, Drizzle, and Prisma
+
+Supabase is Postgres, so you are not limited to PostgREST or `supabase-js`. CipherStash's [Drizzle](/integrations/drizzle) and [Prisma Next](/integrations/prisma-next) integrations can read and write the same EQL columns through a direct database connection. They produce the same ciphertext, so an application can use more than one adapter without re-encrypting its data.
+
+Use `encryptedSupabase` when you want Supabase's query builder and automatic propagation of the caller's JWT. Use an ORM when it better fits your application or when you need encrypted free-text or JSON queries. A privileged direct database connection may bypass Row Level Security, so preserve tenant or user scoping in the connection role or ORM query.
+
+## Row Level Security and indexes
+
+Encryption complements Supabase Row Level Security rather than replacing it. RLS controls which rows a caller may query; encryption controls whether the values in those rows can be read. Keep RLS predicates on plaintext ownership columns such as `user_id`, and encrypt the sensitive contents of the row. [Supabase Auth](/integrations/supabase/auth) can additionally bind decryption to the authenticated user's identity.
+
+Encrypted columns use ordinary functional Postgres indexes over EQL term-extractor functions. The recipes, query shapes, `EXPLAIN` checks, and large-table guidance are maintained in [EQL indexes](/reference/eql/indexes).
+
+In the Supabase Table Editor, encrypted columns appear as ciphertext payloads. Read and edit them through an application using CipherStash; the dashboard does not hold the keys required to decrypt them.
diff --git a/content/docs/integrations/supabase/meta.json b/content/docs/integrations/supabase/meta.json
index b4690bf..f03d6df 100644
--- a/content/docs/integrations/supabase/meta.json
+++ b/content/docs/integrations/supabase/meta.json
@@ -1,5 +1,5 @@
{
"title": "Supabase",
"icon": "Supabase",
- "pages": ["..."]
+ "pages": ["index", "quickstart", "supabase-js", "auth", "edge-functions"]
}
diff --git a/content/docs/integrations/supabase/quickstart.mdx b/content/docs/integrations/supabase/quickstart.mdx
new file mode 100644
index 0000000..c3a3fd4
--- /dev/null
+++ b/content/docs/integrations/supabase/quickstart.mdx
@@ -0,0 +1,165 @@
+---
+title: Quickstart
+description: "Encrypt, store, and query your first field in Supabase with EQL and the encrypted Supabase JavaScript client."
+type: tutorial
+components: [encryption, eql, platform]
+audience: [developer]
+integration:
+ category: platform
+ setup: code-only
+ pairsWith: [drizzle, prisma-next, clerk]
+verifiedAgainst:
+ eql: "3.0.4"
+ stack: "1.0.0"
+---
+
+This tutorial creates a Supabase table with one encrypted column, writes a value through the Supabase JavaScript client, and queries it without sending plaintext to Postgres.
+
+It is designed for a new table or an empty column. If the column already contains production data, use [Encrypt existing data](/guides/migration/encrypt-existing-data) instead.
+
+## Before you start
+
+You need:
+
+- A Supabase project and its database connection string
+- A Node.js project
+- A CipherStash account; the setup flow signs you in
+
+
+
+
+### Initialize CipherStash
+
+Add your Supabase settings to the project's environment:
+
+```sh title=".env"
+SUPABASE_URL=https://.supabase.co
+SUPABASE_ANON_KEY=...
+DATABASE_URL=postgresql://postgres:...@db..supabase.co:5432/postgres
+```
+
+Then run the Supabase setup flow:
+
+```bash cta cta-type="install" example-id="supabase-quickstart-eql-install"
+npx stash init --supabase
+```
+
+`stash init`:
+
+- Opens the CipherStash device login if you do not already have a developer profile
+- Resolves the database configuration
+- Installs compatible, pinned versions of `@cipherstash/stack`, `@cipherstash/stack-supabase`, and the `stash` CLI
+- Installs EQL v3 with the grants required by Supabase's `anon`, `authenticated`, and `service_role` roles
+- Scaffolds the encryption client and records context for coding agents
+
+If the command generates a Supabase migration instead of applying EQL directly, run the apply command it prints before continuing. Confirm that EQL is available with:
+
+```bash
+npx stash eql status
+```
+
+The native Stack client automatically uses the developer profile created by this login. You do not need `CS_*` machine credentials for local development; those are for deployed environments.
+
+
+**Using an agent?** An agent can drive the same setup. If no developer profile exists, it should start `npx stash auth login --json --region --supabase`, show you the returned verification URL, and leave the command running until authorization completes. It can then run `npx stash init --supabase --region `.
+
+At the end of interactive initialization, you can continue into `stash plan`. Choosing Codex or Claude Code installs the Supabase-specific CipherStash skills into the agent's project directory; editor agents receive the same guidance through `AGENTS.md`. The skills cover Supabase, authentication, EQL indexes, deployment, and the CLI, giving the agent the context needed to plan and implement encryption changes safely.
+
+
+
+
+
+### Create an encrypted column
+
+Run this in the Supabase SQL Editor or add it to a new migration:
+
+```sql
+CREATE TABLE contacts (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ email public.eql_v3_text_eq NOT NULL,
+ display_name text
+);
+```
+
+`email` is an EQL equality column, so it can be stored, decrypted, and queried with methods such as `.eq()` and `.in()`. `display_name` is ordinary plaintext and passes through unchanged.
+
+The column type is the encryption schema. There is no separate application declaration to keep synchronized. For other data types and query capabilities, see [EQL core concepts](/reference/eql/core-concepts).
+
+
+
+
+### Create the encrypted Supabase client
+
+`stash init` installed the CipherStash packages. If this project does not already use the Supabase JavaScript client, install it:
+
+```bash
+npm install @supabase/supabase-js
+```
+
+Create a client:
+
+```typescript title="lib/db.ts"
+import { encryptedSupabase } from "@cipherstash/stack-supabase"
+
+export const db = await encryptedSupabase(
+ process.env.SUPABASE_URL!,
+ process.env.SUPABASE_ANON_KEY!,
+)
+```
+
+At startup, `encryptedSupabase` uses `DATABASE_URL` to inspect the database and recognize `contacts.email` as encrypted. Plaintext tables and columns continue to behave normally.
+
+
+
+
+### Insert and query a value
+
+Write plaintext application values as usual:
+
+```typescript
+import { db } from "./lib/db"
+
+const { error: insertError } = await db.from("contacts").insert({
+ email: "alice@example.com",
+ display_name: "Alice",
+})
+
+if (insertError) throw insertError
+```
+
+The wrapper encrypts `email` before it leaves the application. It then encrypts the filter value when you query the column and decrypts the matching result:
+
+```typescript
+const { data, error } = await db
+ .from("contacts")
+ .select("id, email, display_name")
+ .eq("email", "alice@example.com")
+ .single()
+
+if (error) throw error
+
+console.log(data)
+// { id: 1, email: "alice@example.com", display_name: "Alice" }
+```
+
+Supabase Row Level Security still applies because the wrapper sends the request through the Supabase client. Add policies just as you would for an unencrypted table.
+
+
+
+
+### Confirm that Supabase sees ciphertext
+
+Open `contacts` in the Supabase Table Editor. The `email` cell contains an EQL ciphertext payload, while `display_name` remains readable.
+
+The Table Editor cannot decrypt or meaningfully filter the encrypted value because its keys remain outside the database. Read and update encrypted fields through a CipherStash-enabled application.
+
+
+
+
+## Next steps
+
+- Learn the supported mutations, filters, ordering, response types, and errors in the [supabase-js guide](/integrations/supabase/supabase-js).
+- Use the complete [`encryptedSupabase` API reference](/reference/stack/supabase) when you need method-level detail.
+- Add functional indexes when the table reaches meaningful row counts by following [EQL indexes](/reference/eql/indexes).
+- For a populated table, follow the safe dual-write and backfill process in [Encrypt existing data](/guides/migration/encrypt-existing-data).
+- To use a direct Postgres ORM connection, continue with [Drizzle](/integrations/drizzle) or [Prisma Next](/integrations/prisma-next).
diff --git a/content/docs/integrations/supabase/supabase-js.mdx b/content/docs/integrations/supabase/supabase-js.mdx
new file mode 100644
index 0000000..a1d1ebd
--- /dev/null
+++ b/content/docs/integrations/supabase/supabase-js.mdx
@@ -0,0 +1,231 @@
+---
+title: supabase-js
+description: "Use the encryptedSupabase wrapper for transparent encryption, decryption, filtering, and ordering through the Supabase JavaScript client."
+type: guide
+components: [encryption, eql, platform]
+audience: [developer]
+integration:
+ category: orm
+ setup: code-only
+ pairsWith: [supabase]
+verifiedAgainst:
+ stack: "1.0.0"
+ eql: "3.0.4"
+---
+
+`encryptedSupabase` wraps a [`@supabase/supabase-js`](https://supabase.com/docs/reference/javascript/introduction) client. It encrypts values before mutations leave your application, encrypts query operands for searchable columns, and decrypts selected rows before returning them. Plaintext columns pass through unchanged.
+
+The result is the familiar supabase-js query shape:
+
+```typescript
+const { data, error } = await db
+ .from("patients")
+ .select("id, name, date_of_birth")
+ .eq("name", "Alice Chen")
+```
+
+The `name` operand and returned `name` value are plaintext in your code. Supabase and Postgres only receive their encrypted representations.
+
+
+This guide assumes EQL is installed and your encrypted columns use `public.eql_v3_*` domain types. Complete the [Quickstart](/integrations/supabase/quickstart) first if your database is not ready.
+
+
+## Install
+
+Install the Stack SDK, its Supabase adapter, supabase-js, and the Postgres driver used for schema introspection:
+
+```bash cta cta-type="install" example-id="install-supabase-js"
+npm install @cipherstash/stack @cipherstash/stack-supabase @supabase/supabase-js pg
+```
+
+Your server needs these environment variables:
+
+```bash
+SUPABASE_URL=https://.supabase.co
+SUPABASE_ANON_KEY=...
+DATABASE_URL=postgresql://...
+```
+
+The encryption client also needs a local developer profile created by `stash auth login`, or `CS_*` machine credentials in a deployed environment. The [Quickstart setup](/integrations/supabase/quickstart#initialize-cipherstash) creates and uses the developer profile.
+
+## Create the client
+
+Pass your Supabase URL and key to `encryptedSupabase`. The async factory uses `DATABASE_URL` once at startup to inspect the `public` schema and identify its EQL columns. Application queries still go through the Supabase URL and PostgREST.
+
+```typescript title="lib/db.ts"
+import { encryptedSupabase } from "@cipherstash/stack-supabase"
+
+export const db = await encryptedSupabase(
+ process.env.SUPABASE_URL!,
+ process.env.SUPABASE_ANON_KEY!,
+)
+```
+
+Pass `databaseUrl` when your direct connection uses another environment variable:
+
+```typescript
+export const db = await encryptedSupabase(supabaseUrl, supabaseKey, {
+ databaseUrl: process.env.SUPABASE_DATABASE_URL,
+})
+```
+
+If your application already creates a Supabase client, wrap that instance instead. Its Auth session, custom headers, and Row Level Security context are preserved:
+
+```typescript title="lib/db.ts"
+import { createClient } from "@supabase/supabase-js"
+import { encryptedSupabase } from "@cipherstash/stack-supabase"
+
+const supabase = createClient(supabaseUrl, supabaseAnonKey)
+
+export const db = await encryptedSupabase(supabase, {
+ databaseUrl: process.env.DATABASE_URL,
+})
+```
+
+
+Create this client on the server. It needs a direct database connection and CipherStash credentials, so it cannot run in a browser or Worker. For Deno, use the [Edge Functions guide](/integrations/supabase/edge-functions).
+
+
+`encryptedSupabaseV3` remains as a deprecated, type-identical alias for existing applications. Use `encryptedSupabase` in new code.
+
+## Add optional TypeScript schemas
+
+Database introspection is enough at runtime. An optional declared schema adds compile-time row and filter types, and verifies at startup that the declaration matches the live database.
+
+```typescript title="lib/db.ts"
+import { encryptedTable, types } from "@cipherstash/stack/eql/v3"
+import { encryptedSupabase } from "@cipherstash/stack-supabase"
+
+const patients = encryptedTable("patients", {
+ name: types.TextEq("name"),
+ dateOfBirth: types.DateOrd("date_of_birth"),
+})
+
+export const typedDb = await encryptedSupabase(supabaseUrl, supabaseAnonKey, {
+ schemas: { patients },
+})
+```
+
+The record key must match the database table name. A property may map to a different database column, as `dateOfBirth` does above; the wrapper applies that mapping to selects, mutations, filters, and returned rows.
+
+## Insert, update, and upsert
+
+Write plaintext values. The wrapper encrypts only the columns backed by EQL domains and sends every other column through unchanged.
+
+```typescript
+const { data, error } = await db
+ .from("patients")
+ .insert({
+ email: "alice@example.com", // plaintext column
+ name: "Alice Chen", // encrypted column
+ date_of_birth: "1988-06-15", // encrypted column
+ plan: "standard", // plaintext column
+ })
+ .select("id, email, name, date_of_birth, plan")
+ .single()
+
+await db
+ .from("patients")
+ .update({ name: "Alice Jones" })
+ .eq("id", data!.id)
+
+await db.from("patients").upsert(
+ { email: "alice@example.com", name: "Alice Jones" },
+ { onConflict: "email" },
+)
+```
+
+`insert` and `upsert` accept one row or an array. The wrapper batches a mutation's encryption work into one ZeroKMS round trip.
+
+## Select and filter
+
+Encrypted values are decrypted before they appear in `data`. Bare `.select()` and `.select("*")` work because the wrapper expands the column list discovered during introspection.
+
+```typescript
+const { data } = await db
+ .from("patients")
+ .select("*")
+ .eq("name", "Alice Chen")
+```
+
+The EQL domain on each encrypted column determines which filters it accepts:
+
+| supabase-js method | Required encrypted domain | Behaviour |
+| --- | --- | --- |
+| `.eq()`, `.neq()`, `.in()` | `_eq`, `_ord`, or `text_search` | Compares encrypted equality terms |
+| `.gt()`, `.gte()`, `.lt()`, `.lte()` | `_ord` or `text_search` | Compares encrypted ordering terms |
+| `.is(column, null)` | Any column | Passes SQL `NULL` through without encryption |
+| `.match()`, `.or()`, `.not()`, `.filter()` | Depends on the operator | Encrypts operands for encrypted columns |
+| `.contains()` | Plaintext JSON or array | Uses native PostgREST containment |
+
+Encrypted and plaintext predicates compose in the same query:
+
+```typescript
+const { data } = await db
+ .from("patients")
+ .select("id, name, date_of_birth, plan")
+ .gte("date_of_birth", "1980-01-01") // encrypted range
+ .lt("date_of_birth", "1990-01-01") // encrypted range
+ .eq("plan", "standard") // plaintext equality
+```
+
+Use `.is(column, null)` for null checks. Passing `null` to a comparison such as `.eq()` cannot create an encrypted query term and is forwarded to PostgREST; it is almost never the intended SQL operation.
+
+## Order and paginate
+
+`.order()` works on plaintext columns and OPE-backed encrypted ordering domains such as `date_ord`, `integer_ord`, and `text_search`. The wrapper orders an encrypted column by the `op` term inside its payload, reproducing plaintext order without decrypting in Postgres.
+
+```typescript
+const { data } = await db
+ .from("patients")
+ .select("id, name, date_of_birth")
+ .order("date_of_birth", { ascending: false })
+ .range(0, 19)
+```
+
+Storage-only, equality-only, and match-only encrypted columns cannot be ordered. ORE-backed `_ord_ore` domains also cannot be ordered through PostgREST; Supabase installations disable those domains because their operator class requires superuser privileges.
+
+`.limit()`, `.range()`, `.single()`, `.maybeSingle()`, `.abortSignal()`, `.throwOnError()`, and `.returns()` otherwise mirror supabase-js. `.csv()` throws because PostgREST serializes ciphertext before the wrapper has a chance to decrypt it; select rows normally and serialize the plaintext result in your application.
+
+## Free-text and encrypted JSON queries
+
+
+**The current EQL 3.0.4 release cannot run encrypted free-text or encrypted JSON queries through supabase-js.** `.matches()`, encrypted `.contains()`, `.selectorEq()`, and `.selectorNe()` fail before a request is sent. Their SQL operators require an `eql_v3.query_*` cast that PostgREST cannot express. This PostgREST boundary was introduced in EQL 3.0.2 and remains in 3.0.4, which is why the runtime error describes it as `3.0.2+`.
+
+Inserts, selects, decryption, equality, ranges, and ordering are unaffected. Use [Drizzle](/integrations/drizzle), [Prisma Next](/integrations/prisma-next), or a carefully scoped SQL/RPC function for free-text and encrypted JSON queries.
+
+
+Scalar encrypted filters currently use a full encrypted storage envelope as the PostgREST operand because PostgREST cannot cast a term-only query envelope. PostgREST sends filters in GET query strings, so configure application and proxy logs not to retain query strings. The operand is encrypted, but it can contain ciphertext and every index term for the queried value.
+
+## Responses and errors
+
+Awaiting the builder returns the normal supabase-js response fields. Encryption failures use the same response path and add `error.encryptionError`:
+
+```typescript
+const { data, error, count, status, statusText } = await db
+ .from("patients")
+ .select("id, name")
+ .eq("name", "Alice Chen")
+
+if (error?.encryptionError) {
+ // CipherStash authentication, encryption, or decryption failed
+} else if (error) {
+ // Supabase or Postgres rejected the request
+}
+```
+
+Chain `.throwOnError()` when the surrounding code prefers exceptions. Chain `.audit({ metadata })` to attach context to ZeroKMS audit events, or `.withLockContext({ identityClaim: ["sub"] })` to bind encryption to a federated user. See [Supabase Auth](/integrations/supabase/auth) before using lock contexts.
+
+## Where to next
+
+
+
+ Preserve the signed-in Supabase user and optionally lock decryption to their identity.
+
+
+ Run encryption in Deno with the WebAssembly build.
+
+
+ The complete query-builder API, response types, and implementation details.
+
+
diff --git a/content/docs/reference/agent-skills.mdx b/content/docs/reference/agent-skills.mdx
index 03ea3a3..88eb5e5 100644
--- a/content/docs/reference/agent-skills.mdx
+++ b/content/docs/reference/agent-skills.mdx
@@ -53,21 +53,21 @@ The CipherStash CLI (`stash`) for database setup, schema management, and project
### stash-drizzle
-Drizzle ORM integration using `@cipherstash/stack/drizzle`.
+Drizzle ORM integration using `@cipherstash/stack-drizzle`, covering both EQL v2 and the EQL v3 dialect on `@cipherstash/stack-drizzle/v3`.
**Covers:** the `encryptedType()` column type; `extractEncryptionSchema()`; `createEncryptionOperators()`; query operators (`eq`, `ne`, `like`, `ilike`, `gt`, `gte`, `lt`, `lte`, `between`, `inArray`, `asc`, `desc`); encrypted JSONB operators (`jsonbPathExists`, `jsonbPathQueryFirst`, `jsonbGet`); batched `and()` / `or()` conditions; EQL migration generation; non-encrypted column fallback; Express, Hono, and Next.js examples.
-**Activates when:** you are using Drizzle ORM with encrypted columns, or importing from `@cipherstash/stack/drizzle`.
+**Activates when:** you are using Drizzle ORM with encrypted columns, or importing from `@cipherstash/stack-drizzle`.
-**Related docs:** [Drizzle integration](/stack/cipherstash/encryption/drizzle)
+**Related docs:** [Drizzle integration](/integrations/drizzle)
### stash-supabase
-Supabase integration using `@cipherstash/stack/supabase`.
+Supabase integration using `@cipherstash/stack-supabase`.
**Covers:** the `encryptedSupabase()` wrapper; transparent encryption on `insert`, `update`, and `upsert`; transparent decryption on `select`, `single`, and `maybeSingle`; encrypted query filters (`eq`, `neq`, `like`, `ilike`, `gt`, `gte`, `lt`, `lte`, `in`, `match`, `or`, `not`, `filter`); identity-aware encryption with `.withLockContext()`; audit logging with `.audit()`; response types and error handling; Supabase-specific database setup.
-**Activates when:** you are using Supabase with encrypted columns, or importing from `@cipherstash/stack/supabase`.
+**Activates when:** you are using Supabase with encrypted columns, or importing from `@cipherstash/stack-supabase`.
**Related docs:** [Supabase integration](/integrations/supabase)
@@ -81,15 +81,13 @@ Amazon DynamoDB integration using `@cipherstash/stack/dynamodb`.
**Related docs:** [DynamoDB integration](/stack/cipherstash/encryption/dynamodb)
-### stash-secrets
+### stash-supply-chain-security
-Encrypted secrets management with `@cipherstash/stack`.
+Supply-chain security controls for a project that depends on `@cipherstash/stack`.
-**Covers:** the `Secrets` class API (`set`, `get`, `getMany`, `list`, `delete`); environment-based isolation with per-environment keysets; bulk retrieval with `getMany` (2 to 100 secrets per call); error types (`ApiError`, `NetworkError`, `ClientError`, `EncryptionError`, `DecryptionError`); configuration via `CS_*` environment variables or explicit config; patterns for loading secrets at startup.
+**Covers:** post-install script policy (`onlyBuiltDependencies`); install cooldown (`minimumReleaseAge`); lockfile integrity (`blockExoticSubdeps` and the lockfile registry check); frozen-lockfile CI; registry pinning via `.npmrc`; Dependabot cooldown; and `CODEOWNERS`.
-**Activates when:** you are storing or retrieving secrets, or working with the `Secrets` class from `@cipherstash/stack/secrets`.
-
-**Related docs:** [Secrets](https://cipherstash.com/stack/secrets)
+**Activates when:** you are modifying CI workflows, pnpm config, dependency updates, `.github/dependabot.yml`, or anything that changes how packages enter your build.
## How skills work
diff --git a/content/docs/reference/stack/supabase.mdx b/content/docs/reference/stack/supabase.mdx
new file mode 100644
index 0000000..8e4dbe7
--- /dev/null
+++ b/content/docs/reference/stack/supabase.mdx
@@ -0,0 +1,291 @@
+---
+title: Supabase wrapper
+description: "The canonical encryptedSupabase reference: connect-time introspection, the full query-builder surface, and how each filter maps to an encrypted index term."
+type: reference
+components: [encryption, eql]
+audience: [developer]
+verifiedAgainst:
+ stack: "1.0.0"
+ eql: "3.0.4"
+---
+
+`encryptedSupabase` wraps a `@supabase/supabase-js` client so that queries on encrypted columns work like queries on plaintext ones. Mutations are encrypted before they leave your app, filter values are encrypted into the matching index term, and results are decrypted on the way back. You keep writing `.eq()`, `.gt()`, `.order()`, and the wrapper handles the encryption on both sides of every call.
+
+This page is the single source of truth for the wrapper's API. The [Supabase integration](/integrations/supabase) shows it in context; the [EQL reference](/reference/eql) covers the database side it queries against.
+
+The wrapper ships as its own package, `@cipherstash/stack-supabase`, which depends on `@cipherstash/stack`:
+
+```bash
+npm install @cipherstash/stack @cipherstash/stack-supabase @supabase/supabase-js
+```
+
+## Setup
+
+`encryptedSupabase` **introspects your database when you call it**. It reads the column types, finds the ones typed as EQL v3 domains, and derives each column's encryption config from the domain. The database already records which columns are encrypted and what they can do, so you do not declare it a second time.
+
+That makes it async, and it makes `.from()` take a table name and nothing else.
+
+```typescript
+import { encryptedSupabase } from "@cipherstash/stack-supabase"
+
+const supabase = await encryptedSupabase(
+ process.env.SUPABASE_URL!,
+ process.env.SUPABASE_ANON_KEY!,
+)
+
+const { data, error } = await supabase
+ .from("users")
+ .select("id, email")
+ .eq("email", "alice@example.com")
+```
+
+Introspection needs a direct Postgres connection, which is a different credential from the Supabase URL and anon key. It reads `DATABASE_URL` from the environment, or `options.databaseUrl` if you pass it.
+
+```typescript
+const supabase = await encryptedSupabase(supabaseUrl, supabaseKey, {
+ databaseUrl: process.env.PG_URL,
+})
+```
+
+Pass an existing client instead of a URL and key when you already build one, for example to attach a Supabase Auth session:
+
+```typescript
+const supabase = await encryptedSupabase(supabaseClient, {
+ databaseUrl: process.env.PG_URL,
+})
+```
+
+### Options
+
+| Option | Type | Purpose |
+| --- | --- | --- |
+| `databaseUrl` | `string` | Postgres connection string used for introspection. Defaults to `process.env.DATABASE_URL`. |
+| `config` | `ClientConfig` | Passed through to the encryption client. `eqlVersion` is forced to `3`. |
+| `schemas` | `Record` | Optional declared tables. Adds compile-time types and startup verification. |
+
+### `.from(tableName)`
+
+Returns a query builder that mirrors the supabase-js builder. Columns that are not EQL domains pass through untouched, so mixed plaintext and encrypted tables are the normal case.
+
+
+A table containing an EQL column that this SDK cannot model throws when you name it in `.from()`. The introspector identifies EQL domains by their Postgres comment, so an EQL column from a newer bundle than your SDK is detected, reported, and refused rather than silently read back as raw ciphertext.
+
+
+## Declaring columns in the database
+
+The column's domain type is the schema. Declare the capability you query on and nothing more, because each capability stores an extra index term alongside the ciphertext.
+
+```sql
+CREATE TABLE users (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ email public.eql_v3_text_search, -- equality, ordering, free-text match
+ age public.eql_v3_integer_ord, -- equality, ordering
+ name text -- plaintext, untouched
+);
+```
+
+The capability model is the same one the database enforces through [EQL's domain variants](/reference/eql/core-concepts):
+
+| Domain | Enables | EQL term |
+| --- | --- | --- |
+| `public.eql_v3_` | Store and decrypt only | none |
+| `public.eql_v3__eq` | `.eq()`, `.neq()`, `.in()` | `hm` |
+| `public.eql_v3__ord` | `.gt()`, `.gte()`, `.lt()`, `.lte()`, `.order()`, plus equality | `op` |
+| `public.eql_v3_text_match` | `.matches()` | `bf` |
+| `public.eql_v3_text_search` | All of the above, on text | `hm`, `op`, `bf` |
+| `public.eql_v3_json` | Encrypted JSON documents | `sv` |
+
+## Optional schemas: types and startup verification
+
+Without `schemas`, rows are `Record` and any table name is accepted. Declare a table and `.from()` returns a typed builder for it, with row types, filter-value types, and capability-narrowed methods. Declared tables are also verified against the live database before the client is handed back, so a column that was dropped or retyped fails at startup rather than at the first query.
+
+```typescript
+import { encryptedSupabase } from "@cipherstash/stack-supabase"
+import { encryptedTable, types } from "@cipherstash/stack/eql/v3"
+
+const users = encryptedTable("users", {
+ email: types.TextSearch("email"),
+ age: types.IntegerOrd("age"),
+})
+
+const supabase = await encryptedSupabase(supabaseUrl, supabaseKey, {
+ schemas: { users },
+})
+
+// `.gt` is offered on `age`, and rejected on `email`, at compile time
+await supabase.from("users").select("id, email").gt("age", 21)
+```
+
+Each key in `schemas` must equal its table's name. Declaring a table changes what TypeScript knows about it, never how its values are encrypted: a declared and an introspected column build byte-identically.
+
+## Writing data
+
+`insert`, `update`, and `upsert` encrypt every encrypted column before the request leaves your process. All values in a batch are encrypted in a single ZeroKMS round trip, so large inserts do not multiply key-service calls.
+
+```typescript
+await supabase.from("users").insert({
+ email: "alice@example.com", // encrypted
+ age: 30, // encrypted
+ name: "Alice Chen", // plaintext column, passes through
+})
+
+await supabase.from("users").update({ age: 31 }).eq("email", "alice@example.com")
+
+await supabase.from("users").delete().eq("email", "alice@example.com")
+```
+
+`insert` and `upsert` accept a single row or an array. `upsert` supports `onConflict` and `ignoreDuplicates` as in supabase-js.
+
+## Reading data
+
+Encrypted columns in the result are decrypted before `data` is returned to you. `select('*')` and a no-argument `select()` both work: the wrapper expands them from the column list it introspected.
+
+```typescript
+const { data, error } = await supabase
+ .from("users")
+ .select("*")
+ .eq("email", "alice@example.com")
+```
+
+## Filters
+
+Every filter value on an encrypted column is encrypted client-side into the term that capability queries, then sent in place of the plaintext. The database only ever compares ciphertext. Filters on plaintext columns pass through to supabase-js unchanged.
+
+| Method | Requires | What the database compares |
+| --- | --- | --- |
+| `.eq()` / `.neq()` | `_eq`, `_ord`, or `text_search` | Equality terms |
+| `.in(col, values)` | `_eq`, `_ord`, or `text_search` | Each element encrypted separately |
+| `.gt()` `.gte()` `.lt()` `.lte()` | `_ord` or `text_search` | Ordering terms |
+| `.matches(col, value)` | `text_match` or `text_search` | Bloom-filter token containment â **unavailable through PostgREST with EQL 3.0.4, [see below](#free-text-and-json-need-a-different-adapter)** |
+| `.contains(col, value)` | `json`, or a plaintext array | Sub-document containment, not free-text. Encrypted JSON is **unavailable through PostgREST with EQL 3.0.4** |
+| `.selectorEq(col, path, value)` / `.selectorNe()` | `json` | One JSON field's value, by path â **unavailable through PostgREST with EQL 3.0.4** |
+| `.is(col, null)` | any column | Passed through. SQL `NULL` is never encrypted |
+| `.match({ col: value })` | per column | Each pair applied as `.eq()` |
+| `.or(filters)` | per column | Encrypted values inside the filter string are encrypted in place |
+| `.not(col, op, value)` | per operator | Value is encrypted, operator passes through |
+| `.filter(col, op, value)` | per operator | Escape hatch, same as `.not()` |
+
+```typescript
+// Range on an _ord column
+await supabase.from("users").select("id, age").gt("age", 21)
+
+// Encrypted and plaintext filters compose freely
+await supabase
+ .from("users")
+ .select("id, email")
+ .eq("email", "alice@example.com") // encrypted comparison
+ .eq("name", "Alice Chen") // ordinary supabase-js filter
+```
+
+### Free-text and JSON need a different adapter [#free-text-and-json-need-a-different-adapter]
+
+
+**With the current EQL 3.0.4 release, this wrapper cannot run encrypted free-text search or encrypted-JSON queries.** Every spelling raises:
+
+```
+[supabase v3]: matches() on encrypted column "email" is unavailable with EQL
+3.0.2+: the SQL operator requires an eql_v3.query_* cast that PostgREST cannot
+express. Use the Drizzle or Prisma Next adapter, or a scoped SQL/RPC function.
+```
+
+The error says `3.0.2+` because EQL 3.0.2 introduced this query-domain requirement. EQL 3.0.4 retains it.
+
+The limit is PostgREST's, not the encryption's. Those operators need a typed `eql_v3.query_*` operand, and PostgREST has no syntax for the cast â so the wrapper refuses rather than send an operand the database would compare wrongly. It fails closed on an unknown EQL version too.
+
+This applies to `.matches()`, `.contains()` on a `json` column, `.selectorEq()` / `.selectorNe()`, and the same terms reached through `.not()`, `.or()`, or a raw `.filter()`. **Equality, ordering, and range are unaffected** â `.eq()`, `.neq()`, `.in()`, `.gt()` / `.gte()` / `.lt()` / `.lte()`, and `.order()` all work, as do inserts, updates, and decryption on select.
+
+To search encrypted text with EQL 3.0.4, use the [Drizzle](/integrations/drizzle) or [Prisma Next](/integrations/prisma-next) adapter for that query, or put it behind a scoped SQL or RPC function. Everything else on this page still applies.
+
+
+On EQL 3.0.1 and earlier, `.matches()` tests whether the stored value's encrypted token set contains the search string's â case-insensitive, and false positives are possible while false negatives are not:
+
+```typescript
+await supabase.from("users").select("*").matches("email", "alice@example.com")
+```
+
+It is **not** `.contains()`. On an encrypted text column `.contains()` raises regardless of EQL version â containment promises exactness, which bloom-filter matching cannot deliver, so the wrapper refuses rather than quietly answering a different question. `.contains()` is the right method for encrypted JSON documents and plaintext arrays.
+
+### `.like()` and `.ilike()` are approximate
+
+On a plaintext column they are ordinary SQL `LIKE`, unaffected by any of the above. On an **encrypted** column they are a compatibility shim that reduces the pattern to a needle and delegates to `.matches()` â so with EQL 3.0.4 they raise for the same reason it does.
+
+Where they do run, the result is *approximate*: matching becomes case-insensitive and one-sided (it may return false positives), and anchoring is lost. Leading and trailing `%` are stripped, since fuzzy matching subsumes them. A pattern that cannot be approximated â an internal `%`, or any `_` â raises instead of guessing.
+
+```typescript
+// Reduced to matches("email", "@example.com")
+await supabase.from("users").select("*").like("email", "%@example.com")
+
+// â raises: an internal wildcard has no trigram equivalent
+await supabase.from("users").select("*").like("email", "a%e@example.com")
+```
+
+Prefer `.matches()` in new code â it is the operation that actually runs, and it does not imply SQL semantics the database is not applying.
+
+## Ordering and pagination
+
+`.order()` on an `_ord` or `text_search` column sorts on the encrypted ordering terms, which is exactly what that capability exists for. See [ordering encrypted values](/reference/eql/sorting). `.limit()`, `.range()`, `.single()`, and `.maybeSingle()` behave as in supabase-js.
+
+```typescript
+await supabase.from("users").select("id, email, age").order("age", { ascending: false }).limit(20)
+```
+
+Ordering a storage-only, equality-only, match-only, or `_ord_ore` encrypted column throws instead of sorting the raw stored payload. `.csv()` also throws because PostgREST serializes the rows before the wrapper can decrypt them; select rows normally and serialize the plaintext result in your application.
+
+## Identity and audit
+
+Two methods have no supabase-js equivalent:
+
+```typescript
+await supabase
+ .from("users")
+ .select("id, email")
+ .eq("email", "alice@example.com")
+ .withLockContext(lockContext)
+ .audit({ metadata: { reason: "support-lookup" } })
+```
+
+`.withLockContext(lockContext)` scopes every encrypt and decrypt in the query to an identity-locked context. See the [Supabase Auth integration](/integrations/supabase/auth) for wiring it to Supabase Auth sessions. `.audit(config)` attaches metadata to the query's decryption events in the [audit log](/security/audit-logging).
+
+`.abortSignal()`, `.throwOnError()`, and `.returns()` pass through as in supabase-js.
+
+## Responses and errors
+
+Awaiting the builder resolves to the supabase-js response shape, extended with encryption context:
+
+```typescript
+type EncryptedSupabaseResponse = {
+ data: T | null
+ error: EncryptedSupabaseError | null
+ count: number | null
+ status: number
+ statusText: string
+}
+
+type EncryptedSupabaseError = {
+ message: string
+ details?: string
+ hint?: string
+ code?: string
+ encryptionError?: EncryptionError // set when encrypt/decrypt failed, not the query
+}
+```
+
+Check `error.encryptionError` to distinguish an encryption failure (bad workspace credentials, unreachable ZeroKMS, a payload that fails validation) from an ordinary PostgREST error.
+
+## How it works
+
+The builder is deferred: method calls record intent, and nothing executes until you `await`. At that point the wrapper, in order:
+
+1. Encrypts mutation payloads, all rows in one ZeroKMS call
+2. Encrypts every filter value on an encrypted column into its index term
+3. Adds `::jsonb` casts to encrypted columns in the `select` list
+4. Replays the recorded calls onto the real supabase-js builder
+5. Decrypts encrypted columns in the response
+
+Because the wrapped client is your own `createClient(...)` instance, Supabase Auth sessions and **Row Level Security policies apply unchanged**. RLS decides which rows come back; encryption decides whether what is in them can be read. Database-side setup (installing EQL, column types, grants, indexes) is covered in the [Supabase overview](/integrations/supabase).
+
+## Two names, one function
+
+`encryptedSupabase` and `encryptedSupabaseV3` are the **same function**, and either import works. The `V3` suffix dates from when this package also shipped an EQL v2 dialect; now that EQL v3 is the only generation the wrapper authors, the suffix is redundant, so `encryptedSupabase` is the preferred spelling and `encryptedSupabaseV3` is a deprecated alias kept for existing imports.
+
+The type names work the same way: `EncryptedSupabaseOptions`, `EncryptedSupabaseInstance`, and `TypedEncryptedSupabaseInstance`, with their `âŠV3âŠ` forms as deprecated aliases.
diff --git a/content/docs/security/audit-logging.mdx b/content/docs/security/audit-logging.mdx
new file mode 100644
index 0000000..ae1bba0
--- /dev/null
+++ b/content/docs/security/audit-logging.mdx
@@ -0,0 +1,9 @@
+---
+title: Audit logging
+description: "Decryption-event logging: what's recorded, and how to attach query metadata."
+type: concept
+---
+
+This page is being built as part of the docs V2 overhaul ([CIP-3331](https://linear.app/cipherstash/issue/CIP-3331)). Track progress in [IA.md](https://github.com/cipherstash/docs/blob/v2/IA.md).
+
+Until it lands, the current version lives in the [existing docs](/stack/cipherstash/proxy/audit).
diff --git a/content/docs/security/cts.mdx b/content/docs/security/cts.mdx
new file mode 100644
index 0000000..23e541d
--- /dev/null
+++ b/content/docs/security/cts.mdx
@@ -0,0 +1,9 @@
+---
+title: CTS
+description: "The CipherStash Token Service: exchanging identity-provider JWTs for tokens that gate decryption."
+type: concept
+---
+
+This page is being built as part of the docs V2 overhaul ([CIP-3330](https://linear.app/cipherstash/issue/CIP-3330)). Track progress in [IA.md](https://github.com/cipherstash/docs/blob/v2/IA.md).
+
+Until it lands, the current version lives in the [existing docs](/stack/cipherstash/kms/cts).
diff --git a/content/stack/cipherstash/encryption/dynamodb.mdx b/content/stack/cipherstash/encryption/dynamodb.mdx
index 76e2f44..5b672bc 100644
--- a/content/stack/cipherstash/encryption/dynamodb.mdx
+++ b/content/stack/cipherstash/encryption/dynamodb.mdx
@@ -3,6 +3,8 @@ title: DynamoDB
description: Encrypt and decrypt DynamoDB items with the encryptedDynamoDB helper from @cipherstash/stack, including bulk operations and HMAC equality queries.
---
+import { faqPrivacy, faqKms, faqFreeTier } from "@/components/faq/shared";
+
CipherStash provides a DynamoDB integration through `@cipherstash/stack/dynamodb`. The `encryptedDynamoDB` helper encrypts items before writing to DynamoDB and decrypts them after reading â it does not wrap the AWS SDK, so you keep full control of your DynamoDB operations.
## Installation
@@ -469,3 +471,44 @@ const queryResult = await docClient.send(new QueryCommand({
const decrypted = await dynamo.bulkDecryptModels(queryResult.Items ?? [], users)
```
+
+## FAQ
+
+
+ Yes, for exact equality. Each encrypted attribute stores an{" "}
+ __hmac term you match against, so you can look up by an
+ encrypted partition key, sort key, or GSI. Range, comparison, and
+ substring queries on encrypted attributes are not supported.
+ >
+ ),
+ },
+ {
+ title: "Do I have to change how I write DynamoDB operations?",
+ answer: (
+ <>
+ No. encryptedDynamoDB encrypts items before writing and
+ decrypts them after reading; it does not wrap the AWS SDK, so you keep
+ full control of your PutItem, GetItem, and Query calls.
+ >
+ ),
+ },
+ {
+ title: "What does adopting it look like?",
+ answer: (
+ <>
+ Install @cipherstash/stack, define an encrypted schema for
+ the attributes you want to protect, initialize the client, and encrypt
+ values before writing them. You can adopt it one table at a time.
+ >
+ ),
+ },
+ faqKms,
+ faqFreeTier,
+ ]}
+/>
diff --git a/content/stack/reference/drizzle.mdx b/content/stack/reference/drizzle.mdx
index c69a68f..e50e296 100644
--- a/content/stack/reference/drizzle.mdx
+++ b/content/stack/reference/drizzle.mdx
@@ -131,7 +131,7 @@ See the [CipherStash CLI reference](/stack/cipherstash/cli) for all `db install`
## Full API surface
-The Drizzle adapter now ships as its own package, `@cipherstash/stack-drizzle`. The EQL v3 dialect is on `@cipherstash/stack-drizzle/v3`. Its surface:
+The Drizzle adapter now ships as its own package, `@cipherstash/stack-drizzle`, with the EQL v3 dialect on `@cipherstash/stack-drizzle/v3`. This page documents the older `@cipherstash/stack/drizzle` entry point; the [Drizzle integration](/integrations/drizzle) is the current, EQL v3 surface:
- `encryptedType` â column builder
- `extractEncryptionSchema` â schema conversion
diff --git a/next.config.mjs b/next.config.mjs
index 3ffa9e2..990a7b3 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -333,16 +333,6 @@ const config = {
destination: "/stack/cipherstash/kms",
permanent: false,
},
- // === Pre-publish placeholder for the v2 docs restructure ===
- // The v2 branch will host the Supabase docs at /docs/integrations/supabase,
- // but that branch isn't published yet. Until it ships, temporarily (307)
- // point the future URL at the current Supabase overview page so external
- // links to it resolve. Remove this once v2 owns /integrations/supabase.
- {
- source: "/integrations/supabase",
- destination: "/stack/cipherstash/supabase",
- permanent: false,
- },
];
},
async rewrites() {
diff --git a/scripts/fixtures/stash-manifest.json b/scripts/fixtures/stash-manifest.json
index 0de5ec4..8190378 100644
--- a/scripts/fixtures/stash-manifest.json
+++ b/scripts/fixtures/stash-manifest.json
@@ -48,10 +48,7 @@
{
"name": "plan",
"summary": "Draft a reviewable encryption plan at .cipherstash/plan.md",
- "examples": [
- "plan",
- "plan --target claude-code"
- ],
+ "examples": ["plan", "plan --target claude-code"],
"flags": [
{
"name": "--complete-rollout",
@@ -114,10 +111,7 @@
"name": "manifest",
"summary": "Print the structured, versioned command surface",
"long": "Emit the CLI command surface as data. `--json` produces the machine-\nreadable manifest the docs generator and agents consume; without it a\ngrouped command list is printed. The manifest is stamped with the CLI\nversion, so a page generated from it always names the version it describes.",
- "examples": [
- "manifest --json",
- "manifest"
- ],
+ "examples": ["manifest --json", "manifest"],
"flags": [
{
"name": "--json",
@@ -168,10 +162,7 @@
{
"name": "auth regions",
"summary": "List the regions you can authenticate against",
- "examples": [
- "auth regions",
- "auth regions --json"
- ],
+ "examples": ["auth regions", "auth regions --json"],
"flags": [
{
"name": "--json",
diff --git a/scripts/generate-cli-docs.ts b/scripts/generate-cli-docs.ts
index 89a59ce..84c7dcf 100644
--- a/scripts/generate-cli-docs.ts
+++ b/scripts/generate-cli-docs.ts
@@ -317,8 +317,7 @@ function renderIndex(
const sections = manifest.groupOrder
.filter((g) => groups.has(g))
.map((g) => {
- const rows = groups
- .get(g)!
+ const rows = (groups.get(g) ?? [])
.flatMap((base) =>
manifest.commands
.filter((c) => c.base === base)
@@ -348,9 +347,10 @@ ${sections}
function renderMeta(manifest: Manifest, groups: Map): string {
const pages: string[] = [];
for (const g of manifest.groupOrder) {
- if (!groups.has(g)) continue;
+ const groupPages = groups.get(g);
+ if (!groupPages) continue;
pages.push(`---${g}---`);
- pages.push(...groups.get(g)!);
+ pages.push(...groupPages);
}
return `${JSON.stringify({ title: "CLI", pages }, null, 2)}\n`;
}
@@ -379,8 +379,16 @@ function main() {
const groups = new Map();
for (const g of manifest.groupOrder) groups.set(g, []);
for (const base of bases) {
- const g = manifest.commands.find((c) => c.base === base)!.group;
- groups.get(g)!.push(base);
+ const command = manifest.commands.find((c) => c.base === base);
+ if (!command) throw new Error(`No command found for base "${base}".`);
+
+ const group = groups.get(command.group);
+ if (!group) {
+ throw new Error(
+ `Command "${command.path}" uses undeclared group "${command.group}".`,
+ );
+ }
+ group.push(base);
}
for (const [g, list] of groups) if (!list.length) groups.delete(g);
diff --git a/scripts/validate-content-api.ts b/scripts/validate-content-api.ts
index c6c0df2..4563c6b 100644
--- a/scripts/validate-content-api.ts
+++ b/scripts/validate-content-api.ts
@@ -57,12 +57,24 @@ type Rule = {
fix: string;
/** Only apply the rule to files under these prefixes. */
scope: string[];
+ /**
+ * Files that legitimately name the API, and are exempt from this rule. Use
+ * only when a page documents a component that really does still ship it.
+ */
+ exempt?: string[];
};
const IDENTITY_SCOPE = ["content/docs/", "content/stack/"];
/** The legacy tree documents EQL v2 throughout; only the v2 IA must be v3-only. */
const V3_SCOPE = ["content/docs/"];
+/**
+ * `@cipherstash/prisma-next` really does target EQL v2 â its migrations create
+ * `eql_v2_encrypted` columns and call `eql_v2.add_search_config`. Its page must
+ * name those to be correct. Delete this when prisma-next moves to v3.
+ */
+const PRISMA_NEXT_IS_V2 = ["content/docs/integrations/prisma-next.mdx"];
+
const RULES: Rule[] = [
{
id: "lockcontext-identify",
@@ -92,14 +104,16 @@ const RULES: Rule[] = [
id: "eql-v2-encrypted-type",
pattern: /\beql_v2_encrypted\b/,
message: "The `eql_v2_encrypted` column type was removed in EQL 3.0.0.",
- fix: "Type the column with an EQL v3 domain variant, e.g. `public.text_eq`. See /reference/eql/core-concepts.",
+ fix: "Type the column with an EQL v3 domain variant, e.g. `public.eql_v3_text_eq`. See /reference/eql/core-concepts.",
scope: V3_SCOPE,
+ exempt: PRISMA_NEXT_IS_V2,
},
{
id: "eql-v2-schema-functions",
pattern: /\beql_v2\.\w+\s*\(/,
message: "The `eql_v2` schema was removed in EQL 3.0.0.",
fix: "Use the `eql_v3` equivalents, or the encrypted operators directly with a typed operand.",
+ exempt: PRISMA_NEXT_IS_V2,
scope: V3_SCOPE,
},
{
@@ -168,6 +182,7 @@ for (const dir of ["content/docs", "content/stack"]) {
for (const rule of RULES) {
if (!inScope(relative, rule)) continue;
+ if (rule.exempt?.includes(relative)) continue;
if (rule.pattern.test(text)) {
findings.push({
file: relative,
diff --git a/src/components/code-block.tsx b/src/components/code-block.tsx
index d15fbaa..b42044c 100644
--- a/src/components/code-block.tsx
+++ b/src/components/code-block.tsx
@@ -47,6 +47,16 @@ export function TrackedCodeBlock(props: CodeBlockProps) {
return ;
}
+ return ;
+}
+
+/**
+ * Instrumented Fumadocs code block, split from the Mermaid dispatch above so
+ * every hook in this component is called unconditionally.
+ */
+function TrackedFumadocsCodeBlock(props: CodeBlockProps) {
+ const attrs = props as CodeBlockProps & TrackingProps;
+ const language = attrs["data-language"] ?? "plaintext";
const exampleId = attrs["data-example-id"];
const isCta = attrs["data-cta"] === "true";
const ctaType = attrs["data-cta-type"];
diff --git a/src/components/faq/index.tsx b/src/components/faq/index.tsx
new file mode 100644
index 0000000..ffd9ac6
--- /dev/null
+++ b/src/components/faq/index.tsx
@@ -0,0 +1,63 @@
+import { Accordion, Accordions } from "fumadocs-ui/components/accordion";
+import { isValidElement, type ReactNode } from "react";
+
+export type FaqEntry = {
+ title: string;
+ answer: ReactNode;
+};
+
+/** Flatten a ReactNode to plain text for the FAQPage JSON-LD `text` field. */
+function nodeToText(node: ReactNode): string {
+ if (node === null || node === undefined || typeof node === "boolean") {
+ return "";
+ }
+ if (typeof node === "string" || typeof node === "number") {
+ return String(node);
+ }
+ if (Array.isArray(node)) {
+ return node.map(nodeToText).join("");
+ }
+ if (isValidElement(node)) {
+ return nodeToText((node.props as { children?: ReactNode }).children);
+ }
+ return "";
+}
+
+/**
+ * Renders a list of FAQ entries as an accordion and emits FAQPage structured
+ * data (JSON-LD) so the questions can surface as rich results in search.
+ *
+ * Compose `items` from the shared, product-wide entries in `./shared` plus any
+ * integration-specific entries authored inline, keeping generic answers DRY.
+ */
+export function Faq({ items }: { items: FaqEntry[] }) {
+ const jsonLd = {
+ "@context": "https://schema.org",
+ "@type": "FAQPage",
+ mainEntity: items.map((item) => ({
+ "@type": "Question",
+ name: item.title,
+ acceptedAnswer: {
+ "@type": "Answer",
+ text: nodeToText(item.answer).replace(/\s+/g, " ").trim(),
+ },
+ })),
+ };
+
+ return (
+ <>
+
+
+ {items.map((item) => (
+
+ {item.answer}
+
+ ))}
+
+ >
+ );
+}
diff --git a/src/components/faq/shared.tsx b/src/components/faq/shared.tsx
new file mode 100644
index 0000000..5d5378a
--- /dev/null
+++ b/src/components/faq/shared.tsx
@@ -0,0 +1,70 @@
+import type { FaqEntry } from "./index";
+
+/**
+ * Product-wide FAQ answers that are not specific to any one integration.
+ * Import the entries you need into an integration overview page and mix them
+ * with integration-specific `FaqEntry` objects authored inline.
+ */
+
+export const faqPrivacy: FaqEntry = {
+ title: "Can CipherStash ever see my data, or my encryption keys?",
+ answer: (
+ <>
+ No, never. Encryption and decryption occur in your application, and keys
+ are derived in your environment. Plaintext and keys never leave your
+ control and never reach CipherStash.
+ >
+ ),
+};
+
+export const faqScaling: FaqEntry = {
+ title: "How well does it scale?",
+ answer: (
+ <>
+ Latency stays flat as data grows: exact-match lookups hold at ~0.1 ms and
+ range queries at ~0.5 ms, from 10k to 10M rows on the{" "}
+
+ cipherstash/benches
+ {" "}
+ suite.
+ >
+ ),
+};
+
+export const faqKms: FaqEntry = {
+ title: "Do I need to run a KMS or key vault?",
+ answer: (
+ <>
+ No. Key management is built in through ZeroKMS. If you want to control the
+ root key, Bring Your Own Key (BYOK) lets you root it in your own KMS.
+ >
+ ),
+};
+
+export const faqRlsVsEncryption: FaqEntry = {
+ title: "I already use Row Level Security. Do I need this?",
+ answer: (
+ <>
+ RLS and CipherStash do different things, and they work best together. RLS
+ controls which rows someone can see, but the data itself is still
+ unencrypted, so if RLS is bypassed (a leaked key, a wrong policy, a
+ compromised database) the plaintext is exposed. CipherStash encrypts the
+ data and stores the keys separately, so even if someone gets around RLS,
+ the data stays safe. Use RLS to control access, and CipherStash to keep
+ the data protected.
+ >
+ ),
+};
+
+export const faqFreeTier: FaqEntry = {
+ title: "Is there a free tier?",
+ answer: (
+ <>
+ Yes, a free developer tier, so you can build encryption in from day one.
+ >
+ ),
+};
diff --git a/src/mdx-components.tsx b/src/mdx-components.tsx
index e8b9bd4..66ac189 100644
--- a/src/mdx-components.tsx
+++ b/src/mdx-components.tsx
@@ -7,6 +7,7 @@ import { BadExample } from "@/components/bad-example";
import { TrackedCodeBlock } from "@/components/code-block";
import { EqlFn } from "@/components/eql-fn";
import { EqlVersion } from "@/components/eql-version";
+import { Faq } from "@/components/faq";
import { ZeroKmsRegions } from "@/components/zerokms-regions";
/**
@@ -50,6 +51,7 @@ export function getMDXComponents(components?: MDXComponents): MDXComponents {
EqlVersion,
EqlFn,
ZeroKmsRegions,
+ Faq,
...components,
};
}