From 6353ba85f0bbe45724e0fc4ed1b3391ab3941892 Mon Sep 17 00:00:00 2001
From: Dan Draper
Date: Sun, 28 Jun 2026 21:19:24 +1000
Subject: [PATCH 01/18] docs: refresh root README around the three Stack
pillars
- Lead with a searchable-encryption value prop and zero-knowledge trust line
- Account-first quick start; show encrypt + search-without-decrypt + decrypt
- Cover all three pillars early: searchable encryption, ORM integrations
(Supabase/Drizzle/Prisma Next/DynamoDB), identity-aware encryption
- Add a 'How it works' section linking the security architecture docs
- Use reference-style links with all URLs centralised at the bottom
(CipherStash links carry README UTM params)
- Mark @cipherstash/protect (Protect.js) as legacy in its README
- Spec the architecture diagram + type-safety GIF in docs/plans
---
README.md | 225 +++++++++++++++++++++--------
docs/plans/readme-visual-assets.md | 136 +++++++++++++++++
2 files changed, 299 insertions(+), 62 deletions(-)
create mode 100644 docs/plans/readme-visual-assets.md
diff --git a/README.md b/README.md
index 489c7f83a..ff245590c 100644
--- a/README.md
+++ b/README.md
@@ -1,101 +1,202 @@
Field-level encryption for TypeScript apps — search encrypted data without decrypting it, with
+ zero-knowledge key management. Every value gets its own key, and your keys never leave your AWS KMS.
+
+
+
+
+
+
+
+
⭐ Star this repo if encryption you can actually query is your thing!
-## What is the stack?
+
+
+> **CipherStash never sees your plaintext.** Data is encrypted in your app with a unique key per value via
+> [ZeroKMS][zerokms], rooted in your own [AWS KMS][aws-kms] — so a database breach leaks only ciphertext.
+> [See the security architecture →][security-architecture]
+
+## Quick start
+
+You'll need a free CipherStash account to provision keys and a workspace — it takes about a minute.
-- [Encryption](https://cipherstash.com/docs/stack/cipherstash/encryption): Field-level encryption for TypeScript apps with searchable encrypted queries, zero-knowledge key management, and first-class ORM support.
+**1. Create a free account** → **[cipherstash.com/signup][signup]**
-## Quick look at the stack in action
+**2. Initialize your project** — the wizard authenticates you, builds an encryption schema, and wires up your database:
-**Encryption**
+```bash
+npx stash init
+```
+
+**3. Encrypt, search, and decrypt:**
```typescript
-import { Encryption, encryptedTable, types } from "@cipherstash/stack/v3";
+import { Encryption } from "@cipherstash/stack";
+import { encryptedTable, encryptedColumn } from "@cipherstash/stack/schema";
-// 1. Define your schema — the column type fixes its query capabilities
+// Define which columns are encrypted — and how you want to query them
const users = encryptedTable("users", {
email: types.TextSearch("email"), // equality + order/range + free-text search
});
-// 2. Initialize the client
const client = await Encryption({ schemas: [users] });
-// 3. Encrypt
-const encryptResult = await client.encrypt("secret@example.com", {
- column: users.email,
- table: users,
+// Encrypt → store the ciphertext in your own database
+const enc = await client.encrypt("alice@example.com", { table: users, column: users.email });
+
+// Search WITHOUT decrypting — the part nobody else does
+const term = await client.encryptQuery("alice@example.com", {
+ table: users, column: users.email, queryType: "equality",
});
-if (encryptResult.failure) {
- // Handle errors your way
-}
-
-// 4. Decrypt
-const decryptResult = await client.decrypt(encryptResult.data);
-if (decryptResult.failure) {
- // Handle errors your way
-}
-// decryptResult.data => "secret@example.com"
+// → drop term.data straight into your WHERE clause
+
+// Decrypt when you need the plaintext back
+const dec = await client.decrypt(enc.data);
```
-## Install
+Prefer the long version? Follow the **[5-minute quickstart →][quickstart]**
-```bash
-npm install @cipherstash/stack
-# or
-yarn add @cipherstash/stack
-# or
-pnpm add @cipherstash/stack
-# or
-bun add @cipherstash/stack
+## What's in the Stack
+
+Three building blocks for protecting sensitive data in TypeScript apps — use one, or all three together.
+
+### 🔐 Searchable encryption
+
+Encrypt individual fields and still run real queries against them — exact match, full-text search,
+range/sorting, and encrypted JSONB — all on ciphertext, in PostgreSQL.
+
+```typescript
+const users = encryptedTable("users", {
+ email: encryptedColumn("email").equality().freeTextSearch().orderAndRange(),
+ metadata: encryptedColumn("metadata").searchableJson(), // encrypted JSONB queries
+});
```
-> [!IMPORTANT]
-> **You need to opt out of bundling when using `@cipherstash/stack`.**
-> It uses Node.js specific features and requires the native Node.js `require`.
-> Read more about bundling in the [documentation](https://cipherstash.com/docs/stack/deploy/bundling).
+→ [Searchable encryption][searchable-encryption] · [Schema][schema] · [Encrypt & decrypt][encrypt-decrypt] · [Bulk & model operations][model-ops]
+
+### 🔗 ORM & database integrations
+
+Drop encryption into the stack you already use. Type-safe operators let you query encrypted columns
+exactly like normal ones.
+
+| Integration | Status | Guide |
+|---|---|---|
+| PostgreSQL (raw SQL) | ✅ | [Docs][encryption] |
+| Supabase | ✅ | [Docs][supabase] |
+| Drizzle ORM | ✅ | [Docs][drizzle] |
+| Prisma (Prisma Next) | ✅ | [Docs][prisma-next] |
+| DynamoDB | ✅ | [Docs][dynamodb] |
+
+```typescript
+// Drizzle: query encrypted columns with auto-encrypting operators
+const results = await db.select().from(usersTable)
+ .where(await ops.eq(usersTable.email, "alice@example.com"));
+```
+
+### 👤 Identity-aware encryption
-## Features
+Bind decryption to a user's identity so only *that* user can read their data — a valid JWT from your
+identity provider is required to decrypt. Clerk ships a drop-in Next.js middleware today; any OIDC
+provider works through the `LockContext` primitive.
-- **[Searchable encryption](https://cipherstash.com/docs/stack/cipherstash/encryption/searchable-encryption)**: query encrypted data with equality, free text search, range, and [JSONB queries](https://cipherstash.com/docs/stack/cipherstash/encryption/searchable-encryption#jsonb-queries-with-searchablejson).
-- **[Type-safe schema](https://cipherstash.com/docs/stack/cipherstash/encryption/schema)**: define encrypted tables and columns with `encryptedTable` and the `types.*` concrete-domain factories
-- **[Model & bulk operations](https://cipherstash.com/docs/stack/cipherstash/encryption/encrypt-decrypt#model-operations)**: encrypt and decrypt entire objects or batches with `encryptModel` / `bulkEncryptModels`.
-- **[Identity-aware encryption](https://cipherstash.com/docs/stack/cipherstash/encryption/identity)**: authenticate as the end user with `OidcFederationStrategy` and bind the data key to their identity with `.withLockContext({ identityClaim })` for policy-based access control.
+| Provider | Support |
+|---|---|
+| Clerk (Next.js middleware) | ✅ Drop-in |
+| Any OIDC provider (Auth0, Okta, Supabase Auth, …) | ✅ via JWT / `LockContext` |
-## Integrations
+```typescript
+import { LockContext } from "@cipherstash/stack/identity";
-- [Encryption + Drizzle](https://cipherstash.com/docs/stack/cipherstash/encryption/drizzle)
-- [Encryption + Supabase](https://cipherstash.com/docs/stack/cipherstash/encryption/supabase)
-- [Encryption + DynamoDB](https://cipherstash.com/docs/stack/cipherstash/encryption/dynamodb)
+const lc = await new LockContext().identify(userJwt);
+const enc = await client.encrypt("ssn", { table: users, column: users.ssn })
+ .withLockContext(lc.data);
+```
-## Use cases
+→ [Identity-aware encryption][identity]
-- **Trusted data access**: ensure only your end-users can access their sensitive data using identity-bound encryption
-- **Reduce breach impact**: limit the blast radius of exploited vulnerabilities to only the data the affected user can decrypt
+> The Stack also ships a `stash` CLI for auth, schema, and database setup. See the [SDK reference][reference].
-## Documentation
+## How it works
-- [Documentation](https://cipherstash.com/docs)
-- [Quickstart](https://cipherstash.com/docs/stack/quickstart)
-- [SDK and API reference](https://cipherstash.com/docs/stack/reference)
+Encryption happens in your application. Ciphertext is stored as an [EQL][eql] JSON payload in your database;
+plaintext and root keys never reach CipherStash. Per-value keys are issued in bulk by ZeroKMS (so millions
+of unique keys stay fast), and every decryption is logged for compliance.
-## Contributing
+→ [Security architecture][security-architecture] · [ZeroKMS][zerokms]
-Contributions are welcome and highly appreciated. However, before you jump right into it, we would like you to review our [Contribution Guidelines](CONTRIBUTE.md) to make sure you have a smooth experience contributing.
+## Why CipherStash
-## Security
+- **Trusted data access** — only your end-users can access their sensitive data, enforced cryptographically.
+- **Shrink the blast radius** — a breached vulnerability exposes only what one user can decrypt, not your whole table.
+- **Meet compliance faster** — exceed the encryption requirements of SOC 2 and ISO 27001, with an audit trail of every decryption.
-For our full security policy, supported versions, and contributor guidelines, see [SECURITY.md](./SECURITY.md).
+## Install
-## License
+```bash
+npm install @cipherstash/stack # or: yarn / pnpm / bun add @cipherstash/stack
+```
-This project is [MIT licensed](./LICENSE.md).
+> [!IMPORTANT]
+> **Opt out of bundling `@cipherstash/stack`.** It uses native Node.js features (a Rust FFI module) and the
+> native `require`. [Bundling guide →][bundling]
+
+**Requirements:** Node.js ≥ 18.
+
+## Migrating from Protect.js
+
+> [!NOTE]
+> **`@cipherstash/protect` (Protect.js) is now legacy and in maintenance mode.** It still receives critical
+> security fixes, but all new development has moved to `@cipherstash/stack`. New projects should use the
+> Stack; existing Protect.js users can migrate with the mapping below.
+
+| `@cipherstash/protect` | `@cipherstash/stack` |
+|---|---|
+| `protect(config)` | `Encryption(config)` |
+| `csTable` / `csColumn` | `encryptedTable` / `encryptedColumn` |
+| `@cipherstash/protect/identify` | `@cipherstash/stack/identity` |
+
+Method signatures and the `Result` (`data` / `failure`) pattern are unchanged. [Full migration guide →][reference]
+
+## Documentation & community
+
+- 📚 [Documentation][docs] · [Quickstart][quickstart] · [SDK reference][reference]
+- 🧩 [Example apps][examples]
+- 💬 [Discord community][discord]
+
+## Contributing · Security · License
+
+Contributions are welcome — see [CONTRIBUTE.md][contribute]. For our security policy and responsible
+disclosure, see [SECURITY.md][security-policy]. [MIT licensed][license].
+
+
+[signup]: https://cipherstash.com/signup?utm_source=github&utm_medium=stack_readme
+[docs]: https://cipherstash.com/docs/stack?utm_source=github&utm_medium=stack_readme
+[quickstart]: https://cipherstash.com/docs/stack/quickstart?utm_source=github&utm_medium=stack_readme
+[reference]: https://cipherstash.com/docs/stack/reference?utm_source=github&utm_medium=stack_readme
+[encryption]: https://cipherstash.com/docs/stack/cipherstash/encryption?utm_source=github&utm_medium=stack_readme
+[searchable-encryption]: https://cipherstash.com/docs/stack/cipherstash/encryption/searchable-encryption?utm_source=github&utm_medium=stack_readme
+[schema]: https://cipherstash.com/docs/stack/cipherstash/encryption/schema?utm_source=github&utm_medium=stack_readme
+[encrypt-decrypt]: https://cipherstash.com/docs/stack/cipherstash/encryption/encrypt-decrypt?utm_source=github&utm_medium=stack_readme
+[model-ops]: https://cipherstash.com/docs/stack/cipherstash/encryption/encrypt-decrypt?utm_source=github&utm_medium=stack_readme#model-operations
+[supabase]: https://cipherstash.com/docs/stack/cipherstash/encryption/supabase?utm_source=github&utm_medium=stack_readme
+[drizzle]: https://cipherstash.com/docs/stack/cipherstash/encryption/drizzle?utm_source=github&utm_medium=stack_readme
+[prisma-next]: https://cipherstash.com/docs/stack/cipherstash/encryption/prisma-next?utm_source=github&utm_medium=stack_readme
+[dynamodb]: https://cipherstash.com/docs/stack/cipherstash/encryption/dynamodb?utm_source=github&utm_medium=stack_readme
+[identity]: https://cipherstash.com/docs/stack/cipherstash/encryption/identity?utm_source=github&utm_medium=stack_readme
+[security-architecture]: https://cipherstash.com/docs/stack/reference/security-architecture?utm_source=github&utm_medium=stack_readme
+[zerokms]: https://cipherstash.com/docs/stack/cipherstash/kms?utm_source=github&utm_medium=stack_readme
+[bundling]: https://cipherstash.com/docs/stack/deploy/bundling?utm_source=github&utm_medium=stack_readme
+[eql]: https://github.com/cipherstash/encrypt-query-language
+[aws-kms]: https://docs.aws.amazon.com/kms/latest/developerguide/overview.html
+[discord]: https://discord.gg/5qwXUFb6PB
+[examples]: ./examples
+[contribute]: ./CONTRIBUTE.md
+[security-policy]: ./SECURITY.md
+[license]: ./LICENSE.md
diff --git a/docs/plans/readme-visual-assets.md b/docs/plans/readme-visual-assets.md
new file mode 100644
index 000000000..d453dbdde
--- /dev/null
+++ b/docs/plans/readme-visual-assets.md
@@ -0,0 +1,136 @@
+# README visual assets — spec
+
+Two visual assets for the refreshed root `README.md`. Both target the gaps competitor
+READMEs leave open:
+
+1. **Architecture diagram** — security/infra READMEs (Infisical, Vault) bury their architecture
+ off-README. A clear "how it works" diagram is the single biggest trust signal we can add.
+2. **Type-safety autocomplete GIF** — none of the TS-first leaders (Prisma, Drizzle, React Email)
+ *show* their type-safety story; they only describe it. An autocomplete GIF beats all of them.
+
+## Shared conventions
+
+- **Host in-repo** under `docs/images/` so assets are version-controlled.
+- **Reference with absolute URLs** (`https://raw.githubusercontent.com/cipherstash/stack/main/docs/images/...`).
+ Relative paths render on GitHub but **break on the npm package page** — npm needs absolute URLs.
+- **Light + dark variants** using GitHub's mode switch:
+ ```html
+
+
+ ```
+- **Brand**: use the CipherStash palette and logo; match the dark-theme look of cipherstash.com.
+- **Accessibility**: every asset needs descriptive `alt` text (provided below). For the GIF, keep motion
+ calm and the loop short (respect users who dislike motion).
+
+---
+
+## Asset 1 — Architecture diagram ("How it works")
+
+**Goal.** In one glance, prove the core trust claim: *plaintext and root keys never reach CipherStash; the
+database only ever holds ciphertext; every decryption is audited.*
+
+**Placement.** Under the `## How it works` heading, above the "Security architecture" doc link.
+
+**Format.** SVG preferred (crisp, tiny, theme-able). Target ~1400px wide, responsive height.
+
+**Layout (left → right data flow):**
+
+```
+┌─────────────────────────┐ ciphertext ┌──────────────────────────┐
+│ YOUR APP (TypeScript) │ ── EQL JSON payload ──▶ │ YOUR DATABASE │
+│ @cipherstash/stack │ │ PostgreSQL / JSONB │
+│ • encrypt / decrypt │ ◀── encrypted rows ─── │ • stores ciphertext only│
+│ • search on ciphertext │ │ • searchable (EQL) │
+└───────────┬─────────────┘ └──────────────────────────┘
+ │ per-value key requests (bulk)
+ ▼
+┌─────────────────────────┐ root key ┌──────────────────────────┐
+│ ZeroKMS │ ───────────▶ │ YOUR AWS KMS │
+│ • unique key per value │ │ • root key never leaves │
+│ • bulk key ops (fast) │ └──────────────────────────┘
+│ • decryption audit log │
+└─────────────────────────┘
+```
+
+**Trust-boundary callouts to overlay (the persuasive part):**
+- A dashed "trust boundary" line around *Your App + Your Database + Your AWS KMS* labelled
+ **"Plaintext and root keys never leave your boundary."**
+- A badge on ZeroKMS: **"CipherStash never sees plaintext."**
+- A small tag near the audit log: **"Every decryption logged → SOC 2 / ISO 27001 evidence."**
+
+**Alt text:**
+> "CipherStash architecture: encryption and decryption happen in your TypeScript app; only ciphertext
+> (EQL JSON) is stored in your PostgreSQL database. ZeroKMS issues a unique key per value, rooted in your
+> own AWS KMS. Plaintext and root keys never reach CipherStash, and every decryption is logged for audit."
+
+**Tooling.** Figma, Excalidraw, or draw.io → export SVG (light + dark). Keep text as real text (not
+outlines) where possible for crispness and accessibility.
+
+**Interim option (ship today, no designer needed).** GitHub renders Mermaid natively, so this can go in
+immediately and be swapped for the designed SVG later:
+
+```mermaid
+flowchart LR
+ App["Your App (TypeScript) @cipherstash/stack encrypt · decrypt · search"]
+ DB[("Your Database PostgreSQL / JSONB ciphertext only")]
+ ZKMS["ZeroKMS unique key per value bulk ops · audit log"]
+ KMS["Your AWS KMS root key never leaves"]
+
+ App -- "ciphertext (EQL JSON)" --> DB
+ App -- "per-value key requests" --> ZKMS
+ ZKMS -- "root key" --> KMS
+
+ subgraph Boundary["Your trust boundary — plaintext & root keys never leave"]
+ App
+ DB
+ KMS
+ end
+```
+
+---
+
+## Asset 2 — Type-safety / autocomplete GIF
+
+**Goal.** Show the DX payoff in motion: an encrypted field stays **fully typed and queryable** — encryption
+adds security without taking away autocomplete, inference, or compile-time safety.
+
+**Placement.** Inside the `### 🔐 Searchable encryption` pillar, or a short "Developer experience" callout.
+
+**Storyboard (single seamless loop, ≤ 10s):**
+1. Show a schema: `encryptedTable("users", { email: encryptedColumn("email").equality().freeTextSearch() })`.
+2. Type `await client.encryptModel(user, users)` and hover the result — tooltip shows the **schema-aware
+ return type**: `email → Encrypted`, `id → string`, `createdAt → Date` (only schema fields change type).
+3. Start typing a query: `.where(await ops.eq(usersTable.email, "` — show autocomplete offering the typed
+ operator and the column.
+4. Briefly trigger a **red squiggle** by accessing a field not in the schema (or wrong type) — proving
+ errors are caught at compile time.
+
+**Recording specs:**
+- VS Code, clean theme (record a **dark** primary; a light alt is nice-to-have).
+- Font size 16–18px; minimap off; hide activity/status bar clutter; zoom so code is legible on mobile.
+- Crop tight to the editor region. Width 1280–1440px.
+- Length 8–12s, seamless loop. **File budget < 5 MB** (ideally < 3 MB) so the README stays fast.
+
+**Formats:**
+- Ship a **`.gif`** for universal rendering (works on npm and GitHub).
+- Optionally also provide an `.mp4`/`.webm` and embed via `
-Encryption happens in your application. Ciphertext is stored as an [EQL][eql] JSON payload in your database;
-plaintext and root keys never reach CipherStash. Per-value keys are issued in bulk by ZeroKMS (so millions
+Encryption happens in your application. Ciphertext is stored as an [EQL][eql] payload in your database;
+plaintext and keys never reach CipherStash. Per-value keys are issued in bulk by ZeroKMS (so millions
of unique keys stay fast), and every decryption is logged for compliance.
→ [Security architecture][security-architecture] · [ZeroKMS][zerokms]
+## Performance
+
+Encrypted queries stay fast — latency is flat from 10k to 10M rows. Measured in [cipherstash/benches][benches]:
+
+| Operation | Median latency (up to 10M rows) |
+|---|---|
+| Equality lookup | ~0.1 ms |
+| Range query | ~0.5 ms |
+| JSON field equality | ~0.1 ms |
+
+
+
## Why CipherStash
- **Trusted data access** — only your end-users can access their sensitive data, enforced cryptographically.
- **Shrink the blast radius** — a breached vulnerability exposes only what one user can decrypt, not your whole table.
-- **Meet compliance faster** — exceed the encryption requirements of SOC 2 and ISO 27001, with an audit trail of every decryption.
+- **Audit trail built in** — every decryption event is recorded, no extra tooling to bolt on.
+- **Meet compliance faster** — exceed the encryption requirements of SOC 2 and ISO 27001, with FIPS-compliant
+ cryptography and BYOK for teams that need it.
+
+## FAQ
+
+
+Can CipherStash ever see my data, or my encryption keys?
+
+No, never. Encryption and decryption happen in your application, and keys are derived within your own
+environment. Plaintext and keys never leave your control and never reach CipherStash.
+
+
+
+How well does it scale?
+
+Latency stays flat as data grows — exact-match lookups hold at ~0.1 ms and range queries at ~0.5 ms from
+10k up to 10M rows ([cipherstash/benches][benches]). ZeroKMS handles keys in bulk (up to 10,000 per
+call), so key management isn't the bottleneck.
+
+
+
+What does migration look like?
+
+Install EQL on your Postgres database (`npx stash init` and the [quick starts](#quick-starts) handle
+this), declare the columns you want protected with the encrypted type that fits each one (for example
+`eql_v3.text_match` for searchable text or `eql_v3.int4_ord` for range queries), and encrypt values in
+your app before writing. You can adopt it column-by-column — no big-bang rewrite — and your existing
+Postgres indexes keep working.
+
+
+
+Do I have to change how I write queries?
+
+No. Query encrypted columns with the same Supabase.js, Prisma, or Drizzle calls you use today — there
+are no SQL rewrites.
+
+
+
+Do I need to run a KMS or key vault?
+
+No. Key management is built in through ZeroKMS. If you want to control the root key, Bring Your Own Key
+lets you root it in your own KMS.
+
+
+
+Does it work with Supabase Auth and Row Level Security?
+
+Yes. It integrates with Supabase Auth and runs alongside RLS — it complements them, it doesn't replace
+them.
+
+
+
+I already use Row Level Security — do I need this?
+
+RLS and CipherStash solve different problems, and they're strongest together. RLS decides which rows a
+role may query, but the data underneath is plaintext — so anything that bypasses RLS reveals it in the
+clear: a leaked `service_role` key, a misconfigured policy, a SQL injection running as an elevated role,
+a stolen backup, or the database host itself. CipherStash stores only ciphertext and keeps the keys
+outside the database, so those same bypasses reveal nothing readable. Keep RLS for authorization; add
+CipherStash so a bypass never becomes a breach.
+
+
+
+Is there a free tier?
+
+Yes — a free developer tier, so you can build encryption in from day one.
+
+
+## Start free
+
+Encryption is far cheaper to design in than to retrofit — and it's what unlocks regulated and enterprise
+customers. The developer tier is **free**, so you can add encryption from your very first migration:
+
+```bash
+npx stash init
+```
+
+Signing up is the wizard's first step if you don't have an account yet — or
+[create your free account][signup] in the browser first, and `stash init` will pick it up.
## Install
@@ -168,21 +320,6 @@ npm install @cipherstash/stack # or: yarn / pnpm / bun add @cipherstash/stack
**Requirements:** Node.js ≥ 18.
-## Migrating from Protect.js
-
-> [!NOTE]
-> **`@cipherstash/protect` (Protect.js) is now legacy and in maintenance mode.** It still receives critical
-> security fixes, but all new development has moved to `@cipherstash/stack`. New projects should use the
-> Stack; existing Protect.js users can migrate with the mapping below.
-
-| `@cipherstash/protect` | `@cipherstash/stack` |
-|---|---|
-| `protect(config)` | `Encryption(config)` |
-| `csTable` / `csColumn` | `encryptedTable` / `encryptedColumn` |
-| `@cipherstash/protect/identify` | `@cipherstash/stack/identity` |
-
-Method signatures and the `Result` (`data` / `failure`) pattern are unchanged. [Full migration guide →][reference]
-
## Documentation & community
- 📚 [Documentation][docs] · [Quickstart][quickstart] · [SDK reference][reference]
@@ -212,8 +349,15 @@ disclosure, see [SECURITY.md][security-policy]. [MIT licensed][license].
[security-architecture]: https://cipherstash.com/docs/stack/reference/security-architecture?utm_source=github&utm_medium=stack_readme
[zerokms]: https://cipherstash.com/docs/stack/cipherstash/kms?utm_source=github&utm_medium=stack_readme
[bundling]: https://cipherstash.com/docs/stack/deploy/bundling?utm_source=github&utm_medium=stack_readme
+
+[query-equality]: https://cipherstash.com/docs/stack/cipherstash/encryption/searchable-encryption?utm_source=github&utm_medium=stack_readme#equality
+[query-match]: https://cipherstash.com/docs/stack/cipherstash/encryption/searchable-encryption?utm_source=github&utm_medium=stack_readme#free-text-search
+[query-range]: https://cipherstash.com/docs/stack/cipherstash/encryption/searchable-encryption?utm_source=github&utm_medium=stack_readme#range-and-ordering
+[query-json]: https://cipherstash.com/docs/stack/cipherstash/encryption/searchable-encryption?utm_source=github&utm_medium=stack_readme#json
+[auth]: https://cipherstash.com/docs/stack/cipherstash/authentication?utm_source=github&utm_medium=stack_readme
+[keysets]: https://cipherstash.com/docs/stack/cipherstash/kms?utm_source=github&utm_medium=stack_readme#keysets
+[benches]: https://github.com/cipherstash/benches
[eql]: https://github.com/cipherstash/encrypt-query-language
-[aws-kms]: https://docs.aws.amazon.com/kms/latest/developerguide/overview.html
[discord]: https://discord.gg/5qwXUFb6PB
[examples]: ./examples
[contribute]: ./CONTRIBUTE.md
diff --git a/docs/plans/readme-visual-assets.md b/docs/plans/readme-visual-assets.md
index 31e121892..cba9f9ad9 100644
--- a/docs/plans/readme-visual-assets.md
+++ b/docs/plans/readme-visual-assets.md
@@ -126,6 +126,42 @@ Compress with Gifski / `gifsicle -O3`.
---
+## Asset 3 — Performance "flat latency" chart
+
+**Goal.** Make the scaling claim visual: *encrypted query latency stays flat from 10k to 10M rows.* A
+line that refuses to go up is more convincing than any table.
+
+**Placement.** In the `## Performance` section of the root README, under the latency table (a TODO
+comment marks the spot).
+
+**Why not embed the existing benches charts?** Reviewed the `cipherstash/benches` repo:
+
+- `report/query_*_chart.png` — matplotlib internal-report style; each chart also plots a
+ "with decryption" series (~24 ms, dominated by round-trip decrypt cost) that visually buries the
+ sub-millisecond headline. Light-theme only. Not README-quality.
+- `kms-app/results-ec2/**/latency.svg` / `throughput.svg` — the ZeroKMS vs AWS KMS story, cleanly
+ styled and the closest embeddable candidate, but light-theme only and annotated with red
+ "had failures" markers (on ZeroKMS points too, in the throughput chart) that invite the wrong
+ questions in a marketing context.
+
+**Spec.**
+
+- Line chart, x-axis: rows (log scale: 10k · 100k · 1M · 10M); y-axis: median query latency (ms).
+- Three flat lines near the floor: equality (~0.1 ms), range (~0.5 ms), JSON field equality (~0.1 ms).
+- Optional fourth reference: a subtle plaintext-baseline band, showing encrypted ≈ plaintext.
+- Callout label: **"Latency stays flat from 10k → 10M rows."**
+- Theme-aware light/dark SVG pair, same `` treatment and palette as the architecture diagram.
+- Regenerate from `cipherstash/benches` data on each benchmark refresh; attribute the repo in the caption.
+
+**Alt text:**
+> "Line chart of median encrypted-query latency versus table size. Equality, range, and JSON queries
+> hold steady at well under one millisecond as row counts grow from ten thousand to ten million."
+
+**Stretch:** a second chart for the ZeroKMS vs AWS KMS bulk-key story (up to 14× throughput, 10,000
+keys per call) — a two-bar or two-line comparison redrawn in brand style from `kms-app` data.
+
+---
+
## Suggested files
| File | Asset | Status |
@@ -136,6 +172,8 @@ Compress with Gifski / `gifsicle -O3`.
| `docs/images/architecture-stacked-dark.svg` | Architecture — mobile/stacked, dark (760×1100) | ✅ shipped |
| `docs/images/type-safety.gif` | Autocomplete/type-safety demo | todo |
| `docs/images/type-safety.mp4` | Optional higher-quality GitHub embed | todo |
+| `docs/images/perf-latency-light.svg` | Flat-latency chart — light | todo |
+| `docs/images/perf-latency-dark.svg` | Flat-latency chart — dark | todo |
The architecture diagram is embedded in the README via a single `` element that selects one
of the four variants from **two** dimensions at once — theme (`prefers-color-scheme`) and viewport width
From f590c6d0ccc25beefea7524c4f5a784cc43cd4b2 Mon Sep 17 00:00:00 2001
From: Dan Draper
Date: Fri, 3 Jul 2026 14:52:55 +1000
Subject: [PATCH 06/18] docs(readme): EQL v3 types in Drizzle sizzle + go-live
checklist
- Drizzle example uses the types namespace (types.TextMatch) instead of
the SEM-style options object, matching the v3 authoring surface
- Add docs/plans/readme-go-live-checklist.md consolidating every
doc-driven claim to confirm before merging to main, referenced from
a comment at the top of the README
---
README.md | 5 ++-
docs/plans/readme-go-live-checklist.md | 60 ++++++++++++++++++++++++++
2 files changed, 64 insertions(+), 1 deletion(-)
create mode 100644 docs/plans/readme-go-live-checklist.md
diff --git a/README.md b/README.md
index 0a8e159d6..9c4fee311 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,6 @@
+
Searchable, application-level encryption for building privacy-first apps. Encrypt fields in your
- app, keep them fully queryable in Postgres, and let built-in zero-knowledge key management handle the
- rest — CipherStash can never see your data or your keys.
+
Searchable, application-level encryption for building privacy-first apps.
@@ -21,63 +19,33 @@
⭐ Star this repo if encryption you can actually query is your thing!
-
-
-> **CipherStash never sees your plaintext — or your keys.** Data is encrypted in your app with a unique
-> key per value, and keys are derived inside your application via [ZeroKMS][zerokms] — so a database
-> breach leaks only ciphertext. [See the security architecture →][security-architecture]
-
-## Encrypted fields. Real queries. Your tools.
-
-The `email` column below is stored as ciphertext with a unique key per row — and the search still works,
-because the query runs on the ciphertext. No decrypt-and-scan, no query rewrites.
-
-**Supabase** — same Supabase.js calls; filters are encrypted on the way in, results decrypted on the way out:
-
-```typescript
-const db = encryptedSupabase({ encryptionClient, supabaseClient });
-
-const { data } = await db.from("users", users)
- .select("id, name, email")
- .ilike("email", "%@acme.com"); // encrypted free-text match
-```
-
-**Prisma Next** — declare encrypted columns in `schema.prisma`, query with type-safe operators:
-
-```prisma
-model User {
- id String @id
- email cipherstash.EncryptedString()
-}
-```
-```typescript
-const rows = await db.orm.User
- .where((u) => u.email.cipherstashIlike("%@acme.com"))
- .all();
-```
+## Encryption-level security without the pain
-**Drizzle** — encrypted column types in your table, auto-encrypting operators in your queries:
+Field-level encryption is the strongest way to protect data.
+But if DB functionality or performance suffers, you need to justify the pain.
+Searchable Encryption nukes the trade-off: encryption-level security without the pain.
-```typescript
-export const usersTable = pgTable("users", {
- id: integer("id").primaryKey(),
- email: types.TextMatch("email"), // → eql_v3.text_match — the type is the config
-});
+* Searchable Encryption for any Postgres including Supabase, RDS, Aurora, Prisma Postgres and Neon
+* Works with Supabase.js, Prisma Next, Drizzle or plain SQL
+* Built-in key management — automatic rotation, auditing and 14x faster than AWS KMS
+* Integrates with Supabase Auth, Clerk, Auth0 and Okta
-const results = await db.select().from(usersTable)
- .where(await ops.ilike(usersTable.email, "%@acme.com"));
-```
+
## Quick starts
-Pick the guide for the stack you're already on. Each takes about 5 minutes, starts on the
-**free developer tier** ([sign up][signup]), and begins with the same setup wizard:
+### Use the wizard
+
+Takes 5-10 minutes, starts on the **free developer tier** ([sign up][signup]), includes agent handoff.
```bash
+# Run this to start (or just ask Claude to)
npx stash init
```
+### ORM/database specific guides
+
| Quick start | Guide |
|---|---|
| **Supabase** | [Supabase quickstart →][supabase] |
@@ -171,6 +139,10 @@ no rotation schedule to babysit:
than AWS KMS at peak ([benchmarks][benches]).
- **Every decryption is logged** — a built-in audit trail of who decrypted what, and when.
+> **CipherStash never sees your plaintext — or your keys.** Data is encrypted in your app with a unique
+> key per value, and keys are derived inside your application via [ZeroKMS][zerokms] — so a database
+> breach leaks only ciphertext. [See the security architecture →][security-architecture]
+
## Advanced features
### 👤 Identity-aware encryption
@@ -195,6 +167,48 @@ tenant its own keyset for cryptographic tenant isolation (revoking a keyset rend
permanently unreadable), or pin keysets to a region to meet data-sovereignty requirements without
re-architecting your app. [Keysets →][keysets]
+## Encrypted fields. Real queries. Your tools.
+
+The `email` column below is stored as ciphertext with a unique key per row — and the search still works,
+because the query runs on the ciphertext. No decrypt-and-scan, no query rewrites.
+
+**Supabase** — same Supabase.js calls; filters are encrypted on the way in, results decrypted on the way out:
+
+```typescript
+const db = encryptedSupabase({ encryptionClient, supabaseClient });
+
+const { data } = await db.from("users", users)
+ .select("id, name, email")
+ .ilike("email", "%@acme.com"); // encrypted free-text match
+```
+
+**Prisma Next** — declare encrypted columns in `schema.prisma`, query with type-safe operators:
+
+```prisma
+model User {
+ id String @id
+ email cipherstash.EncryptedString()
+}
+```
+
+```typescript
+const rows = await db.orm.User
+ .where((u) => u.email.cipherstashIlike("%@acme.com"))
+ .all();
+```
+
+**Drizzle** — encrypted column types in your table, auto-encrypting operators in your queries:
+
+```typescript
+export const usersTable = pgTable("users", {
+ id: integer("id").primaryKey(),
+ email: types.TextMatch("email"), // → eql_v3.text_match — the type is the config
+});
+
+const results = await db.select().from(usersTable)
+ .where(await ops.ilike(usersTable.email, "%@acme.com"));
+```
+
## How it works
-
+
-
+
-
+
-
+
From dddfb5bd6f27b6c6a910fb5820c200e184208fe8 Mon Sep 17 00:00:00 2001
From: Dan Draper
Date: Thu, 30 Jul 2026 15:15:25 +1000
Subject: [PATCH 11/18] docs(readme): re-verify examples against shipped EQL v3
surface
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The README was written doc-driven, ahead of the code. EQL v3 has since
shipped with different names than the draft guessed, so every code
example is now corrected against main:
- SQL column domains live in public as eql_v3_text_match /
eql_v3_text_eq / eql_v3_integer_ord / eql_v3_json_search (the eql_v3
schema holds only the query_* domains and functions)
- types.IntegerOrd, not types.Int4Ord; client schema imports from
@cipherstash/stack/v3
- config.authStrategy (config.strategy is deprecated)
- Supabase: await encryptedSupabase(url, key) introspects the database;
the sizzle filters with .eq() since matches() is unavailable through
PostgREST on EQL 3.0.2
- Prisma Next: cipherstash.TextSearch() + db.orm.public.User with
eqlMatch (package renamed to @cipherstash/stack-prisma)
- Drizzle: types.TextSearch + ops.matches — no like/ilike on the v3
surface, by design
- Keysets: scoped-operations framing from stash-zerokms; dropped the
unshipped per-keyset region pinning and 'permanently unreadable'
claims
- Node.js >= 22 (matches engines); wasm-inline pointer in the install
note
The go-live checklist is updated to record what was verified, and its
Linear issue references are removed (public repo). The
packages/protect README banner was dropped during rebase — the package
itself was removed from main.
---
README.md | 64 ++++++++++----------
docs/plans/readme-go-live-checklist.md | 82 ++++++++++++++------------
docs/plans/readme-visual-assets.md | 8 +--
3 files changed, 81 insertions(+), 73 deletions(-)
diff --git a/README.md b/README.md
index e718e426e..c020fb5ce 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,5 @@
-
+
⭐ Star this repo if encryption you can actually query is your thing!
+
-## Table of Contents
-- [Install](#install)
-- [Quick Start](#quick-start)
-- [Features](#features)
-- [Schema Definition](#schema-definition)
-- [Encryption and Decryption](#encryption-and-decryption)
-- [Searchable Encryption](#searchable-encryption)
-- [Authentication](#authentication)
-- [Identity-Aware Encryption](#identity-aware-encryption-lock-contexts)
-- [CLI Reference](#cli-reference)
-- [Configuration](#configuration)
-- [Error Handling](#error-handling)
-- [API Reference](#api-reference)
-- [Subpath Exports](#subpath-exports)
-- [Legacy: EQL v2](#legacy-eql-v2)
-- [Requirements](#requirements)
-- [License](#license)
+## Encryption-level security without the pain
----
+Field-level encryption is the strongest way to protect data.
+But if DB functionality or performance suffers, you need to justify the pain.
+Searchable Encryption nukes the trade-off: encryption-level security without the pain.
-## Install
-
-```bash
-npm install @cipherstash/stack
-```
+* Searchable Encryption for any Postgres including Supabase, RDS, Aurora, Prisma Postgres and Neon
+* Works with Supabase.js, Prisma Next, Drizzle or plain SQL
+* Built-in key management — automatic rotation, auditing and up to 14x faster than AWS KMS
+* Integrates with Supabase Auth, Clerk, Auth0 and Okta
-Or with your preferred package manager:
+
-```bash
-yarn add @cipherstash/stack
-pnpm add @cipherstash/stack
-```
+## Quick starts
-## Quick Start
+### Use the wizard
-### 1. Initialize and authenticate your project
+Takes 5-10 minutes, starts on the **free developer tier** ([sign up][signup]), includes agent handoff.
```bash
+# Run this to start (or just ask Claude to)
npx stash init
```
-The wizard will authenticate you, walk you through choosing a database connection method, build an encryption schema, and install the required dependencies.
-
-### 2. Encrypt and decrypt
-
-Define a table with concrete EQL v3 column types, build the typed client, and encrypt:
-
-```typescript
-import { Encryption } from "@cipherstash/stack/v3"
-import { encryptedTable, types } from "@cipherstash/stack/eql/v3"
-
-// Define a schema — the column type fixes its query capabilities
-const users = encryptedTable("users", {
- email: types.TextSearch("email"), // equality + order/range + free-text search
-})
-
-// Create a typed client
-const client = await Encryption({ schemas: [users] })
-
-// Encrypt a value
-const encrypted = await client.encrypt("hello@example.com", {
- column: users.email,
- table: users,
-})
-
-// Every operation returns `{ data } | { failure }`. Narrow on `.failure` and
-// return/throw before reading `.data` — the failure branch has no `data`.
-if (encrypted.failure) {
- throw new Error(`Encryption failed: ${encrypted.failure.message}`)
-}
-console.log("Encrypted payload:", encrypted.data)
-
-// Decrypt the value
-const decrypted = await client.decrypt(encrypted.data)
-if (decrypted.failure) {
- throw new Error(`Decryption failed: ${decrypted.failure.message}`)
-}
-console.log("Plaintext:", decrypted.data) // "hello@example.com"
-```
-
-The client is typed from your schemas: passing the wrong plaintext type for a column (`client.encrypt(42, { column: users.email, ... })`) is a compile error.
-
-## Features
-
-- **Field-level encryption** - Every value encrypted with its own unique key via [ZeroKMS](https://cipherstash.com/products/zerokms), backed by AWS KMS.
-- **Searchable encryption** - Exact match, free-text search, order/range queries, and encrypted JSON queries in PostgreSQL, driven by concrete EQL v3 column types.
-- **Type-safe by construction** - Each encrypted column is a concrete Postgres domain; its query capabilities are fixed by the type you pick and enforced at compile time by the typed client.
-- **Bulk operations** - Encrypt or decrypt thousands of values in a single ZeroKMS call (`bulkEncrypt`, `bulkDecrypt`, `bulkEncryptModels`, `bulkDecryptModels`).
-- **Identity-aware encryption** - Tie encryption to a user's JWT via `OidcFederationStrategy` and `.withLockContext()`, so only that user can decrypt.
-- **CLI (`stash`)** - Initialize projects and set up encryption from the terminal.
-- **TypeScript-first** - Strongly typed schemas, results, and model operations with full generics support.
-
-## Schema Definition
-
-Define which tables and columns to encrypt using `encryptedTable` and the `types` namespace from `@cipherstash/stack/eql/v3`. Each factory in `types` maps 1:1 to a **concrete Postgres domain** named `public.eql_v3_` — the naming rule is: strip the `eql_v3_` prefix and PascalCase each underscore-separated segment. So `types.TextSearch` builds a `public.eql_v3_text_search` column, `types.IntegerOrd` builds `public.eql_v3_integer_ord`.
-
-There are **no chainable capability methods** — the concrete type fully describes what a column can do.
+### ORM/database specific guides
-```typescript
-import { encryptedTable, types } from "@cipherstash/stack/eql/v3"
-
-const users = encryptedTable("users", {
- email: types.TextSearch("email"), // equality + order/range + free-text search
- age: types.IntegerOrd("age"), // equality + order/range
- balance: types.Bigint("balance"), // storage only — encrypt/decrypt, no queries
- metadata: types.Json("metadata"), // encrypted JSON: containment + JSONPath selectors
-})
-```
-
-The returned table is also a column accessor (`users.email`). The JS property name and the DB column name may differ: `createdOn: types.Timestamp("created_at")` reads and writes the `createdOn` property on models but targets the `created_at` column in the database.
-
-### Capability Suffixes
+| Quick start | Guide |
+|---|---|
+| **Supabase** | [Supabase quickstart →][supabase] |
+| **Prisma Next** | [Prisma Next quickstart →][prisma-next] |
+| **Drizzle ORM** | [Drizzle quickstart →][drizzle] |
+| **Raw PostgreSQL (`pg`)** | [PostgreSQL quickstart →][encryption] |
+| **DynamoDB** | [DynamoDB quickstart →][dynamodb] |
-The suffix on the type name encodes the query capability:
+> The Stack also ships a `stash` CLI for auth, schema, and database setup. See the [SDK reference][reference].
-| Suffix | Capabilities | Query types |
-|---|---|---|
-| _(none)_ | Storage only — encrypt/decrypt, no queries | — |
-| `Eq` | Equality | `'equality'` |
-| `Ord` | Equality + ordering/range (OPE-backed) | `'equality'`, `'orderAndRange'` |
-| `OrdOre` | Equality + ordering/range (block-ORE-backed — the ORE operator class is superuser-only and unavailable on managed Postgres such as Supabase) | `'equality'`, `'orderAndRange'` |
-| `Match` (text only) | Free-text search only | `'freeTextSearch'` |
-| `Search` (text only, as `TextSearch`) | Equality + ordering/range + free-text | all three |
-| `Json` | Encrypted JSON containment + JSONPath selector queries | `'searchableJson'` |
+## What's in the Stack
-Prefer the plain `Ord` domains unless you know your database supports the ORE operator class.
+### 🔐 Searchable encryption
-### Domain Families and Plaintext Types
+Encrypt individual fields and still run real queries against them — all on ciphertext, in PostgreSQL:
-| Family | Factories | Plaintext (TypeScript) type |
+| Query type | Operations | Docs |
|---|---|---|
-| `Integer`, `Smallint`, `Numeric`, `Real`, `Double` | base, `Eq`, `Ord`, `OrdOre` | `number` |
-| `Bigint` | base, `Eq`, `Ord`, `OrdOre` | `bigint` (native JS bigint, full i64 range) |
-| `Date` | base, `Eq`, `Ord`, `OrdOre` | `Date` (calendar date; time-of-day truncated) |
-| `Timestamp` | base, `Eq`, `Ord`, `OrdOre` | `Date` (time-of-day preserved) |
-| `Text` | base, `Eq`, `Match`, `Ord`, `OrdOre`, `Search` | `string` |
-| `Boolean` | base only | `boolean` |
-| `Json` | `Json` only | a JSON *document* (object, array, or null — not a top-level scalar) |
-
-### Database Setup
+| **Equality** | `=`, `IN` | [Equality queries →][query-equality] |
+| **Free-text search** | fuzzy `matches` | [Text search →][query-match] |
+| **Range & ordering** | `<`, `>`, `BETWEEN`, `ORDER BY`, `MIN`/`MAX` | [Range queries →][query-range] |
+| **Encrypted JSON** | containment (`@>`), JSONPath selectors | [JSON queries →][query-json] |
-Install the EQL v3 SQL into your database with the stash CLI:
-
-```bash
-npx stash eql install
-# On Supabase, add --supabase to grant the anon/authenticated/service_role
-# roles access to the eql_v3 schemas — without it, encrypted queries fail with
-# "permission denied for schema eql_v3_internal":
-npx stash eql install --supabase
-```
-
-In migrations, declare each encrypted column as its domain type:
+With [EQL v3][eql], the column type *is* the configuration. Declare a column with the encrypted type
+that names its data type and the operations it supports, and it's ready to query — there's no per-column
+search configuration to maintain in your client:
```sql
CREATE TABLE users (
- id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
- email public.eql_v3_text_search,
- age public.eql_v3_integer_ord,
- balance public.eql_v3_bigint,
- metadata public.eql_v3_json_search
+ id serial PRIMARY KEY,
+ username text, -- plaintext — business as usual
+ email eql_v3_text_match, -- encrypted · free-text search
+ ssn eql_v3_text_eq, -- encrypted · equality
+ salary eql_v3_integer_ord, -- encrypted · range + ORDER BY
+ preferences eql_v3_json_search -- encrypted · containment + selectors
);
```
-## Encryption and Decryption
-
-### Single Values
-
-```typescript
-// Encrypt — plaintext is pinned to the column's domain type
-const encrypted = await client.encrypt("secret@example.com", {
- column: users.email,
- table: users,
-})
-
-// Decrypt (narrow on `.failure` before reading `.data`)
-if (encrypted.failure) throw new Error(encrypted.failure.message)
-const decrypted = await client.decrypt(encrypted.data)
-```
-
-### Model Operations
-
-Encrypt or decrypt an entire object. Only fields matching your schema are encrypted; other fields pass through unchanged. Schema fields are validated against their inferred plaintext type at compile time.
-
-`decryptModel` takes the **table as a second argument** and returns the precise plaintext model: `Date` columns are reconstructed to real `Date` instances, and `bigint` columns round-trip as native `bigint`.
-
-```typescript
-const user = {
- id: "user_123", // not in schema -> passes through
- email: "alice@example.com", // TextSearch -> encrypted as string
- age: 30, // IntegerOrd -> encrypted as number
- balance: 100_000n, // Bigint -> encrypted as bigint
-}
-
-const encryptedResult = await client.encryptModel(user, users)
-// encryptedResult.data.email -> Encrypted
-// encryptedResult.data.id -> string
-
-if (encryptedResult.failure) throw new Error(encryptedResult.failure.message)
-const decryptedResult = await client.decryptModel(encryptedResult.data, users)
-// decryptedResult.data.email -> string
-// decryptedResult.data.balance -> bigint
-```
-
-### Bulk Operations
-
-All bulk methods make a single call to ZeroKMS regardless of the number of records, while still using a unique key per value.
-
-#### Bulk Encrypt / Decrypt Models
-
-```typescript
-const userModels = [
- { id: "1", email: "alice@example.com", age: 30, balance: 100_000n },
- { id: "2", email: "bob@example.com", age: 41, balance: 250_000n },
-]
-
-const encrypted = await client.bulkEncryptModels(userModels, users)
-if (encrypted.failure) throw new Error(encrypted.failure.message)
-const decrypted = await client.bulkDecryptModels(encrypted.data, users)
-```
-
-#### Bulk Encrypt / Decrypt (raw values)
-
-`bulkEncrypt` / `bulkDecrypt` work on raw value arrays rather than models. `bulkEncrypt` is typed like `encrypt` — `{ table, column }` pins every `plaintext` to that column's domain — while `bulkDecrypt` takes the payloads alone, so it resolves to the plaintext union and does **no `Date` reconstruction**: a `types.Date` / `types.Timestamp` column read this way is the string it was stored as, where `bulkDecryptModels(rows, table)` gives you a `Date`. See [`EncryptionClient` Methods](#encryptionclient-methods) for why.
+Encrypted types exist for text, integers, floats, numerics, dates, timestamps, booleans, and JSON, so
+your schema documents itself — and encrypted data stays indexable with standard Postgres indexes. No
+special index engine, no SQL rewrites. ORMs pick the types up transparently: declare the column as
+encrypted in `schema.prisma` or your Drizzle table and the Stack handles the rest. Only raw `pg` needs
+a client-side [schema][schema] — declared with the same type names:
```typescript
-const plaintexts = [
- { id: "u1", plaintext: "alice@example.com" },
- { id: "u2", plaintext: "bob@example.com" },
-]
-
-const encrypted = await client.bulkEncrypt(plaintexts, {
- column: users.email,
- table: users,
-})
-
-// encrypted.data = [{ id: "u1", data: EncryptedPayload }, ...]
-
-if (encrypted.failure) throw new Error(encrypted.failure.message)
-const decrypted = await client.bulkDecrypt(encrypted.data)
-if (decrypted.failure) throw new Error(decrypted.failure.message)
-
-// Each item has either { data: "plaintext" } or { error: "message" }
-for (const item of decrypted.data) {
- if ("data" in item) {
- console.log(`${item.id}: ${item.data}`)
- } else {
- console.error(`${item.id} failed: ${item.error}`)
- }
-}
-```
-
-## Searchable Encryption
-
-Encrypt a query term so you can search encrypted data in PostgreSQL. The typed client only accepts queryable columns, and `queryType` is constrained to the column's capabilities — equality and range queries run through the domain's own SQL operators.
-
-```typescript
-// Equality
-const eqQuery = await client.encryptQuery("alice@example.com", {
- column: users.email,
- table: users,
- queryType: "equality",
-})
-
-// Free-text search — queryType is REQUIRED for a match term (see gotcha below)
-const matchQuery = await client.encryptQuery("ali", {
- column: users.email,
- table: users,
- queryType: "freeTextSearch",
-})
-
-// Order and range
-const rangeQuery = await client.encryptQuery(30, {
- column: users.age,
- table: users,
- queryType: "orderAndRange",
-})
-```
-
-> **Gotcha — `TextSearch` defaults to equality.** A `TextSearch` column carries all three indexes, and `encryptQuery` with **no explicit `queryType` builds an equality term, not a free-text match**. A substring like `"joh"` then matches nothing. Always pass `queryType: 'freeTextSearch'` for substring/token search.
-
-Free-text search is fuzzy bloom-filter token matching, surfaced as `matches` in
-the Drizzle adapter and `eqlMatch` in Prisma Next. It is order- and
-multiplicity-insensitive and one-sided (a match may be a false positive, a
-non-match never is). It is not SQL `LIKE`; don't pass `%` wildcards.
+import { encryptedTable, types } from "@cipherstash/stack/v3";
-> **Supabase + EQL 3.0.2:** encrypted free-text and JSON operators now require
-> typed `eql_v3.query_*` operands. PostgREST cannot express those casts, so
-> Supabase v3 fails fast for `matches()`, encrypted `contains()`, and
-> `selectorEq()`/`selectorNe()`. Use Drizzle, Prisma Next, or a carefully scoped
-> direct SQL/RPC path.
-
-### Encrypted JSON
-
-A `types.Json` column encrypts a whole JSON document (an object, array, or null — not a top-level scalar) to a `public.eql_v3_json_search` value. Two query patterns are supported:
-
-**Exact containment** (jsonb `@>` semantics, no false positives). Pass a sub-object or sub-array needle; array containment is a subset test regardless of element position — `{ roles: ["admin"] }` matches any document whose `roles` array includes `"admin"`:
-
-```typescript
-const events = encryptedTable("events", { metadata: types.Json("metadata") })
-
-const containsQuery = await client.encryptQuery(
- { roles: ["admin"] },
- { column: events.metadata, table: events }, // queryType inferred: 'searchableJson'
-)
+const users = encryptedTable("users", {
+ email: types.TextMatch("email"), // ↔ eql_v3_text_match
+ salary: types.IntegerOrd("salary"), // ↔ eql_v3_integer_ord
+});
```
-**JSONPath selectors** — equality and ordering at a path (`$.a`, `$.a.b` dot-notation object paths):
-
-- **Drizzle**: `ops.selector(events.metadata, "$.age")` returns comparison methods bound to the path — `eq`, `ne`, `gt`, `gte`, `lt`, `lte` (e.g. `await ops.selector(events.metadata, "$.age").gt(21)`). Its unique power over containment is *ordering* at a path; equality at a path is equivalently `contains(col, { age: 21 })`.
-- **Supabase**: unavailable through PostgREST on EQL 3.0.2 because it cannot
- cast operands to `eql_v3.query_json`; the adapter fails fast.
-- **Prisma Next**: `eqlJsonPathEq/Neq/Gt/Gte/Lt/Lte(path, value)` on an encrypted JSON field.
-
-Two semantics to know:
-
-- **`ne` includes absent paths.** A "not equal at path" query also matches rows where the path does not exist at all.
-- **Array-leaf caveat:** a scalar needle does not match an array at the path.
- Use a full-array containment needle for membership tests.
-
-`types.Json` carries no equality or ordering on the document itself, so applying `eq` / `gt` / `asc` directly to a `Json` column throws.
+→ [Searchable encryption][searchable-encryption] · [Schema][schema] · [Encrypt & decrypt][encrypt-decrypt] · [Bulk & model operations][model-ops]
-> **Upgrade note:** EQL 3.0.2 changes the searchable-JSON storage domain and
-> SteVec wire format. Existing encrypted JSON rows must be re-encrypted before
-> querying them with this version. Legacy EQL v2 `searchableJson()` columns are
-> no longer supported; migrate them to the EQL v3 `types.Json` domain. For raw
-> `encryptQuery` callers, explicit `queryType: 'steVecTerm'` now means a scalar
-> JSON ordering term, not containment. Prefer `searchableJson` for containment,
-> or `steVecValueSelector` for exact equality at a path.
+### 🔑 Authentication
-### Batch Query Encryption
+How you authenticate to ZeroKMS depends on who's asking for keys:
-Encrypt multiple query terms in one call:
+- **Device auth** — browser-based login for local development: `npx stash auth login` opens your
+ browser and saves credentials to your local CipherStash profile. No secrets in your repo or shell.
+- **Access key auth** — service-level credentials for servers, workers, and CI, supplied via `CS_*`
+ environment variables.
+- **OIDC federation** — federate your identity provider's JWT so every key request authenticates *as the
+ signed-in user*, not as your app. Supported providers: **Supabase Auth**, **Clerk**, **Okta**, and **Auth0**
+ (any OIDC-compliant provider works).
```typescript
-const terms = [
- { value: "alice@example.com", column: users.email, table: users, queryType: "equality" as const },
- { value: "bob", column: users.email, table: users, queryType: "freeTextSearch" as const },
-]
-
-const results = await client.encryptQuery(terms)
-```
-
-### Ordering Encrypted Data
-
-`ORDER BY` works on encrypted ordering columns via the domain's order term:
-
-- **Drizzle**: `ops.asc(col)` / `ops.desc(col)` emit `ORDER BY eql_v3.ord_term(col)` (or `eql_v3.ord_term_ore` for ORE domains).
-- **Supabase**: `.order()` works on OPE-backed ordering columns — every plain `*Ord` domain plus `TextSearch`.
-
-The one limitation is the ORE-backed `*OrdOre` domains: their ordering term needs the superuser-only ORE operator class, which is unavailable on managed Postgres (e.g. Supabase) — the Supabase adapter rejects `order()` on those columns with a clear error. Prefer the plain `*Ord` (OPE) domains for anything you need to sort in a managed environment.
-
-### Drizzle Integration
-
-The separate `@cipherstash/stack-drizzle` package provides Drizzle-native column factories, schema extraction, and auto-encrypting, capability-checked query operators. It is EQL v3 only, all on the package root — the EQL v2 surface was removed and the old `./v3` subpath collapsed into `.`.
-
-Declare a Drizzle table using the `types` factories — each factory emits its domain as the column's SQL type, so `drizzle-kit generate` produces `ADD COLUMN email public.eql_v3_text_search` etc.:
-
-```ts
-import { pgTable, integer } from "drizzle-orm/pg-core"
-import { drizzle } from "drizzle-orm/postgres-js"
-import {
- types,
- createEncryptionOperators,
- extractEncryptionSchema,
-} from "@cipherstash/stack-drizzle"
-import { Encryption } from "@cipherstash/stack/v3"
+// Access key (default) — reads CS_* env vars, no config needed
+const client = await Encryption({ schemas: [users] });
-// Capabilities come from the concrete type — no flags to configure.
-const users = pgTable("users", {
- id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
- email: types.TextEq("email"), // equality: eq / ne / inArray
- age: types.IntegerOrd("age"), // order + range: gt/gte/lt/lte, between, asc/desc
- bio: types.TextMatch("bio"), // free-text search: matches
- balance: types.Bigint("balance"), // storage only (no query capability)
-})
-```
-
-Derive the v3 schema from the table, build the typed client, and create the operators:
-
-```ts
-const usersSchema = extractEncryptionSchema(users)
-const client = await Encryption({ schemas: [usersSchema] })
-const ops = createEncryptionOperators(client)
-
-const db = drizzle({ client: sqlClient })
+// OIDC federation — every ZeroKMS request authenticates as the end user
+const client = await Encryption({
+ schemas: [users],
+ config: { authStrategy: OidcFederationStrategy.create(workspaceCrn, () => getUserJwt()) },
+});
```
-The operators auto-encrypt their operands and validate them against the column's concrete type. Applying an operator the type doesn't support throws `EncryptionOperatorError`:
-
-```ts
-// Equality — email is TextEq
-const exact = await db.select().from(users)
- .where(await ops.eq(users.email, "alice@example.com"))
-
-// Range + 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 match — bio is TextMatch
-const coffee = await db.select().from(users)
- .where(await ops.matches(users.bio, "coffee"))
-```
+→ [Authentication][auth]
-Rows are **pre-encrypted** with `client.bulkEncryptModels(...)` before they reach `db.insert(...).values(...)` — Drizzle never sees plaintext. `Bigint` columns take a native JS `bigint`:
+### 🗝️ Built-in key management
-```ts
-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)
+Key management is built in, powered by [ZeroKMS][zerokms] — no AWS KMS to wire up, no key vault to run,
+no rotation schedule to babysit:
-await db.insert(users).values(rows.data)
-```
+- **A unique key for every value** — not one key per table or per database.
+- **Automatic key rotation** — handled for you, with zero downtime.
+- **CipherStash can never see your keys.** Keys are *derived inside your application*; neither plaintext
+ keys nor plaintext data ever leave your infrastructure.
+- **Fast at scale** — bulk key operations handle up to 10,000 keys in a single call, up to 14× faster
+ than AWS KMS at peak ([benchmarks][benches]).
+- **Every decryption is logged** — a built-in audit trail of who decrypted what, and when.
-Notes:
+> **CipherStash never sees your plaintext — or your keys.** Data is encrypted in your app with a unique
+> key per value, and keys are derived inside your application via [ZeroKMS][zerokms] — so a database
+> breach leaks only ciphertext. [See the security architecture →][security-architecture]
-- **Free-text search is `ops.matches`** (fuzzy bloom token matching on `TextMatch` / `TextSearch` columns), not SQL `like` / `ilike` — those operators do not exist on the v3 surface. `ops.contains` is a *different* operator: exact encrypted-JSON containment on `types.Json` columns.
-- **The concrete type defines the legal operators.** `TextEq` supports `eq` / `ne` / `inArray` / `notInArray`; `*Ord` types add `gt` / `gte` / `lt` / `lte` / `between` / `notBetween` and `asc` / `desc`; `TextMatch` and `TextSearch` add `matches`; `Json` supports `contains` and `selector(col, '$.path').{eq,ne,gt,gte,lt,lte}`; a bare `Text` / `Integer` / `Bigint` column is storage-only. Using an unsupported operator throws `EncryptionOperatorError`.
-- Combine conditions with `ops.and` / `ops.or`, and do NULL checks with `ops.isNull` / `ops.isNotNull` (the where-clause operators are `async` and must be `await`ed; `ops.asc` / `ops.desc` are synchronous).
+## Advanced features
-### Supabase Integration
+### 👤 Identity-locking encryption
-`encryptedSupabase` from the separate `@cipherstash/stack-supabase` package wraps a Supabase client and **introspects the database at connect time** — it detects EQL v3 columns by their Postgres domain and builds the encryption client internally:
+Building on OIDC federation, you can bind a record's encryption key to the end user's identity, so only
+*that* user can decrypt their data: `.withLockContext({ identityClaim })` ties the data key to a claim in
+the user's JWT, enforced cryptographically by ZeroKMS.
```typescript
-import { encryptedSupabase } from "@cipherstash/stack-supabase"
-
-const es = await encryptedSupabase(supabaseUrl, supabaseKey)
-
-await es.from("users").insert({ email: "a@b.com", age: 30 })
-await es.from("users").select("id, email").eq("email", "a@b.com")
-await es.from("users").select("id, age").gte("age", 18).order("age")
+// Bind the data key to a claim — the same claim is required to decrypt
+await client
+ .encrypt("alice@example.com", { table: users, column: users.email })
+ .withLockContext({ identityClaim: ["sub"] });
```
-Equality/range filters and `order()` on OPE-backed ordering columns remain
-available. On EQL 3.0.2, PostgREST cannot express the typed query operands
-required by encrypted free-text and JSON operators, so those methods fail with
-an actionable error. Pass optional declared `schemas` for compile-time row
-types. See the `stash-supabase` skill or the [docs](https://cipherstash.com/docs)
-for the full guide.
-
-## Authentication
+→ [Identity-locking encryption][identity]
-The client authenticates to ZeroKMS through `config.authStrategy`. Leave it
-unset for the default **auto** strategy: in local development, authenticate
-once with `npx stash auth login` (preferred — no credentials in your
-environment); in CI/production, set the `CS_*` environment variables. Two
-explicit strategies cover the other cases:
+### 🗂️ Keysets for multitenancy & sovereignty
-- **`AccessKeyStrategy`** — service-to-service / CI. Authenticates a *service*
- with a CipherStash access key.
-- **`OidcFederationStrategy`** — authenticates the client **as the end user**
- by federating a third-party OIDC JWT (Clerk, Supabase, Auth0, Okta, ...)
- into a CipherStash service token:
+Partition your keys into **keysets** — independent key hierarchies within a single workspace. Give each
+tenant its own keyset (`config.keyset`) for cryptographic tenant isolation: every encrypt, decrypt, and
+query is scoped to a keyset, so revoking a keyset's access makes that tenant's data undecryptable —
+without re-architecting your app. [Keysets →][keysets]
-```typescript
-import { OidcFederationStrategy } from "@cipherstash/stack"
-import { Encryption } from "@cipherstash/stack/v3"
-
-// The callback is re-invoked on every (re-)federation and must return the
-// CURRENT third-party OIDC JWT.
-const strategy = OidcFederationStrategy.create(
- process.env.CS_WORKSPACE_CRN!,
- () => getUserJwt(),
-)
-if (strategy.failure) throw new Error(strategy.failure.error.message)
-
-const client = await Encryption({
- schemas: [users],
- config: { authStrategy: strategy.data },
-})
-```
+## Encrypted fields. Real queries. Your tools.
-Authentication stands on its own — an OIDC-authenticated client encrypts and
-decrypts normally. Binding *data* to the authenticated user is a separate,
-optional step: the lock context, below.
+The `email` column below is stored as ciphertext with a unique key per row — and the search still works,
+because the query runs on the ciphertext. No decrypt-and-scan, no query rewrites.
-## Identity-Aware Encryption (Lock Contexts)
-
-Bind a data key to a claim from the end user's JWT, so only that user can
-decrypt. Chain `.withLockContext({ identityClaim })` on any operation:
+**Supabase** — same Supabase.js calls; the wrapper introspects your schema, encrypts filters on the way
+in, and decrypts results on the way out:
```typescript
-// Requires a client authenticated with OidcFederationStrategy (above) — the
-// claim's value resolves from the federated JWT.
-const IDENTITY = { identityClaim: ["sub"] }
-
-const encrypted = await client
- .encrypt("sensitive data", { column: users.email, table: users })
- .withLockContext(IDENTITY)
-if (encrypted.failure) throw new Error(encrypted.failure.message)
-
-const decrypted = await client
- .decrypt(encrypted.data)
- .withLockContext(IDENTITY)
-```
-
-Lock contexts **require** an `OidcFederationStrategy`-authenticated client
-(the auto and access-key strategies authenticate no end user, so there is no
-JWT to resolve claims from); plain authentication never requires a lock
-context.
-
-`identityClaim` is an array of JWT claim *names* (`["sub"]`), not values, and the
-same claim must be supplied to encrypt and decrypt. Lock contexts work with all
-operations: `encrypt`, `decrypt`, `encryptModel`, `decryptModel`,
-`bulkEncryptModels`, `bulkDecryptModels`, `bulkEncrypt`, `bulkDecrypt`,
-`encryptQuery`. `.withLockContext()` also accepts a `LockContext` instance.
-On the typed client, `decryptModel` / `bulkDecryptModels` additionally accept
-the lock context as an optional third argument. Use that or `.withLockContext()`,
-not both — chaining onto a decrypt that already took a positional lock context
-throws.
-
-> **Deprecated: `LockContext.identify()`.** Per-operation CTS tokens were removed
-> in `protect-ffi` 0.25; the token `identify()` fetches is no longer used by
-> encryption. Authenticate with `OidcFederationStrategy` and pass the claim
-> directly, as above.
-
-## CLI Reference
+const db = await encryptedSupabase(supabaseUrl, supabaseKey);
-The CLI is available via `npx stash` after install.
-
-### `npx stash auth`
-
-Authenticate with CipherStash.
-
-```bash
-npx stash auth login
+const { data } = await db.from("users")
+ .select("id, name, email")
+ .eq("email", "alice@acme.com"); // encrypted equality — runs on ciphertext
```
-This runs the device code flow: it opens your browser, you confirm the code, and a token is saved to `~/.cipherstash/auth.json`. No environment variables or credentials files are needed for local development.
-
-### `npx stash init`
+**Prisma Next** — declare encrypted columns in `schema.prisma`, query with type-safe operators:
-Initialize CipherStash for your project with an interactive wizard.
-
-```bash
-npx stash init
-npx stash init --supabase
+```prisma
+model User {
+ id String @id
+ email cipherstash.TextSearch()
+}
```
-The wizard will:
-1. Authenticate with CipherStash (device code flow)
-2. Introspect your database and install the EQL v3 SQL
-3. Choose your database connection method (Drizzle ORM, Supabase JS, Prisma, or Raw SQL)
-4. Build an encryption schema interactively or use a placeholder, then generate the encryption client file
-5. Install `stash` as a dev dependency for database tooling
-
-`init` installs EQL for you — no separate `eql install` step is needed afterward.
-
-| Flag | Description |
-|------|-------------|
-| `--supabase` / `--drizzle` / `--prisma-next` | Target a specific integration's setup flow |
-| `--region ` | Workspace region (env `STASH_REGION`); **required for non-interactive init when not already logged in** |
-
-## Configuration
-
-### Local Development
-
-No environment variables or credentials are needed for local development. Run `npx stash auth login` to authenticate via the device code flow (or `npx stash init` for the agent-assisted end-to-end setup), and the SDK and CLI will use the token saved to `~/.cipherstash/auth.json`.
-
-### Going to Production
-
-For production, CI/CD, and deployed environments, you'll need to set up machine credentials via environment variables:
-
-| Variable | Description |
-|-----|-------|
-| `CS_WORKSPACE_CRN` | The workspace identifier (CRN format) |
-| `CS_CLIENT_ID` | The client identifier |
-| `CS_CLIENT_KEY` | Client key material used with ZeroKMS for encryption |
-| `CS_CLIENT_ACCESS_KEY` | API key for authenticating with the CipherStash API |
-
-See the [Going to Production](https://cipherstash.com/docs/stack/deploy/going-to-production) guide for full details on creating machine clients, setting up access keys, and configuring CI/CD pipelines.
-
-### Programmatic Config
-
-Pass config directly when initializing the client:
-
```typescript
-import { Encryption } from "@cipherstash/stack/v3"
-import { users } from "./schema"
-
-const client = await Encryption({
- schemas: [users],
- config: {
- workspaceCrn: "crn:ap-southeast-2.aws:your-workspace-id",
- clientId: "your-client-id",
- clientKey: "your-client-key",
- accessKey: "your-access-key",
- keyset: { name: "my-keyset" }, // or { id: "uuid" }
- },
-})
+const rows = await db.orm.public.User
+ .where((u) => u.email.eqlMatch("acme.com"))
+ .all();
```
-### Multi-Tenant Encryption (Keysets)
-
-Isolate encryption keys per tenant using keysets:
+**Drizzle** — encrypted column types in your table, auto-encrypting operators in your queries:
```typescript
-const client = await Encryption({
- schemas: [users],
- config: {
- keyset: { id: "123e4567-e89b-12d3-a456-426614174000" },
- },
-})
+export const usersTable = pgTable("users", {
+ id: integer("id").primaryKey(),
+ email: types.TextSearch("email"), // → eql_v3_text_search — the type is the config
+});
-// or by name
-const client2 = await Encryption({
- schemas: [users],
- config: {
- keyset: { name: "Company A" },
- },
-})
+const results = await db.select().from(usersTable)
+ .where(await ops.matches(usersTable.email, "acme.com"));
```
-### Logging
+## How it works
-The SDK uses structured logging across all interfaces (Encryption, Supabase, DynamoDB). Each operation emits a single wide event with context such as the operation type, table, column, lock context status, and duration.
+
+
+
+
+
+
+
+
+
+
+
+
-Configure the log level with the `STASH_STACK_LOG` environment variable:
+Encryption happens in your application. Ciphertext is stored as an [EQL][eql] payload in your database;
+plaintext and keys never reach CipherStash. Per-value keys are issued in bulk by ZeroKMS (so millions
+of unique keys stay fast), and every decryption is logged for compliance.
-```bash
-STASH_STACK_LOG=error # debug | info | error (default: error)
-```
+→ [Security architecture][security-architecture] · [ZeroKMS][zerokms]
-| Value | What is logged |
-| ------- | ---------------------- |
-| `error` | Errors only (default) |
-| `info` | Info and errors |
-| `debug` | Debug, info, and errors |
+## Performance
-When `STASH_STACK_LOG` is not set, the SDK defaults to `error` (errors only).
+Encrypted queries stay fast — latency is flat from 10k to 10M rows. Measured on EQL v3 in [cipherstash/benches][benches]:
-The SDK never logs plaintext data.
+| Operation | Median latency (up to 10M rows) |
+|---|---|
+| Equality lookup | ~0.1 ms |
+| Range query | ~0.5 ms |
+| JSON field equality | ~0.1 ms |
-## Error Handling
+
-All async methods return a `Result` object with either a `data` key (success) or a `failure` key (error). This is a discriminated union - you never get both.
+## Why CipherStash
-```typescript
-const result = await client.encrypt("hello", { column: users.email, table: users })
-
-if (result.failure) {
- // result.failure.type: string (e.g. "EncryptionError")
- // result.failure.message: string
- console.error(result.failure.type, result.failure.message)
-} else {
- // result.data: Encrypted payload
- console.log(result.data)
-}
-```
+- **Trusted data access** — only your end-users can access their sensitive data, enforced cryptographically.
+- **Shrink the blast radius** — a breached vulnerability exposes only what one user can decrypt, not your whole table.
+- **Audit trail built in** — every decryption event is recorded, no extra tooling to bolt on.
+- **Meet compliance faster** — exceed the encryption requirements of SOC 2 and ISO 27001, with FIPS-compliant
+ cryptography and BYOK for teams that need it.
-### Error Types
+## FAQ
-| Type | When |
-|---|---|
-| `ClientInitError` | Client initialization fails (bad credentials, missing config) |
-| `EncryptionError` | An encrypt operation fails |
-| `DecryptionError` | A decrypt operation fails |
-| `LockContextError` | Lock context creation or usage fails |
-| `CtsTokenError` | Identity token exchange fails |
+
+Can CipherStash ever see my data, or my encryption keys?
-## API Reference
+No, never. Encryption and decryption happen in your application, and keys are derived within your own
+environment. Plaintext and keys never leave your control and never reach CipherStash.
+
-### `Encryption(config)` - Initialize the typed client
+
+How well does it scale?
-```typescript
-// Overload 1 — non-emptiness in the CONSTRAINT, so code that is itself
-// generic over its schemas still compiles.
-function Encryption(
- config: { schemas: S; config?: ClientConfig },
-): Promise>
-
-// Overload 2 — any array of v3 tables; non-emptiness is enforced on the
-// `schemas` PROPERTY, which keeps `const` inference (and per-column plaintext
-// typing) intact on the array-literal path.
-function Encryption(
- config: { schemas: NonEmptyV3; config?: ClientConfig },
-): Promise>
-```
+Latency stays flat as data grows — exact-match lookups hold at ~0.1 ms and range queries at ~0.5 ms from
+10k up to 10M rows ([cipherstash/benches][benches]). ZeroKMS handles keys in bulk (up to 10,000 per
+call), so key management isn't the bottleneck.
+
-(`NonEmptyV3` is an internal helper — it resolves to `S` for any non-empty
-array and to `never` for `readonly []`. It is not exported; you never name it.)
+
+What does migration look like?
-`schemas` accepts **any non-empty array of v3 tables** — an array literal, a
-shared `export const schemas: AnyV3Table[]`, a `ReadonlyArray`, or one built at
-runtime by push or spread. A mutable array literal is not required. The returned
-client's model and query types are derived from `S`.
+Install EQL on your Postgres database (`npx stash init` and the [quick starts](#quick-starts) handle
+this), declare the columns you want protected with the encrypted type that fits each one (for example
+`eql_v3_text_match` for searchable text or `eql_v3_integer_ord` for range queries), and encrypt values in
+your app before writing. You can adopt it column-by-column — no big-bang rewrite — and your existing
+Postgres indexes keep working.
+
-`Encryption({ schemas: [] })` is a compile error. An array *typed* `AnyV3Table[]`
-that happens to be empty at runtime compiles and throws on init instead.
+
+Do I have to change how I write queries?
-The wire format is pinned to EQL v3 — `config.eqlVersion` is not supported, and
-`Encryption()` throws if the field is present at all.
+Barely. You keep your query builder: Supabase.js filters work unchanged, and Drizzle and Prisma Next
+add encrypted-aware operators (`ops.eq`, `ops.matches`, `eqlMatch`, …) that take plaintext and encrypt
+it for you. There are no SQL rewrites.
+
-### `EncryptionClient` Methods
+
+Do I need to run a KMS or key vault?
-Method signatures are derived from your schemas: plaintext arguments are pinned to each column's domain type, query methods only accept queryable columns, and `queryType` is constrained to the column's capabilities.
+No. Key management is built in through ZeroKMS. If you want to control the root key, Bring Your Own Key
+lets you root it in your own KMS.
+
-| Method | Signature | Returns |
-|----|------|-----|
-| `encrypt` | `(plaintext, { column, table })` | `EncryptOperation` (thenable) |
-| `decrypt` | `(encryptedData)` | `DecryptOperation` (thenable) |
-| `encryptQuery` | `(plaintext, { column, table, queryType?, returnType? })` | `EncryptQueryOperation` (thenable) |
+
+Does it work with Supabase Auth and Row Level Security?
-`returnType` controls the encrypted query term's shape: `'eql'` (default, the EQL JSON payload for the ORM adapters), `'composite-literal'` (a Postgres composite string for `.eq()`/string-based APIs), or `'escaped-composite-literal'` (the same, escaped for embedding). Most users take the default; the adapters set it as needed.
-| `encryptQuery` | `(terms: ScalarQueryTerm[])` | `BatchEncryptQueryOperation` (thenable) |
-| `encryptModel` | `(model, table)` | `EncryptModelOperation` (thenable) |
-| `decryptModel` | `(encryptedModel, table, lockContext?)` | `AuditableDecryptModelOperation` (thenable) |
-| `bulkEncryptModels` | `(models, table)` | `BulkEncryptModelsOperation` (thenable) |
-| `bulkDecryptModels` | `(encryptedModels, table, lockContext?)` | `AuditableDecryptModelOperation` (thenable) |
-| `bulkEncrypt` | `(plaintexts, { column, table })` | `BulkEncryptOperation` (thenable) |
-| `bulkDecrypt` | `(encryptedPayloads)` | `BulkDecryptOperation` (thenable) |
-| `getEncryptConfig` | `()` | The resolved encrypt config |
+Yes. It integrates with Supabase Auth and runs alongside RLS — it complements them, it doesn't replace
+them.
+
-The thenable operations support `.withLockContext(lockContext)` for identity-aware encryption, and `decryptModel` / `bulkDecryptModels` also support `.audit({ metadata })`. Those two additionally accept the lock context as an optional third argument — use one form or the other. `decrypt` of a single value cannot be strongly typed (TypeScript cannot know which column a runtime payload came from), and `encryptQuery` rejects storage-only columns at compile time.
+
+I already use Row Level Security — do I need this?
-**`decrypt` / `bulkDecrypt` do not reconstruct `Date` values.** A `types.Date` / `types.Timestamp` column read through the raw path comes back as the string it was stored as; read through `decryptModel` / `bulkDecryptModels` with the table, it comes back as a `Date`. Reconstruction is driven by the table's `cast_as`, which only the model path is handed. Use the model helpers when you want the column's declared plaintext type, or rebuild at the call site with `new Date(value)`.
+RLS and CipherStash solve different problems, and they're strongest together. RLS decides which rows a
+role may query, but the data underneath is plaintext — so anything that bypasses RLS reveals it in the
+clear: a leaked `service_role` key, a misconfigured policy, a SQL injection running as an elevated role,
+a stolen backup, or the database host itself. CipherStash stores only ciphertext and keeps the keys
+outside the database, so those same bypasses reveal nothing readable. Keep RLS for authorization; add
+CipherStash so a bypass never becomes a breach.
+
-### `LockContext` (legacy)
+
+Is there a free tier?
-Identity-aware encryption is done with `OidcFederationStrategy` +
-`.withLockContext({ identityClaim })` (see [Identity-Aware Encryption](#identity-aware-encryption-lock-contexts)).
-`LockContext` / `identify()` remain for backwards compatibility only — the
-per-operation CTS token `identify()` fetches was removed in `protect-ffi` 0.25
-and is no longer used by encryption.
+Yes — a free developer tier, so you can build encryption in from day one.
+
-### Schema Builders
+## Start free
-```typescript
-import { encryptedTable, types } from "@cipherstash/stack/eql/v3"
+Encryption is far cheaper to design in than to retrofit — and it's what unlocks regulated and enterprise
+customers. The developer tier is **free**, so you can add encryption from your very first migration:
-encryptedTable(tableName, columns) // columns: Record
-types.TextSearch("email") // one factory per public.eql_v3_* domain
+```bash
+npx stash init
```
-Type inference helpers live on the same subpath:
-
-```typescript
-import type { InferPlaintext, InferEncrypted } from "@cipherstash/stack/eql/v3"
+Signing up is the wizard's first step if you don't have an account yet — or
+[create your free account][signup] in the browser first, and `stash init` will pick it up.
-type UserPlaintext = InferPlaintext
-// { email: string; age: number; balance: bigint; metadata: JsonDocument }
+## Install
-type UserEncrypted = InferEncrypted
-// { email: Encrypted; age: Encrypted; ... }
+```bash
+npm install @cipherstash/stack # or: yarn / pnpm / bun add @cipherstash/stack
```
-## Subpath Exports
-
-| Import Path | Provides |
-|-------|-----|
-| `@cipherstash/stack/v3` | `Encryption`, `EncryptionClient`, and the EQL v3 authoring DSL |
-| `@cipherstash/stack/eql/v3` | EQL v3 authoring DSL: `encryptedTable`, the `types` namespace, `buildEncryptConfig`, inference types (`InferPlaintext`, `InferEncrypted`, ...) |
-| `@cipherstash/stack` | `Encryption` — the v3-only client factory — plus auth strategies |
-| `@cipherstash/stack/schema` | Low-level encrypt-config types and validation helpers |
-| `@cipherstash/stack/identity` | `LockContext` class and identity types |
-| `@cipherstash/stack/types` | All TypeScript types (`Encrypted`, `Decrypted`, `ClientConfig`, `EncryptionClientConfig`, query types, etc.) |
-
-The Drizzle and Supabase integrations are **separate first-party packages** that
-depend on `@cipherstash/stack` (they are no longer subpaths of it):
-
-| Package | Provides |
-|-------|-----|
-| `@cipherstash/stack-drizzle` | EQL v3 Drizzle integration (package root, v3 only): `types` column factories, `createEncryptionOperators`, `extractEncryptionSchema`, `makeEqlV3Column`, `EncryptionOperatorError` |
-| `@cipherstash/stack-supabase` | Supabase integration (v3 only): `encryptedSupabase` (`encryptedSupabaseV3` is a `@deprecated` alias) |
-
-## Legacy: EQL v2
-
-EQL v2 is retained only as native decrypt compatibility. Stored v2 payloads are
-recognised automatically by `decrypt`, `decryptModel`, `bulkDecrypt`, and
-`bulkDecryptModels`; there is no public v2 schema builder or write-mode flag.
-Author all schemas and new writes with EQL v3. DynamoDB legacy reads use a v3
-table descriptor plus `{ storedEqlVersion: 2 }`.
-
-Full v2 documentation lives at [cipherstash.com/docs](https://cipherstash.com/docs).
-
-### Migrating from @cipherstash/protect
-
-Method signatures on the encryption client (`encrypt`, `decrypt`,
-`encryptModel`, ...) and the `Result` pattern (`data` / `failure`) are unchanged.
-**Declare tables with the EQL v3 DSL.** A column's capabilities come from its `types.*` domain rather than
-chained tuners: `csColumn("email").equality().freeTextSearch()` becomes
-`types.TextSearch("email")`.
-
-| `@cipherstash/protect` | `@cipherstash/stack` | Import Path |
-|------------|-----------|-------|
-| `protect(config)` | `Encryption(config)` | `@cipherstash/stack` |
-| `csTable(name, cols)` | `encryptedTable(name, cols)` | `@cipherstash/stack/eql/v3` |
-| `csColumn(name)` | `types.(name)` (e.g. `types.TextSearch`) | `@cipherstash/stack/eql/v3` |
-| `import { LockContext } from "@cipherstash/protect/identify"` | `import { LockContext } from "@cipherstash/stack/identity"` | `@cipherstash/stack/identity` |
-| N/A | CLI | `npx stash` |
-
-## Requirements
-
-- **Node.js** >= 22
-- The default entry includes a native FFI module (`@cipherstash/protect-ffi`). On a Node server, externalize it from bundling (e.g. Next.js `serverExternalPackages`).
-- For bundled or non-Node runtimes (Deno, Bun, Cloudflare Workers, Supabase Edge Functions), import `@cipherstash/stack/wasm-inline` instead — it inlines the WASM build, so no externalization is needed. See the [bundling guide](https://cipherstash.com/docs/stack/deploy/bundling).
-
-## License
-
-MIT - see [LICENSE.md](https://github.com/cipherstash/stack/blob/main/LICENSE.md).
+> [!IMPORTANT]
+> **Opt out of bundling `@cipherstash/stack`.** It uses native Node.js features (a Rust FFI module) and the
+> native `require`. For edge and serverless runtimes (Cloudflare Workers, Deno, Bun), use the bundler-friendly
+> `@cipherstash/stack/wasm-inline` entry instead. [Bundling guide →][bundling]
+
+**Requirements:** Node.js ≥ 22.
+
+## Documentation & community
+
+- 📚 [Documentation][docs] · [Quickstart][quickstart] · [SDK reference][reference]
+- 🧩 [Example apps][examples]
+- 💬 [Discord community][discord]
+
+## Contributing · Security · License
+
+Contributions are welcome — see [CONTRIBUTE.md][contribute]. For our security policy and responsible
+disclosure, see [SECURITY.md][security-policy]. [MIT licensed][license].
+
+
+[signup]: https://cipherstash.com/signup?utm_source=github&utm_medium=stack_readme
+[docs]: https://cipherstash.com/docs/stack?utm_source=github&utm_medium=stack_readme
+[quickstart]: https://cipherstash.com/docs/stack/quickstart?utm_source=github&utm_medium=stack_readme
+[reference]: https://cipherstash.com/docs/stack/reference?utm_source=github&utm_medium=stack_readme
+[encryption]: https://cipherstash.com/docs/stack/cipherstash/encryption?utm_source=github&utm_medium=stack_readme
+[searchable-encryption]: https://cipherstash.com/docs/stack/cipherstash/encryption/searchable-encryption?utm_source=github&utm_medium=stack_readme
+[schema]: https://cipherstash.com/docs/stack/cipherstash/encryption/schema?utm_source=github&utm_medium=stack_readme
+[encrypt-decrypt]: https://cipherstash.com/docs/stack/cipherstash/encryption/encrypt-decrypt?utm_source=github&utm_medium=stack_readme
+[model-ops]: https://cipherstash.com/docs/stack/cipherstash/encryption/encrypt-decrypt?utm_source=github&utm_medium=stack_readme#model-operations
+[supabase]: https://cipherstash.com/docs/stack/cipherstash/encryption/supabase?utm_source=github&utm_medium=stack_readme
+[drizzle]: https://cipherstash.com/docs/stack/cipherstash/encryption/drizzle?utm_source=github&utm_medium=stack_readme
+[prisma-next]: https://cipherstash.com/docs/stack/cipherstash/encryption/prisma-next?utm_source=github&utm_medium=stack_readme
+[dynamodb]: https://cipherstash.com/docs/stack/cipherstash/encryption/dynamodb?utm_source=github&utm_medium=stack_readme
+[identity]: https://cipherstash.com/docs/stack/cipherstash/encryption/identity?utm_source=github&utm_medium=stack_readme
+[security-architecture]: https://cipherstash.com/docs/stack/reference/security-architecture?utm_source=github&utm_medium=stack_readme
+[zerokms]: https://cipherstash.com/docs/stack/cipherstash/kms?utm_source=github&utm_medium=stack_readme
+[bundling]: https://cipherstash.com/docs/stack/deploy/bundling?utm_source=github&utm_medium=stack_readme
+[query-equality]: https://cipherstash.com/docs/stack/cipherstash/encryption/searchable-encryption?utm_source=github&utm_medium=stack_readme#equality
+[query-match]: https://cipherstash.com/docs/stack/cipherstash/encryption/searchable-encryption?utm_source=github&utm_medium=stack_readme#free-text-search
+[query-range]: https://cipherstash.com/docs/stack/cipherstash/encryption/searchable-encryption?utm_source=github&utm_medium=stack_readme#range-and-ordering
+[query-json]: https://cipherstash.com/docs/stack/cipherstash/encryption/searchable-encryption?utm_source=github&utm_medium=stack_readme#json
+[auth]: https://cipherstash.com/docs/stack/cipherstash/encryption/identity?utm_source=github&utm_medium=stack_readme
+[keysets]: https://cipherstash.com/docs/stack/cipherstash/kms?utm_source=github&utm_medium=stack_readme#keysets
+[benches]: https://github.com/cipherstash/benches
+[eql]: https://github.com/cipherstash/encrypt-query-language
+[discord]: https://discord.gg/5qwXUFb6PB
+[examples]: https://github.com/cipherstash/stack/tree/main/examples
+[contribute]: https://github.com/cipherstash/stack/blob/main/CONTRIBUTE.md
+[security-policy]: https://github.com/cipherstash/stack/blob/main/SECURITY.md
+[license]: https://github.com/cipherstash/stack/blob/main/LICENSE.md
diff --git a/packages/stack/__tests__/readme-sync.test.ts b/packages/stack/__tests__/readme-sync.test.ts
new file mode 100644
index 000000000..23047a723
--- /dev/null
+++ b/packages/stack/__tests__/readme-sync.test.ts
@@ -0,0 +1,31 @@
+/**
+ * Guards the npm README against drifting from the root README.
+ *
+ * `packages/stack/README.md` is a synced copy of the repo root `README.md` —
+ * the root file is the single source of truth, and the `prebuild` script
+ * copies it into the package before every build (npm cannot publish a
+ * symlink, so a real file must ship in the tarball). If someone edits the
+ * root README and the copy isn't rebuilt/committed, or edits the package
+ * copy directly, this test fails rather than publishing a stale README.
+ */
+
+import { readFileSync } from 'node:fs'
+import { fileURLToPath } from 'node:url'
+import { describe, expect, it } from 'vitest'
+
+describe('package README', () => {
+ it('is an exact copy of the root README (synced by prebuild)', () => {
+ const pkgReadme = readFileSync(
+ fileURLToPath(new URL('../README.md', import.meta.url)),
+ 'utf8',
+ )
+ const rootReadme = readFileSync(
+ fileURLToPath(new URL('../../../README.md', import.meta.url)),
+ 'utf8',
+ )
+ expect(
+ pkgReadme,
+ 'packages/stack/README.md has drifted from the root README — run `pnpm --filter @cipherstash/stack build` and commit the synced copy',
+ ).toBe(rootReadme)
+ })
+})
diff --git a/packages/stack/package.json b/packages/stack/package.json
index 694319c85..233de71a1 100644
--- a/packages/stack/package.json
+++ b/packages/stack/package.json
@@ -178,7 +178,7 @@
"./package.json": "./package.json"
},
"scripts": {
- "prebuild": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\"",
+ "prebuild": "node -e \"const fs=require('node:fs');fs.rmSync('dist',{recursive:true,force:true});fs.copyFileSync('../../README.md','README.md')\"",
"build": "tsup",
"dev": "tsup --watch",
"analyze:complexity": "fta src/eql/v3 --score-cap 69",
From 881085de2873131901096a745c8e7c5d942496de Mon Sep 17 00:00:00 2001
From: Dan Draper
Date: Thu, 30 Jul 2026 15:57:58 +1000
Subject: [PATCH 16/18] docs(readme): address CodeRabbit review nits
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- keysets: 'undecryptable' implied permanent destruction — grant
revocation is reversible, so say unreadable until the grant is
restored
- hyphenate 'ORM/database-specific guides'
- language tag on the ASCII-diagram fence in readme-visual-assets
- re-sync packages/stack/README.md
---
README.md | 6 +++---
docs/plans/readme-visual-assets.md | 2 +-
packages/stack/README.md | 6 +++---
3 files changed, 7 insertions(+), 7 deletions(-)
diff --git a/README.md b/README.md
index 044d10e66..008155c61 100644
--- a/README.md
+++ b/README.md
@@ -43,7 +43,7 @@ Takes 5-10 minutes, starts on the **free developer tier** ([sign up][signup]), i
npx stash init
```
-### ORM/database specific guides
+### ORM/database-specific guides
| Quick start | Guide |
|---|---|
@@ -163,8 +163,8 @@ await client
Partition your keys into **keysets** — independent key hierarchies within a single workspace. Give each
tenant its own keyset (`config.keyset`) for cryptographic tenant isolation: every encrypt, decrypt, and
-query is scoped to a keyset, so revoking a keyset's access makes that tenant's data undecryptable —
-without re-architecting your app. [Keysets →][keysets]
+query is scoped to a keyset, so revoking a client's access to a keyset makes that tenant's data
+unreadable until the grant is restored — without re-architecting your app. [Keysets →][keysets]
## Encrypted fields. Real queries. Your tools.
diff --git a/docs/plans/readme-visual-assets.md b/docs/plans/readme-visual-assets.md
index 1fc40a02e..57b4511b1 100644
--- a/docs/plans/readme-visual-assets.md
+++ b/docs/plans/readme-visual-assets.md
@@ -35,7 +35,7 @@ database only ever holds ciphertext; every decryption is audited.*
**Layout (left → right data flow):**
-```
+```text
┌─────────────────────────┐ ciphertext ┌──────────────────────────┐
│ YOUR APP (TypeScript) │ ── EQL JSON payload ──▶ │ YOUR DATABASE │
│ @cipherstash/stack │ │ PostgreSQL / JSONB │
diff --git a/packages/stack/README.md b/packages/stack/README.md
index 044d10e66..008155c61 100644
--- a/packages/stack/README.md
+++ b/packages/stack/README.md
@@ -43,7 +43,7 @@ Takes 5-10 minutes, starts on the **free developer tier** ([sign up][signup]), i
npx stash init
```
-### ORM/database specific guides
+### ORM/database-specific guides
| Quick start | Guide |
|---|---|
@@ -163,8 +163,8 @@ await client
Partition your keys into **keysets** — independent key hierarchies within a single workspace. Give each
tenant its own keyset (`config.keyset`) for cryptographic tenant isolation: every encrypt, decrypt, and
-query is scoped to a keyset, so revoking a keyset's access makes that tenant's data undecryptable —
-without re-architecting your app. [Keysets →][keysets]
+query is scoped to a keyset, so revoking a client's access to a keyset makes that tenant's data
+unreadable until the grant is restored — without re-architecting your app. [Keysets →][keysets]
## Encrypted fields. Real queries. Your tools.
From 13edce0f10e4c62fa840bc17c723eb5f9f8c8968 Mon Sep 17 00:00:00 2001
From: Dan Draper
Date: Thu, 30 Jul 2026 16:07:22 +1000
Subject: [PATCH 17/18] docs(adapters): refresh the stack-supabase,
stack-drizzle, stack-prisma READMEs
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
These ship on each package's npm page. stack-supabase was a 44-line stub;
it's now a full README in the stack-drizzle style: the introspecting
encryptedSupabase(url, key) factory, the encrypted filter surface, the
EQL 3.0.2 PostgREST limitations, quick start, and how-it-works.
stack-drizzle's hero example called ops.contains on a text column —
contains is encrypted-JSONB containment (types.Json / ste_vec only,
split from matches in #617) and would throw at runtime; the free-text
operator is ops.matches. The operator table listed contains under
free-text match too; fixed.
stack-prisma claimed 'six encrypted column types' — the catalog is
domain-named factories across text/integer/smallint/bigint/numeric/
real/double/date/timestamp/boolean/JSON. Also fixed the authentication
docs URL (missing the /cipherstash/ segment) and replaced relative
links, which 404 on npm because repository.directory scopes them to
the package folder.
All three gain the root README's badge header and the theme/viewport-
responsive architecture diagram (absolute raw.githubusercontent URLs —
they 404 until this PR's SVGs merge to main, same as the root README).
---
.changeset/adapter-readmes-refresh.md | 22 ++++
packages/stack-drizzle/README.md | 28 +++--
packages/stack-prisma/README.md | 39 +++++--
packages/stack-supabase/README.md | 146 +++++++++++++++++++++-----
4 files changed, 195 insertions(+), 40 deletions(-)
create mode 100644 .changeset/adapter-readmes-refresh.md
diff --git a/.changeset/adapter-readmes-refresh.md b/.changeset/adapter-readmes-refresh.md
new file mode 100644
index 000000000..893559a40
--- /dev/null
+++ b/.changeset/adapter-readmes-refresh.md
@@ -0,0 +1,22 @@
+---
+'@cipherstash/stack-supabase': patch
+'@cipherstash/stack-drizzle': patch
+'@cipherstash/stack-prisma': patch
+---
+
+Refresh the adapter READMEs (they ship on each package's npm page):
+
+- **stack-supabase**: full rewrite — the old README was a stub. Now covers the
+ introspecting `encryptedSupabase(url, key)` factory, the encrypted filter
+ surface (`eq`/`neq`/`in`/`match`, range, `order()` on OPE-backed columns),
+ the EQL 3.0.2 PostgREST limitations, and the quick start.
+- **stack-drizzle**: fix the hero example — `ops.contains` is encrypted-JSONB
+ containment (a `types.Json` column) and would throw on a text column; the
+ free-text operator is `ops.matches`. The operator table no longer lists
+ `contains` under free-text match.
+- **stack-prisma**: correct the encrypted-column-type catalog (domain-named
+ factories across text/integer/float/numeric/date/timestamp/boolean/JSON, not
+ "six types"), fix the authentication docs URL, and replace relative links
+ (which 404 on npm) with absolute ones.
+- All three: add the badge header and the architecture diagram from the root
+ README.
diff --git a/packages/stack-drizzle/README.md b/packages/stack-drizzle/README.md
index d261a97d1..59ba18b8d 100644
--- a/packages/stack-drizzle/README.md
+++ b/packages/stack-drizzle/README.md
@@ -1,7 +1,14 @@
-# @cipherstash/stack-drizzle
+
+
@cipherstash/stack-drizzle
-**Searchable, application-level encryption for [Drizzle ORM][drizzle-orm] and PostgreSQL**,
-from [CipherStash Stack][stack-repo].
+
## Why
@@ -35,7 +42,7 @@ const rows = await db
.select()
.from(users)
.where(await ops.and(
- ops.contains(users.email, 'alice'), // free-text match, on ciphertext
+ ops.matches(users.email, 'alice'), // free-text match, on ciphertext
ops.between(users.age, 18, 65), // range, on ciphertext
))
.orderBy(ops.asc(users.age)) // ordered by encrypted value
@@ -47,7 +54,7 @@ The operators mirror Drizzle's and encrypt their operands transparently:
|---|---|---|
| **Equality** | `ops.eq`, `ops.ne`, `ops.inArray` | [Equality queries →][query-equality] |
| **Range & ordering** | `ops.gt`/`gte`/`lt`/`lte`, `ops.between`, `ops.asc`/`desc` | [Range & ordering →][query-range] |
-| **Free-text match** | `ops.matches`, `ops.contains` | [Text search →][query-match] |
+| **Free-text match** | `ops.matches` | [Text search →][query-match] |
| **Encrypted JSON** | `ops.contains` (containment), `ops.selector(col, path)` | [JSON →][query-json] |
Each column's query capabilities are fixed by its type, so an unsupported operation is rejected
@@ -154,6 +161,15 @@ expression indexes instead; the bundled `stash-indexing` agent skill has the ful
## How it works
+
+
+
+
+
+
+
+
+
Every value is encrypted into an [EQL][eql] payload: the ciphertext plus the *searchable
terms* its column type declares — an HMAC term for equality, an order-preserving term for
range and sorting, a bloom filter for text match, a structured-encryption vector for JSON.
@@ -174,8 +190,6 @@ install needs no superuser.
> Not to be confused with `@cipherstash/drizzle`, the older `@cipherstash/protect`-based
> package — deprecated and no longer maintained; this package replaces it.
-[drizzle-orm]: https://orm.drizzle.team
-[stack-repo]: https://github.com/cipherstash/stack
[eql]: https://github.com/cipherstash/encrypt-query-language
[signup]: https://cipherstash.com/signup?utm_source=github&utm_medium=stack_drizzle_readme
[zerokms]: https://cipherstash.com/docs/stack/cipherstash/kms?utm_source=github&utm_medium=stack_drizzle_readme
diff --git a/packages/stack-prisma/README.md b/packages/stack-prisma/README.md
index 050361dbb..cc7367fe1 100644
--- a/packages/stack-prisma/README.md
+++ b/packages/stack-prisma/README.md
@@ -1,6 +1,14 @@
-# @cipherstash/stack-prisma
+
+
@cipherstash/stack-prisma
-**Searchable field-level encryption for Postgres with [Prisma Next](https://www.npmjs.com/package/prisma-next)** — powered by [`@cipherstash/stack`](../stack/README.md) and the [EQL bundle](https://cipherstash.com/docs/stack/platform/eql).
+
Declare encrypted columns directly in `schema.prisma`, and the framework's migration system installs the EQL bundle in the same control-plane sweep that creates your tables. No separate "install EQL" step.
@@ -8,7 +16,7 @@ Declare encrypted columns directly in `schema.prisma`, and the framework's migra
## Features
-- 🔒 Six encrypted column types — `string`, `double`, `bigint`, `date`, `boolean`, `json`
+- 🔒 Domain-named encrypted column types for text, integers, floats, numerics, dates, timestamps, booleans, and JSON — the name encodes the query capability (`cipherstash.TextSearch()`, `cipherstash.DoubleOrd()`, …)
- 🔍 Searchable encryption — equality, free-text search, range, order, JSON path and containment
- 🎯 Type-safe query operators — the EQL-derived `eql*` vocabulary (`eqlEq`, `eqlMatch`, `eqlGt`, `eqlAsc`, …)
- ⚡ Bulk encrypt / bulk decrypt coalescing — one SDK round-trip per `(table, column)` group per query
@@ -110,21 +118,38 @@ There are 2 main ways to authenticate to CipherStash:
### Env vars (Production)
-The four `CS_*` env vars (`CS_WORKSPACE_CRN`, `CS_CLIENT_ID`, `CS_CLIENT_KEY`, `CS_CLIENT_ACCESS_KEY`) are reserved for production deployments and CI runners. See the [authentication docs](https://cipherstash.com/docs/stack/encryption/prisma-next#authentication) for more information.
+The four `CS_*` env vars (`CS_WORKSPACE_CRN`, `CS_CLIENT_ID`, `CS_CLIENT_KEY`, `CS_CLIENT_ACCESS_KEY`) are reserved for production deployments and CI runners. See the [authentication docs](https://cipherstash.com/docs/stack/cipherstash/encryption/prisma-next#authentication) for more information.
## Example
-A runnable end-to-end example lives at [`examples/prisma/`](../../examples/prisma/) — bundles a docker-compose Postgres, a six-codec `User` schema, and a flow that exercises every operator category against a live ZeroKMS workspace.
+A runnable end-to-end example lives at [`examples/prisma/`](https://github.com/cipherstash/stack/tree/main/examples/prisma) — bundles a docker-compose Postgres, a six-codec `User` schema, and a flow that exercises every operator category against a live ZeroKMS workspace.
+
+## How it works
+
+
+
+
+
+
+
+
+
+
+Encryption happens in your application: the codecs encrypt on write and the `eql*` operators encrypt
+their query operands, so only ciphertext ([EQL](https://github.com/cipherstash/encrypt-query-language)
+payloads in `eql_v3_*` column domains) ever reaches Postgres. Per-value keys are issued in bulk by
+[ZeroKMS](https://cipherstash.com/docs/stack/cipherstash/kms?utm_source=github&utm_medium=stack_prisma_readme),
+plaintext and keys never reach CipherStash, and every decryption is logged for audit.
## Contributing
-See [`DEVELOPING.md`](./DEVELOPING.md) for the source layout, two-pass codec encode + middleware rewrite lifecycle, physical-column-name routing, the `bigint → Number` SDK boundary, and other runtime-side details.
+See [`DEVELOPING.md`](https://github.com/cipherstash/stack/blob/main/packages/stack-prisma/DEVELOPING.md) for the source layout, two-pass codec encode + middleware rewrite lifecycle, physical-column-name routing, the `bigint → Number` SDK boundary, and other runtime-side details.
## References
- 📖 [**Full docs**](https://cipherstash.com/docs/stack/cipherstash/encryption/prisma-next) — column types, operator reference, security model, known limitations.
- [CipherStash EQL reference](https://cipherstash.com/docs/stack/platform/eql) — encrypted operator semantics and search-config index types.
-- [`@cipherstash/stack`](../stack/README.md) — encryption SDK and schema DSL.
+- [`@cipherstash/stack`](https://www.npmjs.com/package/@cipherstash/stack) — encryption SDK and schema DSL.
- [Prisma Next](https://www.npmjs.com/package/prisma-next) — the framework this extension plugs into.
## License
diff --git a/packages/stack-supabase/README.md b/packages/stack-supabase/README.md
index 70aaf1981..7c5d04895 100644
--- a/packages/stack-supabase/README.md
+++ b/packages/stack-supabase/README.md
@@ -1,44 +1,138 @@
-# @cipherstash/stack-supabase
+
+
@cipherstash/stack-supabase
-Supabase integration for [CipherStash Stack](https://www.npmjs.com/package/@cipherstash/stack) —
-transparent, searchable field-level encryption on top of a Supabase (PostgREST) client.
+
-```bash
-npm install @cipherstash/stack @cipherstash/stack-supabase @supabase/supabase-js
-```
+## Why
+
+Anyone with database access — a leaked `service_role` key, a misconfigured RLS policy, a SQL
+injection, a stolen backup — normally sees everything. CipherStash encrypts each value with its
+own key before it leaves your app, so what reaches Supabase is ciphertext; you can only decrypt
+what you're explicitly authorized to, and every decryption is audited.
-## EQL v3
+The trick is queries still work: searchable encrypted terms let equality and range filters run
+against native Postgres indexes without decrypting the table. RLS stays exactly where it is —
+this complements authorization, it doesn't replace it.
+[Security architecture →][security-architecture]
-`encryptedSupabase` introspects the database at connect time (native
-`public.eql_v3_*` column domains) — no schema argument, `select('*')` support,
-equality/range filters, and encrypted `order()` on OPE columns.
+## Encrypted columns. Real Supabase queries.
-EQL 3.0.2 requires typed query-domain operands for encrypted free-text and JSON
-operators. PostgREST cannot express those casts, so v3 `matches()`, encrypted
-`contains()`, and `selectorEq()`/`selectorNe()` fail fast with this EQL release.
-Use the Drizzle or Prisma Next adapter, or a carefully scoped direct SQL/RPC
-path.
+The `email` and `amount` columns below are stored as ciphertext with a unique key per row — and
+the same Supabase.js calls keep working, because the filters run on the ciphertext:
```ts
import { encryptedSupabase } from '@cipherstash/stack-supabase'
const es = await encryptedSupabase(supabaseUrl, supabaseKey)
-await es.from('users').select('id, email').eq('email', 'a@b.com')
+
+// Insert — encrypted transparently on the way in
+await es.from('users').insert({ email: 'alice@example.com', amount: 30 })
+
+// Query — filters encrypted on the way in, results decrypted on the way out
+const { data } = await es
+ .from('users')
+ .select('id, email, amount')
+ .eq('email', 'alice@example.com') // encrypted equality — runs on ciphertext
+ .gte('amount', 10) // encrypted range
```
-`encryptedSupabaseV3` remains as a `@deprecated`, type-identical alias of
-`encryptedSupabase`, so existing imports keep working.
+You can also wrap an existing client: `await encryptedSupabase(supabaseClient, options)`.
+
+| Query type | Filters | Notes |
+|---|---|---|
+| **Equality** | `.eq`, `.neq`, `.in`, `.match({ … })` | on equality-capable domains |
+| **Range** | `.gt` / `.gte` / `.lt` / `.lte` | on `*Ord` domains |
+| **Ordering** | `.order()` | on OPE-backed encrypted ordering columns (and plaintext columns) |
+| **Compound** | `.or(…)` | over the filters above |
+
+Each column's query capabilities are fixed by its `eql_v3_*` type, so an unsupported operation is
+rejected loudly instead of silently scanning.
+
+> **PostgREST limitation (EQL 3.0.2).** Encrypted free-text `matches()`, encrypted JSON
+> `contains()`, and `selectorEq()`/`selectorNe()` need typed query-domain casts that PostgREST
+> cannot express, so they fail fast with this EQL release. Use the
+> [Drizzle][stack-drizzle] or [Prisma][stack-prisma] adapter, or a carefully scoped SQL/RPC
+> path, for those query shapes. Plaintext `like`/`ilike` on encrypted columns is rejected by
+> design.
+
+## Quick start
+
+About five minutes, starting on the **free developer tier** ([sign up][signup]). The setup wizard
+handles authentication, the EQL install, and your schema:
+
+```bash
+npx stash init
+```
-Introspection needs a direct Postgres connection (`DATABASE_URL`), so `pg` is an
-optional peer and the factory cannot run in a Worker or the browser.
+Or install manually (this package depends on `@cipherstash/stack`; install all three):
+
+```bash
+npm install @cipherstash/stack @cipherstash/stack-supabase @supabase/supabase-js
+```
+
+Full guide: [Supabase quickstart →][supabase-docs]
+
+## How the wrapper works
+
+`encryptedSupabase` **introspects your database at connect time**: it discovers the native
+`public.eql_v3_*` column domains, so there is no schema argument and no client-side column
+config to maintain — `select('*')` just works, inserts and updates encrypt automatically, and
+reads decrypt automatically.
+
+Introspection needs a direct Postgres connection (`DATABASE_URL`), so `pg` is an optional peer
+dependency and the factory cannot run in an edge Worker or the browser — construct it in your
+server-side code.
+
+It runs alongside Supabase Auth and RLS, and supports
+[identity-locking encryption][identity] — binding a row's data key to the signed-in user's
+JWT claim — via the same lock-context API as the rest of the Stack.
+
+## How it works
+
+
+
+
+
+
+
+
+
+
+Every value is encrypted into an [EQL][eql] payload: the ciphertext plus the *searchable terms*
+its column type declares — an HMAC term for equality, an order-preserving term for range and
+sorting. The EQL SQL bundle defines the Postgres domains and operators, so an encrypted `.eq()`
+resolves to a comparison of equality terms and engages a functional index. Keys come from
+[ZeroKMS][zerokms] — one per value — so a leaked key or a dumped table never exposes more than
+it should, and the EQL install needs no superuser (it works on cloud-hosted Supabase as-is).
## EQL v2 (removed)
-The legacy EQL v2 authoring wrapper — `encryptedSupabase({ encryptionClient,
-supabaseClient }).from(tableName, schema)` — has been removed; this package now
-authors and queries EQL v3 only. Migrate existing v2 columns to an `eql_v3_*`
-domain, or pin the last release that shipped the v2 wrapper.
+The legacy EQL v2 authoring wrapper — `encryptedSupabase({ encryptionClient, supabaseClient
+}).from(tableName, schema)` — has been removed; this package authors and queries EQL v3 only.
+Migrate existing v2 columns to an `eql_v3_*` domain, or pin the last release that shipped the
+v2 wrapper. `encryptedSupabaseV3` remains as a `@deprecated`, type-identical alias of
+`encryptedSupabase`, so existing imports keep working.
+
+## Docs
+
+- [Supabase integration guide →][supabase-docs]
+- [Searchable encryption concepts →][searchable-encryption]
+- [Security architecture →][security-architecture]
+- The bundled `stash-supabase` agent skill, installed into your repo by `stash init`
-See the `stash-supabase` agent skill and https://cipherstash.com/docs for the full guide.
+[signup]: https://cipherstash.com/signup?utm_source=github&utm_medium=stack_supabase_readme
+[supabase-docs]: https://cipherstash.com/docs/stack/cipherstash/encryption/supabase?utm_source=github&utm_medium=stack_supabase_readme
+[searchable-encryption]: https://cipherstash.com/docs/stack/cipherstash/encryption/searchable-encryption?utm_source=github&utm_medium=stack_supabase_readme
+[security-architecture]: https://cipherstash.com/docs/stack/reference/security-architecture?utm_source=github&utm_medium=stack_supabase_readme
+[identity]: https://cipherstash.com/docs/stack/cipherstash/encryption/identity?utm_source=github&utm_medium=stack_supabase_readme
+[zerokms]: https://cipherstash.com/docs/stack/cipherstash/kms?utm_source=github&utm_medium=stack_supabase_readme
+[eql]: https://github.com/cipherstash/encrypt-query-language
+[stack-drizzle]: https://www.npmjs.com/package/@cipherstash/stack-drizzle
+[stack-prisma]: https://www.npmjs.com/package/@cipherstash/stack-prisma
From 9d5d891daf1cfcb71d1a6feb6eeafd93d818cb65 Mon Sep 17 00:00:00 2001
From: Dan Draper
Date: Thu, 30 Jul 2026 16:10:54 +1000
Subject: [PATCH 18/18] docs(stack-supabase): drop the EQL v2 section from the
README
The v2 wrapper is long gone; the README doesn't need to re-litigate it.
The encryptedSupabaseV3 deprecated-alias note moves into the wrapper
section since it's current API surface, not v2 history.
---
packages/stack-supabase/README.md | 11 +++--------
1 file changed, 3 insertions(+), 8 deletions(-)
diff --git a/packages/stack-supabase/README.md b/packages/stack-supabase/README.md
index 7c5d04895..8669a81fd 100644
--- a/packages/stack-supabase/README.md
+++ b/packages/stack-supabase/README.md
@@ -94,6 +94,9 @@ It runs alongside Supabase Auth and RLS, and supports
[identity-locking encryption][identity] — binding a row's data key to the signed-in user's
JWT claim — via the same lock-context API as the rest of the Stack.
+> `encryptedSupabaseV3` remains as a `@deprecated`, type-identical alias of `encryptedSupabase`,
+> so existing imports keep working.
+
## How it works
@@ -112,14 +115,6 @@ resolves to a comparison of equality terms and engages a functional index. Keys
[ZeroKMS][zerokms] — one per value — so a leaked key or a dumped table never exposes more than
it should, and the EQL install needs no superuser (it works on cloud-hosted Supabase as-is).
-## EQL v2 (removed)
-
-The legacy EQL v2 authoring wrapper — `encryptedSupabase({ encryptionClient, supabaseClient
-}).from(tableName, schema)` — has been removed; this package authors and queries EQL v3 only.
-Migrate existing v2 columns to an `eql_v3_*` domain, or pin the last release that shipped the
-v2 wrapper. `encryptedSupabaseV3` remains as a `@deprecated`, type-identical alias of
-`encryptedSupabase`, so existing imports keep working.
-
## Docs
- [Supabase integration guide →][supabase-docs]