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/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..df00457 --- /dev/null +++ b/content/docs/guides/migration/encrypt-existing-data.mdx @@ -0,0 +1,9 @@ +--- +title: Encrypt existing data +description: "Backfilling encryption onto live tables, column by column." +type: guide +--- + +This page is being built as part of the docs V2 overhaul ([CIP-3329](https://linear.app/cipherstash/issue/CIP-3329)). 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/encrypt-tool). 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..f60ea3e 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. + + + + `encryptedSupabaseV3` 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..ecbaff7 --- /dev/null +++ b/content/docs/integrations/supabase/auth.mdx @@ -0,0 +1,137 @@ +--- +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-rc.0" + auth: "0.41.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 dashboard's [Supabase integration](/integrations/supabase#connecting-your-project-to-cipherstash) can register this for you in one click while it's connected to your 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 { encryptedSupabaseV3 } 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 +} + +export const db = await encryptedSupabaseV3(supabaseClient, { + config: { + authStrategy: OidcFederationStrategy.create(process.env.CS_WORKSPACE_CRN!, getJwt), + }, +}) +``` + +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 [`encryptedSupabaseV3`](/reference/stack/supabase) query with `.withLockContext()`. The query builder takes a `LockContext` **instance**. `sub` is the Supabase user's UUID: + +```typescript +import { LockContext } from "@cipherstash/stack/identity" +import { db } from "./lib/db" + +const lockContext = new LockContext({ context: { 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's `.withLockContext()` takes a `LockContext` instance. The core `Encryption` client's operations take a plain object instead, as `.withLockContext({ identityClaim: ["sub"] })`. The two surfaces differ. + + +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..240f52a --- /dev/null +++ b/content/docs/integrations/supabase/edge-functions.mdx @@ -0,0 +1,131 @@ +--- +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-rc.0" + auth: "0.41.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" + } +} +``` + +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 encryptedSupabaseV3 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..046aac1 100644 --- a/content/docs/integrations/supabase/index.mdx +++ b/content/docs/integrations/supabase/index.mdx @@ -1,20 +1,249 @@ --- -title: Supabase -description: "Searchable, application-level encryption for your Supabase project — encrypt in your app, query in Postgres." -type: tutorial +title: Overview +description: "Searchable, application-level encryption for your Supabase project: what it gives you, how EQL installs into Supabase Postgres, and how to operate encrypted columns across grants, indexes, RLS, and the dashboard." +type: concept components: [encryption, eql, platform] -audience: [developer] +audience: [developer, ciso] integration: category: platform setup: dashboard-required - pairsWith: [drizzle, prisma-next, clerk, nextjs] + pairsWith: [drizzle, prisma-next, clerk] +verifiedAgainst: + eql: "3.0.0" + stack: "1.0.0-rc.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. +import { faqPrivacy, faqScaling, faqKms, faqRlsVsEncryption, faqFreeTier } from "@/components/faq/shared"; -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). +CipherStash adds application-level encryption to your Supabase project. Sensitive fields (names, emails, health records, anything you choose) are encrypted inside your application before they ever reach Postgres, so plaintext never touches your database, its backups, or its replication streams. CipherStash never sees your data or your keys. + +Unlike ordinary field encryption, the ciphertext stays **fully queryable**. You install [Encrypt Query Language (EQL)](/reference/eql) on your Supabase database, declare each protected column with the encrypted type that matches the operations you need, then query it with the same `.eq()`, `.gt()`, and `.order()` calls you use today. + +If you want the fastest path from an empty project to an encrypted table, start with the [Quickstart](/integrations/supabase/quickstart) — about fifteen minutes. This page is the reference behind it: how EQL installs and what Supabase's roles need from it, choosing and indexing column types, how encryption composes with Row Level Security, and what encrypted data looks like across the dashboard. + +## How it works + +Encrypted search is built on [EQL](/reference/eql), which installs into any Supabase database as plain SQL. Like regular Postgres, EQL provides a set of data types and functions, but designed for working with encrypted data. + +Each protected column is declared with an EQL domain type whose name states what the column can do. The wrapper reads that type straight from the database, so there is no second declaration to keep in sync. The full type catalogue lives in the EQL reference — [numbers](/reference/eql/numbers), [dates & times](/reference/eql/dates-and-times), [text](/reference/eql/text), [JSON](/reference/eql/json), [booleans](/reference/eql/booleans) — but the shape is always the same: + +| You query it with | Type the column as | EQL term | +| --- | --- | --- | +| Nothing (store/decrypt only) | `public.eql_v3_` | none | +| `.eq()` / `.in()` | `public.eql_v3__eq` | `hm` | +| Ranges, `.order()` | `public.eql_v3__ord` | `op` | +| Free-text match | `public.eql_v3_text_match` | `bf` | +| All three (text) | `public.eql_v3_text_search` | `hm`, `op`, `bf` | +| Encrypted JSON | `public.eql_v3_json` | `sv` | + +Declare only the capabilities you use: every extra capability stores extra searchable material with defined leakage — see [Searchable encryption](/concepts/searchable-encryption). + +Those are the column's capabilities in the database. Two of them are not reachable through supabase-js on EQL 3.0.2+ — free-text match and encrypted JSON both need an operand cast PostgREST cannot express — so if you plan to query either, read [that constraint](/reference/stack/supabase#free-text-and-json-need-a-different-adapter) before choosing the type. Equality and ordering are unaffected. + +## Installing EQL + +The `stash` CLI installs EQL. It reads `DATABASE_URL`, detects that it points at Supabase, and installs directly against the database: + +```bash example-id="supabase-eql-install" +npx stash eql install +``` + +Pass `--database-url ` to override the connection for a single run without writing it to disk. The install is idempotent, so `--force` reinstalls over an existing schema, and `--dry-run` shows what would happen. Check what you ended up with: + +```bash +npx stash eql status +``` + +The install is **always EQL v3**. The full flag set is in the [CLI reference](/reference/cli/eql). + + +**EQL installs directly against the database, not through a migration.** `stash eql install` rejects `--migration`, `--migrations-dir`, and `--latest`, and there is no Supabase migration variant to fall back on — one v3 SQL artifact targets every platform. + +The practical consequence is local resets. `supabase db reset` rebuilds your database from your migrations directory, so it drops the EQL install. Re-run `stash eql install` after each reset. + +`--drizzle` is rejected here too, but has its own answer: `stash eql migration --drizzle` generates a v3 install migration — including the `cs_migrations` tracking schema — so Drizzle projects can keep the install in their migration history and apply it with `drizzle-kit migrate`. That path is Drizzle's alone; it does not produce a `supabase/migrations/` file. + + +## Grants + +Supabase's API roles need access to the `eql_v3` schema, because your table columns are typed with its domains and your queries resolve its operators and functions: + +```sql +GRANT USAGE ON SCHEMA eql_v3 TO anon, authenticated, service_role; +GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA eql_v3 TO anon, authenticated, service_role; +``` + +Run this once after install (and after upgrades that add functions). Without `USAGE`, PostgREST queries against tables with encrypted columns fail with a permission error on the schema. + +You do **not** need to add `eql_v3` to the *Exposed schemas* list in the [API settings](https://supabase.com/dashboard/project/_/settings/api) for normal queries — your tables live in `public`. Expose it only if you want to call EQL functions directly over the REST API. + +## Indexes + +Encrypted columns index through **ordinary functional indexes** over EQL's term-extractor functions. This is why EQL works on Supabase unchanged: the default ordering term indexes under Postgres's own `bytea_ops`, so no custom operator class is needed, just `CREATE INDEX`, which any migration can run: + +```sql +CREATE INDEX patients_email_eq ON patients USING hash (eql_v3.eq_term(email)); +CREATE INDEX patients_dob_ord ON patients USING btree (eql_v3.ord_term(date_of_birth)); +CREATE INDEX patients_name_match ON patients USING gin (eql_v3.match_term(name)); +ANALYZE patients; +``` + +Add them once a table has real row counts (thousands, not dozens). Index selection, `EXPLAIN` verification, and large-table build guidance are in [EQL indexes](/reference/eql/indexes) — all of it applies to Supabase verbatim. + + +Creating a custom operator class needs superuser, which Supabase does not grant. The EQL installer detects this and **disables the ORE-backed domains** (`_ord_ore`, `text_search_ore`), which then raise `feature_not_supported` on the first value written. Use `_ord` and `text_search`, whose CLLW OPE ordering term indexes under the default btree operator class. See [SEM specifiers](/reference/eql/core-concepts#sem-specifiers). + + +## Row Level Security + +Encrypted columns and RLS compose — they solve different problems: + +- **RLS** decides *which rows* a role may query. It's enforced in the database, on whatever the database stores. +- **Encryption** decides *whether a value can be read at all*. Keys stay outside the database, so anything that bypasses RLS — a leaked `service_role` key, an over-broad policy, a stolen backup, direct disk access — yields ciphertext, not plaintext. + +Run both. Keep writing policies exactly as before; the `encryptedSupabaseV3` wrapper goes through your own supabase-js client, so the caller's JWT and your policies apply to every query. Policies can freely reference *non-encrypted* columns (`user_id`, `tenant_id`, timestamps). Policies **cannot usefully compare encrypted values to plaintext** — the database never sees plaintext, so keep RLS predicates on plaintext columns and encrypted-value filtering in your queries. + +One practical pattern: leave your row-ownership predicate on a plaintext `user_id uuid` column, and encrypt the *contents* of the row. RLS scopes the rows to `auth.uid()`; CipherStash makes their contents unreadable to everything except your app. To go one step further and make a row's contents decryptable *only* by its owner — even to your own service role — see [Supabase Auth](/integrations/supabase/auth). + +## Connections and pooling + +The `encryptedSupabaseV3` wrapper talks to Supabase over PostgREST (your existing `SUPABASE_URL`), so connection pooling concerns don't change — nothing about encryption touches the connection layer. It does open one direct Postgres connection at startup, over `DATABASE_URL`, to introspect your encrypted columns. If you also connect to the database directly (migrations, [CipherStash Proxy](/reference/proxy), or an ORM like [Drizzle](/integrations/drizzle)), use the same connection strings you'd use without encryption; EQL is passive SQL and has no session requirements. The direct-connection path is covered in [Quickstart → Using an ORM instead](/integrations/supabase/quickstart#using-an-orm-instead). + +## Working in the dashboard + +Encrypted columns live inside your normal Supabase dashboard — no separate tool. Here's what to expect in each part of it. + +### Table Editor + +Encrypted columns show **ciphertext payloads**, not plaintext. A `patients` row looks like: + +| id | email | name | plan | +| --- | --- | --- | --- | +| 1 | `{"v": 3, "i": {"t": "patients", "c": "email"}, "c": "mBbKmsMM%bK#
", "hm": "9c8ec1d2
"}` | `{"v": 3, "i": 
, "c": "x7Qq
", "bf": [42, 1290, 
]}` | `standard` | + +This is the point, not a limitation: what the Table Editor shows is exactly what a backup, a replication stream, a leaked `service_role` key, or a curious operator sees. The payload structure (`v`, `i`, `c`, and the index terms) is documented in [EQL core concepts](/reference/eql/core-concepts) — none of it reveals the plaintext. + +Practical consequences: + +- **Reading data** happens through your app (or any client using the [Stack SDK](/reference/stack)); the dashboard can't decrypt, because the keys never go near it. +- **Hand-editing an encrypted cell** in the Table Editor will produce a value that fails the column's `CHECK` constraint or fails to decrypt — treat encrypted columns as read-only in the dashboard. +- **Filtering and sorting** on encrypted columns in the Table Editor UI operates on the raw payloads, so it won't return meaningful results. Query through your app instead. + +### Creating encrypted columns + +Use the **SQL Editor** for schema work on encrypted columns: + +```sql +ALTER TABLE patients ADD COLUMN tax_id public.eql_v3_text_eq; +``` + +The `eql_v3_*` domain types are user-defined types, and the Table Editor's column-type picker doesn't reliably surface custom domains — SQL is the dependable path, and it's what your migrations should contain anyway. + +### SQL Editor + +Everything in the [EQL reference](/reference/eql) can be run from the SQL Editor — installing EQL, creating indexes, `EXPLAIN`-ing query plans. Two checks worth knowing: + +```sql +-- Is EQL installed, and which version? +SELECT eql_v3.version(); + +-- Which columns are encrypted? +SELECT table_name, column_name, udt_name +FROM information_schema.columns +WHERE udt_schema = 'eql_v3'; +``` + +### API settings + +Normal operation needs **no** API-settings changes: your tables are in the `public` schema, and the wrapper's queries are ordinary PostgREST calls. The `eql_v3` schema needs role *grants* (see [Grants](#grants) above), but does **not** need to be in the *Exposed schemas* list unless you want to call EQL functions directly over REST. + +## Connecting your project to CipherStash + +Connecting is optional — you can set the `CS_*` credentials by hand ([Quickstart](/integrations/supabase/quickstart#install-the-sdk-and-connect-your-workspace)). The CipherStash dashboard's first-class Supabase integration at [dashboard.cipherstash.com](https://dashboard.cipherstash.com) automates it: + + + +### Connect via OAuth + +Authorize CipherStash against your Supabase organization. The integration requests read-only scopes (`projects:read`, `database:read`) — it never sees your database secrets. + + +### Pick a project and run the readiness checks + +The setup hub verifies EQL is installed (`eql_v3.version()`), checks for encrypted columns, and flags anything missing. + + +### Configure identity (optional) + +One click registers your project's Supabase Auth issuer (`https://.supabase.co/auth/v1`) with your workspace, enabling [identity-aware encryption](/integrations/supabase/auth). + + +### Copy your environment + +The hub emits the `CS_*` credentials block for your app's environment, ready to pair with your existing `SUPABASE_URL` and keys. + + + +## The rest of this section + +- **[Quickstart](/integrations/supabase/quickstart):** the end-to-end walkthrough — install EQL, wrap your client, query encrypted data — plus the direct-connection path if you use Drizzle or Prisma. +- **[Supabase Auth](/integrations/supabase/auth):** federate the signed-in session so encryption authenticates as the user, and optionally lock decryption to their identity. +- **[Edge Functions](/integrations/supabase/edge-functions):** run encryption server-side through the WebAssembly build. +- **[Key management and audit logging](/security/audit-logging):** per-value keys and rotation through CipherStash ZeroKMS, a decryption access trail out of the box, optional Bring Your Own Key, and FIPS and SOC 2 compliance. + +## FAQ + + + Yes. It integrates with Supabase Auth and runs alongside RLS. It + complements them; it does not replace them. + + ), + }, + faqRlsVsEncryption, + { + title: "Do I have to change how I write queries?", + answer: ( + <> + No. Query encrypted columns with the same Supabase.js calls you use + today. + + ), + }, + { + title: "What does migration look like?", + answer: ( + <> + Install EQL on your Supabase database, choose which columns to protect, + then set their encryption type. Encrypt values in your app before + saving them, and update columns one at a time. + + ), + }, + faqKms, + faqScaling, + faqFreeTier, + ]} +/> + +## Where to next + + + + The end-to-end walkthrough: install EQL, wrap your client, query encrypted data. + + + Federate the Supabase session and lock decryption to the authenticated user. + + + Run encryption server-side in Supabase Edge Functions. + + + The complete wrapper API: every filter, response shapes, and how it works. + + diff --git a/content/docs/integrations/supabase/meta.json b/content/docs/integrations/supabase/meta.json index b4690bf..0312a00 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", "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..7134390 --- /dev/null +++ b/content/docs/integrations/supabase/quickstart.mdx @@ -0,0 +1,194 @@ +--- +title: Quickstart +description: "Take an empty Supabase project to an encrypted, searchable table: install EQL, declare encrypted columns, wrap your Supabase client, and query with the calls you already use." +type: tutorial +components: [encryption, eql, platform] +audience: [developer] +integration: + category: platform + setup: dashboard-required + pairsWith: [drizzle, prisma-next, clerk] +verifiedAgainst: + eql: "3.0.0" + stack: "1.0.0-rc.0" +--- + +This tutorial takes an empty Supabase project to an encrypted, searchable table. It takes about fifteen minutes. For what CipherStash is and why you'd add it, start with the [overview](/integrations/supabase). + +## How it fits together + +Two pieces, one on each side of your Supabase connection: + +- **[EQL](/reference/eql)** installs into your Supabase Postgres as plain SQL. It provides encrypted column types, such as `public.eql_v3_text_eq` and `public.eql_v3_date_ord`, where the type name declares what queries the column supports. +- **[`@cipherstash/stack`](/reference/stack)** runs in your application. Its [`encryptedSupabaseV3` wrapper](/reference/stack/supabase) encrypts values before they're written, encrypts filter terms before they're compared, and decrypts results. It wraps your existing supabase-js client, so Supabase Auth and Row Level Security apply unchanged. + + + + +### Install EQL on your database + +Point `DATABASE_URL` at your Supabase project, then install EQL with the `stash` CLI: + +```bash cta cta-type="install" example-id="supabase-quickstart-eql-install" +npx stash eql install +``` + +It detects Supabase from the connection string, needs no superuser, and completes in seconds, creating the `eql_v3` schema for functions and operators, and the encrypted column types in `public`. Then grant your Supabase roles access: + +```sql +GRANT USAGE ON SCHEMA eql_v3 TO anon, authenticated, service_role; +GRANT EXECUTE ON ALL FUNCTIONS IN SCHEMA eql_v3 TO anon, authenticated, service_role; +``` + +The install targets EQL v3 by default. The [overview](/integrations/supabase#installing-eql) covers the rest, including why a v3 install does not survive `supabase db reset`. + + + + +### Create a table with encrypted columns + +The column type declares what each column can do. Equality lookups, free-text matching, and range or ordering queries are different capabilities. Declare only what you query, because each capability stores an extra index term alongside the ciphertext: + +```sql +CREATE TABLE patients ( + id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + email public.eql_v3_text_eq NOT NULL, -- exact lookup + name public.eql_v3_text_match, -- free-text match + date_of_birth public.eql_v3_date_ord, -- ranges and ORDER BY + plan text -- not sensitive: ordinary column +); +``` + +Which type fits which column, and what each stores, is covered by the [EQL type pages](/reference/eql/core-concepts). + + +Supabase runs the EQL installer as a non-superuser, so the ORE-backed domains (`_ord_ore`, `text_search_ore`) are disabled on Supabase and raise if you use them. Use `_ord` and `text_search`, which order under Postgres's default btree operator class. See [SEM specifiers](/reference/eql/core-concepts#sem-specifiers). + + + + + +### Install the SDK and connect your workspace + +```sh +npm install @cipherstash/stack @cipherstash/stack-supabase @supabase/supabase-js +``` + +Sign up at [dashboard.cipherstash.com](https://dashboard.cipherstash.com), create a workspace, and set its credentials alongside your Supabase keys. `DATABASE_URL` is your project's Postgres connection string, which the wrapper uses to introspect your encrypted columns: + +```sh +CS_WORKSPACE_CRN=... +CS_CLIENT_ID=... +CS_CLIENT_KEY=... +CS_CLIENT_ACCESS_KEY=... +SUPABASE_URL=https://.supabase.co +SUPABASE_ANON_KEY=... +DATABASE_URL=postgresql://postgres:...@db..supabase.co:5432/postgres +``` + +> **Good to know**: the CipherStash dashboard's Supabase integration can generate this whole block for you. It connects to your project over OAuth, runs readiness checks, and emits the `CS_*` credentials. It's optional; the manual block above works everywhere. See [Overview → Connecting your project](/integrations/supabase#connecting-your-project-to-cipherstash). + + + + +### Wrap your Supabase client + +There is no schema to declare. `encryptedSupabaseV3` introspects the database over `DATABASE_URL`, finds the columns typed as EQL domains, and derives each one's encryption config from its type. + +```typescript title="lib/db.ts" +import { encryptedSupabaseV3 } from "@cipherstash/stack-supabase" + +export const db = await encryptedSupabaseV3( + process.env.SUPABASE_URL!, + process.env.SUPABASE_ANON_KEY!, +) +``` + + + + +### Write and query encrypted data + +```typescript +import { db } from "./lib/db" + +// Insert — encrypted columns are encrypted before the request leaves your app +await db.from("patients").insert({ + email: "alice@example.com", + name: "Alice Chen", + date_of_birth: "1987-04-12", + plan: "standard", +}) + +// Exact lookup on an encrypted column +const { data } = await db + .from("patients") + .select("id, email, name") + .eq("email", "alice@example.com") + +// Range and newest-first on an encrypted date +await db + .from("patients") + .select("id, name, date_of_birth") + .gt("date_of_birth", "1980-01-01") + .order("date_of_birth", { ascending: false }) + .limit(20) +``` + + +**Free-text search is not available through this wrapper on EQL 3.0.2+.** `.matches()` — and `.like()` / `.ilike()`, which delegate to it — raise, because the operator needs an `eql_v3.query_*` operand that PostgREST cannot express. Encrypted-JSON queries are unavailable for the same reason. Equality, ordering, and range all work, as shown above. For encrypted free-text, use the [Drizzle](/integrations/drizzle) or [Prisma Next](/integrations/prisma-next) adapter for that query, or a scoped SQL/RPC function. Full detail in the [wrapper reference](/reference/stack/supabase#free-text-and-json-need-a-different-adapter). + + +The full method surface, including filters, upserts, error shapes, and identity-locked queries, is in the [`encryptedSupabaseV3` reference](/reference/stack/supabase). + + + + +### See what the database sees + +Open the Table Editor and look at the `patients` rows: every encrypted cell is a ciphertext payload, not plaintext. That's what a backup, a replication stream, or anyone bypassing your app sees, including a leaked `service_role` key. [Overview → Working in the dashboard](/integrations/supabase#working-in-the-dashboard) walks through what encrypted data looks like across the Supabase dashboard. + + + + +## Using an ORM instead + +Everything above goes through PostgREST, Supabase's REST API, with `encryptedSupabaseV3` encrypting around each request. The other way to reach the same tables is a direct Postgres connection from an ORM, where the [Stack SDK](/reference/stack) encrypts values in your application and the EQL-typed columns store and search them. Both paths write the **same ciphertext into the same columns**, so the database setup above is identical either way — and you can mix them, using an `encryptedSupabaseV3` client for application queries and Drizzle for migrations against the same tables. + + + + Encrypted column types, query operators, and EQL migrations with Drizzle. + + + Encrypted fields and searchable queries with Prisma. + + + +Both integrations are database-agnostic — Supabase is Postgres, so the encryption mechanics are the same as anywhere else. Only two things are Supabase-specific: + +- **Which connection string.** Use the **direct connection** (port `5432`) for migrations and long-running servers, and the **Supavisor transaction pooler** (port `6543`) for serverless, edge, and Next.js Route Handlers, where connections are short-lived and numerous. Encryption doesn't change the choice: EQL is passive SQL, so use the same connection string you'd use without CipherStash. Run EQL migrations over the direct connection — DDL and the install script want a real session. +- **RLS stops applying for free.** The PostgREST path carries the end user's JWT, so `auth.uid()` and your policies apply automatically. A direct connection typically authenticates as a privileged role (`postgres` or `service_role`), which **bypasses RLS**. If you rely on RLS for row scoping, either connect as the `authenticated` role with the user's JWT (setting `request.jwt.claims`), or enforce that scoping in your ORM query. Encryption is unaffected either way: values are ciphertext regardless of which role reads them. + +## Production checklist + +- **Indexes** — at real row counts, add functional indexes for the capabilities you query. Two lines of SQL per column: [EQL indexes](/reference/eql/indexes). +- **RLS** — keep your Row Level Security policies; they compose. RLS controls *who can query* rows, encryption controls *whether their contents can be read at all*. A bypassed policy or leaked key yields only ciphertext. +- **Identity-locked decryption** — tie decryption to the logged-in Supabase Auth user: [Supabase Auth integration](/integrations/supabase/auth). +- **Adopt incrementally** — you can encrypt one column at a time; nothing requires a big-bang migration. See [encrypting existing data](/guides/migration/encrypt-existing-data). + +## Go deeper + + + + Installing EQL, grants, column types, indexes, RLS, and the dashboard experience in detail. + + + Federate the Supabase session and lock decryption to the authenticated user. + + + Full indexing recipes and EXPLAIN verification. + + + The complete wrapper API: every filter, response shapes, and how it works. + + 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..544812e --- /dev/null +++ b/content/docs/reference/stack/supabase.mdx @@ -0,0 +1,289 @@ +--- +title: Supabase wrapper +description: "The canonical encryptedSupabaseV3 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-rc.0" + eql: "3.0.0" +--- + +`encryptedSupabaseV3` 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 + +`encryptedSupabaseV3` **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 { encryptedSupabaseV3 } from "@cipherstash/stack-supabase" + +const supabase = await encryptedSupabaseV3( + 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 encryptedSupabaseV3(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 encryptedSupabaseV3(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 { encryptedSupabaseV3 } 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 encryptedSupabaseV3(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 on EQL 3.0.2+, [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 on EQL 3.0.2+** | +| `.selectorEq(col, path, value)` / `.selectorNe()` | `json` | One JSON field's value, by path — **unavailable on EQL 3.0.2+** | +| `.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] + + +**On EQL 3.0.2 and later, 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 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 on EQL 3.0.2+, 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 on EQL 3.0.2+ 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()`, `.maybeSingle()`, and `.csv()` behave as in supabase-js. + +```typescript +await supabase.from("users").select("id, email, age").order("age", { ascending: false }).limit(20) +``` + +Ordering a column without an ordering term does not error. It sorts on the raw stored payload, which is meaningless. + +## 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/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/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 ( + <> +