@cipherstash/stack 1.0.0-rc.0 → 1.0.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/README.md +321 -239
  3. package/dist/adapter-kit.cjs +83 -4
  4. package/dist/adapter-kit.cjs.map +1 -1
  5. package/dist/adapter-kit.d.cts +41 -3
  6. package/dist/adapter-kit.d.ts +41 -3
  7. package/dist/adapter-kit.js +78 -3
  8. package/dist/adapter-kit.js.map +1 -1
  9. package/dist/{chunk-V2Q3NYIH.js → chunk-6SGN52W6.js} +1 -1
  10. package/dist/{chunk-V2Q3NYIH.js.map → chunk-6SGN52W6.js.map} +1 -1
  11. package/dist/{chunk-HQANMV7R.js → chunk-BCY6WB24.js} +3 -3
  12. package/dist/{chunk-HQANMV7R.js.map → chunk-BCY6WB24.js.map} +1 -1
  13. package/dist/{chunk-LBMC4D6D.js → chunk-NVKK7UDN.js} +1 -1
  14. package/dist/chunk-NVKK7UDN.js.map +1 -0
  15. package/dist/{chunk-OFQ555AX.js → chunk-Q4WTOYVI.js} +3 -3
  16. package/dist/{chunk-ZTP5QJ27.js → chunk-UI7V2QCK.js} +6 -6
  17. package/dist/{chunk-ZTP5QJ27.js.map → chunk-UI7V2QCK.js.map} +1 -1
  18. package/dist/{columns-rZc7fQHI.d.ts → columns-0lbT9stl.d.ts} +2 -2
  19. package/dist/{columns-D2_YzrCX.d.cts → columns-Bxv7Oo9o.d.cts} +2 -2
  20. package/dist/dynamodb/index.cjs +2 -2
  21. package/dist/dynamodb/index.cjs.map +1 -1
  22. package/dist/dynamodb/index.js +1 -1
  23. package/dist/encryption/index.cjs +4 -4
  24. package/dist/encryption/index.cjs.map +1 -1
  25. package/dist/encryption/index.js +4 -4
  26. package/dist/encryption/v3.cjs +4 -4
  27. package/dist/encryption/v3.cjs.map +1 -1
  28. package/dist/encryption/v3.d.cts +2 -2
  29. package/dist/encryption/v3.d.ts +2 -2
  30. package/dist/encryption/v3.js +5 -5
  31. package/dist/eql/v3/index.cjs.map +1 -1
  32. package/dist/eql/v3/index.d.cts +2 -2
  33. package/dist/eql/v3/index.d.ts +2 -2
  34. package/dist/eql/v3/index.js +1 -1
  35. package/dist/errors/index.cjs.map +1 -1
  36. package/dist/errors/index.d.cts +5 -5
  37. package/dist/errors/index.d.ts +5 -5
  38. package/dist/errors/index.js +1 -1
  39. package/dist/identity/index.cjs +2 -2
  40. package/dist/identity/index.cjs.map +1 -1
  41. package/dist/identity/index.js +3 -3
  42. package/dist/index.cjs +4 -4
  43. package/dist/index.cjs.map +1 -1
  44. package/dist/index.js +4 -4
  45. package/dist/wasm-inline.d.ts +243 -58
  46. package/dist/wasm-inline.js +268 -0
  47. package/dist/wasm-inline.js.map +1 -1
  48. package/package.json +16 -16
  49. package/dist/chunk-LBMC4D6D.js.map +0 -1
  50. /package/dist/{chunk-OFQ555AX.js.map → chunk-Q4WTOYVI.js.map} +0 -0
package/README.md CHANGED
@@ -6,7 +6,7 @@ The all-in-one TypeScript SDK for the CipherStash data security stack.
6
6
  [![License: MIT](https://img.shields.io/npm/l/@cipherstash/stack.svg?style=for-the-badge&labelColor=000000)](https://github.com/cipherstash/stack/blob/main/LICENSE.md)
7
7
  [![TypeScript](https://img.shields.io/badge/TypeScript-first-blue?style=for-the-badge&labelColor=000000)](https://www.typescriptlang.org/)
8
8
 
9
- --
9
+ ---
10
10
 
11
11
  ## Table of Contents
12
12
 
@@ -16,17 +16,18 @@ The all-in-one TypeScript SDK for the CipherStash data security stack.
16
16
  - [Schema Definition](#schema-definition)
17
17
  - [Encryption and Decryption](#encryption-and-decryption)
18
18
  - [Searchable Encryption](#searchable-encryption)
19
- - [Identity-Aware Encryption](#identity-aware-encryption)
19
+ - [Authentication](#authentication)
20
+ - [Identity-Aware Encryption](#identity-aware-encryption-lock-contexts)
20
21
  - [CLI Reference](#cli-reference)
21
22
  - [Configuration](#configuration)
22
23
  - [Error Handling](#error-handling)
23
24
  - [API Reference](#api-reference)
24
25
  - [Subpath Exports](#subpath-exports)
25
- - [Migration from @cipherstash/protect](#migration-from-cipherstashprotect)
26
+ - [Legacy: EQL v2](#legacy-eql-v2)
26
27
  - [Requirements](#requirements)
27
28
  - [License](#license)
28
29
 
29
- --
30
+ ---
30
31
 
31
32
  ## Install
32
33
 
@@ -53,17 +54,19 @@ The wizard will authenticate you, walk you through choosing a database connectio
53
54
 
54
55
  ### 2. Encrypt and decrypt
55
56
 
57
+ Define a table with concrete EQL v3 column types, build the typed client, and encrypt:
58
+
56
59
  ```typescript
57
- import { Encryption } from "@cipherstash/stack"
58
- import { encryptedTable, encryptedColumn } from "@cipherstash/stack/schema"
60
+ import { EncryptionV3 } from "@cipherstash/stack/v3"
61
+ import { encryptedTable, types } from "@cipherstash/stack/eql/v3"
59
62
 
60
- // Define a schema
63
+ // Define a schema — the column type fixes its query capabilities
61
64
  const users = encryptedTable("users", {
62
- email: encryptedColumn("email").equality().freeTextSearch(),
65
+ email: types.TextSearch("email"), // equality + order/range + free-text search
63
66
  })
64
67
 
65
- // Create a client
66
- const client = await Encryption({ schemas: [users] })
68
+ // Create a typed client
69
+ const client = await EncryptionV3({ schemas: [users] })
67
70
 
68
71
  // Encrypt a value
69
72
  const encrypted = await client.encrypt("hello@example.com", {
@@ -71,114 +74,169 @@ const encrypted = await client.encrypt("hello@example.com", {
71
74
  table: users,
72
75
  })
73
76
 
77
+ // Every operation returns `{ data } | { failure }`. Narrow on `.failure` and
78
+ // return/throw before reading `.data` — the failure branch has no `data`.
74
79
  if (encrypted.failure) {
75
- console.error("Encryption failed:", encrypted.failure.message)
76
- } else {
77
- console.log("Encrypted payload:", encrypted.data)
80
+ throw new Error(`Encryption failed: ${encrypted.failure.message}`)
78
81
  }
82
+ console.log("Encrypted payload:", encrypted.data)
79
83
 
80
84
  // Decrypt the value
81
85
  const decrypted = await client.decrypt(encrypted.data)
82
-
83
86
  if (decrypted.failure) {
84
- console.error("Decryption failed:", decrypted.failure.message)
85
- } else {
86
- console.log("Plaintext:", decrypted.data) // "hello@example.com"
87
+ throw new Error(`Decryption failed: ${decrypted.failure.message}`)
87
88
  }
89
+ console.log("Plaintext:", decrypted.data) // "hello@example.com"
88
90
  ```
89
91
 
92
+ 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.
93
+
90
94
  ## Features
91
95
 
92
96
  - **Field-level encryption** - Every value encrypted with its own unique key via [ZeroKMS](https://cipherstash.com/products/zerokms), backed by AWS KMS.
93
- - **Searchable encryption** - Exact match, free-text search, order/range queries, and encrypted JSONB queries in PostgreSQL.
97
+ - **Searchable encryption** - Exact match, free-text search, order/range queries, and encrypted JSON queries in PostgreSQL, driven by concrete EQL v3 column types.
98
+ - **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.
94
99
  - **Bulk operations** - Encrypt or decrypt thousands of values in a single ZeroKMS call (`bulkEncrypt`, `bulkDecrypt`, `bulkEncryptModels`, `bulkDecryptModels`).
95
- - **Identity-aware encryption** - Tie encryption to a user's JWT via `LockContext`, so only that user can decrypt.
100
+ - **Identity-aware encryption** - Tie encryption to a user's JWT via `OidcFederationStrategy` and `.withLockContext()`, so only that user can decrypt.
96
101
  - **CLI (`stash`)** - Initialize projects and set up encryption from the terminal.
97
102
  - **TypeScript-first** - Strongly typed schemas, results, and model operations with full generics support.
98
103
 
99
104
  ## Schema Definition
100
105
 
101
- Define which tables and columns to encrypt using `encryptedTable` and `encryptedColumn` from `@cipherstash/stack/schema`.
106
+ 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_<name>` — 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`.
107
+
108
+ There are **no chainable capability methods** — the concrete type fully describes what a column can do.
102
109
 
103
110
  ```typescript
104
- import { encryptedTable, encryptedColumn } from "@cipherstash/stack/schema"
111
+ import { encryptedTable, types } from "@cipherstash/stack/eql/v3"
105
112
 
106
113
  const users = encryptedTable("users", {
107
- email: encryptedColumn("email")
108
- .equality() // exact-match queries
109
- .freeTextSearch() // full-text search queries
110
- .orderAndRange(), // sorting and range queries
111
- })
112
-
113
- const documents = encryptedTable("documents", {
114
- metadata: encryptedColumn("metadata")
115
- .searchableJson(), // encrypted JSONB queries (JSONPath + containment)
114
+ email: types.TextSearch("email"), // equality + order/range + free-text search
115
+ age: types.IntegerOrd("age"), // equality + order/range
116
+ balance: types.Bigint("balance"), // storage only — encrypt/decrypt, no queries
117
+ metadata: types.Json("metadata"), // encrypted JSON: containment + JSONPath selectors
116
118
  })
117
119
  ```
118
120
 
119
- ### Index Types
121
+ 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.
122
+
123
+ ### Capability Suffixes
124
+
125
+ The suffix on the type name encodes the query capability:
126
+
127
+ | Suffix | Capabilities | Query types |
128
+ |---|---|---|
129
+ | _(none)_ | Storage only — encrypt/decrypt, no queries | — |
130
+ | `Eq` | Equality | `'equality'` |
131
+ | `Ord` | Equality + ordering/range (OPE-backed) | `'equality'`, `'orderAndRange'` |
132
+ | `OrdOre` | Equality + ordering/range (block-ORE-backed — the ORE operator class is superuser-only and unavailable on managed Postgres such as Supabase) | `'equality'`, `'orderAndRange'` |
133
+ | `Match` (text only) | Free-text search only | `'freeTextSearch'` |
134
+ | `Search` (text only, as `TextSearch`) | Equality + ordering/range + free-text | all three |
135
+ | `Json` | Encrypted JSON containment + JSONPath selector queries | `'searchableJson'` |
136
+
137
+ Prefer the plain `Ord` domains unless you know your database supports the ORE operator class.
138
+
139
+ ### Domain Families and Plaintext Types
120
140
 
121
- | Method | Purpose | Query Type |
122
- |----|-----|------|
123
- | `.equality()` | Exact match lookups | `'equality'` |
124
- | `.freeTextSearch()` | Full-text / fuzzy search | `'freeTextSearch'` |
125
- | `.orderAndRange()` | Sorting, comparison, range queries | `'orderAndRange'` |
126
- | `.searchableJson()` | Encrypted JSONB path and containment queries | `'searchableJson'` |
127
- | `.dataType(cast)` | Set the plaintext data type (`'string'`, `'number'`, `'boolean'`, `'date'`, `'bigint'`, `'json'`) | N/A |
141
+ | Family | Factories | Plaintext (TypeScript) type |
142
+ |---|---|---|
143
+ | `Integer`, `Smallint`, `Numeric`, `Real`, `Double` | base, `Eq`, `Ord`, `OrdOre` | `number` |
144
+ | `Bigint` | base, `Eq`, `Ord`, `OrdOre` | `bigint` (native JS bigint, full i64 range) |
145
+ | `Date` | base, `Eq`, `Ord`, `OrdOre` | `Date` (calendar date; time-of-day truncated) |
146
+ | `Timestamp` | base, `Eq`, `Ord`, `OrdOre` | `Date` (time-of-day preserved) |
147
+ | `Text` | base, `Eq`, `Match`, `Ord`, `OrdOre`, `Search` | `string` |
148
+ | `Boolean` | base only | `boolean` |
149
+ | `Json` | `Json` only | a JSON *document* (object, array, or null — not a top-level scalar) |
150
+
151
+ ### Database Setup
152
+
153
+ Install the EQL v3 SQL into your database with the stash CLI:
154
+
155
+ ```bash
156
+ npx stash eql install --eql-version 3
157
+ # On Supabase, add --supabase to grant the anon/authenticated/service_role
158
+ # roles access to the eql_v3 schemas — without it, encrypted queries fail with
159
+ # "permission denied for schema eql_v3_internal":
160
+ npx stash eql install --eql-version 3 --supabase
161
+ ```
128
162
 
129
- Methods are chainable - call as many as you need on a single column.
163
+ In migrations, declare each encrypted column as its domain type:
164
+
165
+ ```sql
166
+ CREATE TABLE users (
167
+ id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
168
+ email public.eql_v3_text_search,
169
+ age public.eql_v3_integer_ord,
170
+ balance public.eql_v3_bigint,
171
+ metadata public.eql_v3_json
172
+ );
173
+ ```
130
174
 
131
175
  ## Encryption and Decryption
132
176
 
133
177
  ### Single Values
134
178
 
135
179
  ```typescript
136
- // Encrypt
180
+ // Encrypt — plaintext is pinned to the column's domain type
137
181
  const encrypted = await client.encrypt("secret@example.com", {
138
182
  column: users.email,
139
183
  table: users,
140
184
  })
141
185
 
142
- // Decrypt
186
+ // Decrypt (narrow on `.failure` before reading `.data`)
187
+ if (encrypted.failure) throw new Error(encrypted.failure.message)
143
188
  const decrypted = await client.decrypt(encrypted.data)
144
189
  ```
145
190
 
146
191
  ### Model Operations
147
192
 
148
- Encrypt or decrypt an entire object. Only fields matching your schema are encrypted; other fields pass through unchanged.
193
+ 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.
149
194
 
150
- The return type is **schema-aware**: fields matching the table schema are typed as `Encrypted`, while other fields retain their original types. For best results, let TypeScript infer the type parameters from the arguments:
195
+ `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`.
151
196
 
152
197
  ```typescript
153
- type User = { id: string; email: string; createdAt: Date }
154
-
155
198
  const user = {
156
- id: "user_123",
157
- email: "alice@example.com", // defined in schema -> encrypted
158
- createdAt: new Date(), // not in schema -> unchanged
199
+ id: "user_123", // not in schema -> passes through
200
+ email: "alice@example.com", // TextSearch -> encrypted as string
201
+ age: 30, // IntegerOrd -> encrypted as number
202
+ balance: 100_000n, // Bigint -> encrypted as bigint
159
203
  }
160
204
 
161
- // Let TypeScript infer the return type from the schema
162
205
  const encryptedResult = await client.encryptModel(user, users)
163
- // encryptedResult.data.email -> Encrypted
164
- // encryptedResult.data.id -> string
165
- // encryptedResult.data.createdAt -> Date
206
+ // encryptedResult.data.email -> Encrypted
207
+ // encryptedResult.data.id -> string
166
208
 
167
- // Decrypt a model
168
- const decryptedResult = await client.decryptModel(encryptedResult.data)
209
+ if (encryptedResult.failure) throw new Error(encryptedResult.failure.message)
210
+ const decryptedResult = await client.decryptModel(encryptedResult.data, users)
211
+ // decryptedResult.data.email -> string
212
+ // decryptedResult.data.balance -> bigint
169
213
  ```
170
214
 
171
215
  ### Bulk Operations
172
216
 
173
217
  All bulk methods make a single call to ZeroKMS regardless of the number of records, while still using a unique key per value.
174
218
 
219
+ #### Bulk Encrypt / Decrypt Models
220
+
221
+ ```typescript
222
+ const userModels = [
223
+ { id: "1", email: "alice@example.com", age: 30, balance: 100_000n },
224
+ { id: "2", email: "bob@example.com", age: 41, balance: 250_000n },
225
+ ]
226
+
227
+ const encrypted = await client.bulkEncryptModels(userModels, users)
228
+ if (encrypted.failure) throw new Error(encrypted.failure.message)
229
+ const decrypted = await client.bulkDecryptModels(encrypted.data, users)
230
+ ```
231
+
175
232
  #### Bulk Encrypt / Decrypt (raw values)
176
233
 
234
+ `bulkEncrypt` / `bulkDecrypt` are untyped passthroughs for raw value arrays:
235
+
177
236
  ```typescript
178
237
  const plaintexts = [
179
238
  { id: "u1", plaintext: "alice@example.com" },
180
239
  { id: "u2", plaintext: "bob@example.com" },
181
- { id: "u3", plaintext: "charlie@example.com" },
182
240
  ]
183
241
 
184
242
  const encrypted = await client.bulkEncrypt(plaintexts, {
@@ -188,7 +246,9 @@ const encrypted = await client.bulkEncrypt(plaintexts, {
188
246
 
189
247
  // encrypted.data = [{ id: "u1", data: EncryptedPayload }, ...]
190
248
 
249
+ if (encrypted.failure) throw new Error(encrypted.failure.message)
191
250
  const decrypted = await client.bulkDecrypt(encrypted.data)
251
+ if (decrypted.failure) throw new Error(decrypted.failure.message)
192
252
 
193
253
  // Each item has either { data: "plaintext" } or { error: "message" }
194
254
  for (const item of decrypted.data) {
@@ -200,175 +260,91 @@ for (const item of decrypted.data) {
200
260
  }
201
261
  ```
202
262
 
203
- #### Bulk Encrypt / Decrypt Models
204
-
205
- ```typescript
206
- const userModels = [
207
- { id: "1", email: "alice@example.com" },
208
- { id: "2", email: "bob@example.com" },
209
- ]
210
-
211
- const encrypted = await client.bulkEncryptModels(userModels, users)
212
- const decrypted = await client.bulkDecryptModels(encrypted.data)
213
- ```
214
-
215
263
  ## Searchable Encryption
216
264
 
217
- Encrypt a query term so you can search encrypted data in PostgreSQL.
265
+ 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.
218
266
 
219
267
  ```typescript
220
- // Equality query
268
+ // Equality
221
269
  const eqQuery = await client.encryptQuery("alice@example.com", {
222
270
  column: users.email,
223
271
  table: users,
224
272
  queryType: "equality",
225
273
  })
226
274
 
227
- // Free-text search
228
- const matchQuery = await client.encryptQuery("alice", {
275
+ // Free-text search — queryType is REQUIRED for a match term (see gotcha below)
276
+ const matchQuery = await client.encryptQuery("ali", {
229
277
  column: users.email,
230
278
  table: users,
231
279
  queryType: "freeTextSearch",
232
280
  })
233
281
 
234
282
  // Order and range
235
- const rangeQuery = await client.encryptQuery("alice@example.com", {
236
- column: users.email,
283
+ const rangeQuery = await client.encryptQuery(30, {
284
+ column: users.age,
237
285
  table: users,
238
286
  queryType: "orderAndRange",
239
287
  })
240
288
  ```
241
289
 
242
- ### Searchable JSON
243
-
244
- For columns using `.searchableJson()`, the query type is auto-inferred from the plaintext:
290
+ > **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.
245
291
 
246
- ```typescript
247
- // String -> JSONPath selector query
248
- const pathQuery = await client.encryptQuery("$.user.email", {
249
- column: documents.metadata,
250
- table: documents,
251
- })
292
+ Free-text search is fuzzy bloom-filter token matching, surfaced as `matches` in the Drizzle and Supabase adapters — 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.
252
293
 
253
- // Object/Array -> containment query
254
- const containsQuery = await client.encryptQuery({ role: "admin" }, {
255
- column: documents.metadata,
256
- table: documents,
257
- })
258
- ```
294
+ ### Encrypted JSON
259
295
 
260
- ### Batch Query Encryption
296
+ 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` value. Two query patterns are supported:
261
297
 
262
- Encrypt multiple query terms in one call:
298
+ **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"`:
263
299
 
264
300
  ```typescript
265
- const terms = [
266
- { value: "alice@example.com", column: users.email, table: users, queryType: "equality" as const },
267
- { value: "bob", column: users.email, table: users, queryType: "freeTextSearch" as const },
268
- ]
269
-
270
- const results = await client.encryptQuery(terms)
271
- ```
301
+ const events = encryptedTable("events", { metadata: types.Json("metadata") })
272
302
 
273
- ### Query Result Formatting (`returnType`)
274
-
275
- By default `encryptQuery` returns an `Encrypted` object (the raw EQL JSON payload). Use `returnType` to change the output format:
276
-
277
- | `returnType` | Output | Use case |
278
- |---|---|---|
279
- | `'eql'` (default) | `Encrypted` object | Parameterized queries, ORMs accepting JSON |
280
- | `'composite-literal'` | `string` | Supabase `.eq()`, string-based APIs |
281
- | `'escaped-composite-literal'` | `string` | Embedding inside another string or JSON value |
282
-
283
- ```typescript
284
- // Get a composite literal string for use with Supabase
285
- const term = await client.encryptQuery("alice@example.com", {
286
- column: users.email,
287
- table: users,
288
- queryType: "equality",
289
- returnType: "composite-literal",
290
- })
291
-
292
- // term.data is a string — use directly with .eq()
293
- await supabase.from("users").select().eq("email", term.data)
303
+ const containsQuery = await client.encryptQuery(
304
+ { roles: ["admin"] },
305
+ { column: events.metadata, table: events }, // queryType inferred: 'searchableJson'
306
+ )
294
307
  ```
295
308
 
296
- Each term in a batch can have its own `returnType`.
309
+ **JSONPath selectors** equality and ordering at a path (`$.a`, `$.a.b` dot-notation object paths):
297
310
 
298
- ### Ordering Encrypted Data
299
-
300
- **`ORDER BY` on encrypted columns requires operator family support in the database.**
311
+ - **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 })`.
312
+ - **Supabase**: `selectorEq(col, path, value)` and `selectorNe(col, path, value)`.
301
313
 
302
- On databases without operator families (e.g. Supabase, or when EQL is installed with `--exclude-operator-family`), sorting on encrypted columns is not currently supported — regardless of the client or ORM used. Sort application-side after decrypting the results as a workaround.
314
+ Two semantics to know:
303
315
 
304
- Operator family support for Supabase is being developed in collaboration with the Supabase and CipherStash teams and will be available in a future release.
316
+ - **`ne` includes absent paths.** A "not equal at path" query also matches rows where the path does not exist at all.
317
+ - **Array-leaf caveat:** a scalar needle does not match an array at the path. `selectorEq("payload", "$.roles", "admin")` does *not* match `{ roles: ["admin", "analyst"] }` — use containment for membership tests.
305
318
 
306
- ### PostgreSQL / Drizzle Integration Pattern
319
+ `types.Json` carries no equality or ordering on the document itself, so applying `eq` / `gt` / `asc` directly to a `Json` column throws.
307
320
 
308
- Encrypted data is stored as an [EQL](https://github.com/cipherstash/encrypt-query-language) JSON payload. Install the EQL extension in PostgreSQL to enable searchable queries, then store encrypted data in `eql_v2_encrypted` columns.
321
+ ### Batch Query Encryption
309
322
 
310
- The `@cipherstash/stack-drizzle` module provides `encryptedType` for defining encrypted columns and `createEncryptionOperators` for querying them:
323
+ Encrypt multiple query terms in one call:
311
324
 
312
325
  ```typescript
313
- import { pgTable, integer, timestamp } from "drizzle-orm/pg-core"
314
- import { encryptedType, extractEncryptionSchema, createEncryptionOperators } from "@cipherstash/stack-drizzle"
315
- import { Encryption } from "@cipherstash/stack"
316
-
317
- // Define schema with encrypted columns
318
- const usersTable = pgTable("users", {
319
- id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
320
- email: encryptedType<string>("email", {
321
- equality: true,
322
- freeTextSearch: true,
323
- orderAndRange: true,
324
- }),
325
- profile: encryptedType<{ name: string; bio: string }>("profile", {
326
- dataType: "json",
327
- searchableJson: true,
328
- }),
329
- })
330
-
331
- // Initialize
332
- const usersSchema = extractEncryptionSchema(usersTable)
333
- const client = await Encryption({ schemas: [usersSchema] })
334
- const ops = createEncryptionOperators(client)
335
-
336
- // Query with auto-encrypting operators
337
- const results = await db.select().from(usersTable)
338
- .where(await ops.eq(usersTable.email, "alice@example.com"))
326
+ const terms = [
327
+ { value: "alice@example.com", column: users.email, table: users, queryType: "equality" as const },
328
+ { value: "bob", column: users.email, table: users, queryType: "freeTextSearch" as const },
329
+ ]
339
330
 
340
- // JSONB queries on encrypted JSON columns
341
- const jsonResults = await db.select().from(usersTable)
342
- .where(await ops.jsonbPathExists(usersTable.profile, "$.bio"))
331
+ const results = await client.encryptQuery(terms)
343
332
  ```
344
333
 
345
- #### Drizzle `encryptedType` Config Options
346
-
347
- | Option | Type | Description |
348
- |---|---|---|
349
- | `dataType` | `"string"` \| `"number"` \| `"json"` | Plaintext data type (default: `"string"`) |
350
- | `equality` | `boolean` \| `TokenFilter[]` | Enable equality index |
351
- | `freeTextSearch` | `boolean` \| `MatchIndexOpts` | Enable free-text search index |
352
- | `orderAndRange` | `boolean` | Enable ORE index for sorting/range queries |
353
- | `searchableJson` | `boolean` | Enable JSONB path queries (requires `dataType: "json"`) |
354
-
355
- #### Drizzle JSONB Operators
334
+ ### Ordering Encrypted Data
356
335
 
357
- For columns with `searchableJson: true`, three JSONB operators are available:
336
+ `ORDER BY` works on encrypted ordering columns via the domain's order term:
358
337
 
359
- | Operator | Description |
360
- |---|---|
361
- | `jsonbPathExists(col, selector)` | Check if a JSONB path exists (boolean, use in `WHERE`) |
362
- | `jsonbPathQueryFirst(col, selector)` | Extract first value at a JSONB path |
363
- | `jsonbGet(col, selector)` | Get value using the JSONB `->` operator |
338
+ - **Drizzle**: `ops.asc(col)` / `ops.desc(col)` emit `ORDER BY eql_v3.ord_term(col)` (or `eql_v3.ord_term_ore` for ORE domains).
339
+ - **Supabase**: `.order()` works on OPE-backed ordering columns — every plain `*Ord` domain plus `TextSearch`.
364
340
 
365
- These operators encrypt the JSON path selector using the `steVecSelector` query type and cast it to `eql_v2_encrypted` for use with the EQL PostgreSQL functions.
341
+ 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.
366
342
 
367
- ### EQL v3 Concrete-Type Columns (Drizzle)
343
+ ### Drizzle Integration
368
344
 
369
- The `@cipherstash/stack-drizzle/v3` module targets EQL v3, where each encrypted column is a **concrete Postgres domain type** (`eql_v3.text_eq`, `eql_v3.integer_ord`, …). Instead of toggling capability flags, you pick a concrete type from the `types` namespace and its query capabilities are fixed by that choice. It is the `/v3` subpath of the same `@cipherstash/stack-drizzle` package whose v2 surface is shown above.
345
+ The `@cipherstash/stack-drizzle/v3` subpath (of the separate `@cipherstash/stack-drizzle` package) provides Drizzle-native column factories, schema extraction, and auto-encrypting, capability-checked query operators.
370
346
 
371
- Declare a Drizzle table using the `types` factories. The suffix encodes the capability: `*Eq` (equality), `*Ord` (order + range, which also covers equality), `*Match` / `TextSearch` (free-text search), and the bare name (e.g. `Text`, `Bigint`) is storage-only.
347
+ 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.:
372
348
 
373
349
  ```ts
374
350
  import { pgTable, integer } from "drizzle-orm/pg-core"
@@ -385,12 +361,12 @@ const users = pgTable("users", {
385
361
  id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
386
362
  email: types.TextEq("email"), // equality: eq / ne / inArray
387
363
  age: types.IntegerOrd("age"), // order + range: gt/gte/lt/lte, between, asc/desc
388
- bio: types.TextMatch("bio"), // free-text search: contains
364
+ bio: types.TextMatch("bio"), // free-text search: matches
389
365
  balance: types.Bigint("balance"), // storage only (no query capability)
390
366
  })
391
367
  ```
392
368
 
393
- Derive the v3 schema from the table, build the typed client, and create the capability-checked operators:
369
+ Derive the v3 schema from the table, build the typed client, and create the operators:
394
370
 
395
371
  ```ts
396
372
  const usersSchema = extractEncryptionSchemaV3(users)
@@ -419,9 +395,9 @@ const midBand = await db.select().from(users)
419
395
  const listed = await db.select().from(users)
420
396
  .where(await ops.inArray(users.email, ["alice@example.com", "bob@example.com"]))
421
397
 
422
- // Free-text token containment — bio is TextMatch
398
+ // Free-text token match — bio is TextMatch
423
399
  const coffee = await db.select().from(users)
424
- .where(await ops.contains(users.bio, "coffee"))
400
+ .where(await ops.matches(users.bio, "coffee"))
425
401
  ```
426
402
 
427
403
  Rows are **pre-encrypted** with `client.bulkEncryptModels(...)` before they reach `db.insert(...).values(...)` — Drizzle never sees plaintext. `Bigint` columns take a native JS `bigint`:
@@ -441,41 +417,99 @@ await db.insert(users).values(rows.data)
441
417
 
442
418
  Notes:
443
419
 
444
- - **Free-text search uses `ops.contains`** (token containment on a `match` index), not SQL `like` / `ilike`. It matches whole indexed tokens, so `contains(users.bio, "coffee")` finds rows whose `bio` contains the token `coffee`.
445
- - **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`; `*Match` and `TextSearch` add `contains`; a bare `Text` / `Integer` / `Bigint` column is storage-only. Using an unsupported operator throws `EncryptionOperatorError`.
420
+ - **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.
421
+ - **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`.
446
422
  - 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).
447
423
 
448
- ## Identity-Aware Encryption
424
+ ### Supabase Integration
449
425
 
450
- Lock encryption to a specific user by requiring a valid JWT for decryption.
426
+ `encryptedSupabaseV3` 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:
451
427
 
452
428
  ```typescript
453
- import { LockContext } from "@cipherstash/stack/identity"
429
+ import { encryptedSupabaseV3 } from "@cipherstash/stack-supabase"
454
430
 
455
- // 1. Create a lock context (defaults to the "sub" claim)
456
- const lc = new LockContext()
431
+ const es = await encryptedSupabaseV3(supabaseUrl, supabaseKey)
457
432
 
458
- // 2. Identify the user with their JWT
459
- const identifyResult = await lc.identify(userJwt)
433
+ await es.from("users").insert({ email: "a@b.com", age: 30 })
434
+ await es.from("users").select("id, email").eq("email", "a@b.com")
435
+ await es.from("users").select("id, age").gte("age", 18).order("age")
436
+ ```
460
437
 
461
- if (identifyResult.failure) {
462
- throw new Error(identifyResult.failure.message)
463
- }
438
+ Encrypted free-text search is `matches()` (fuzzy bloom token search — `contains()` stays native, exact containment for plaintext columns), encrypted-JSON path queries are `selectorEq()` / `selectorNe()`, and `order()` works on OPE-backed ordering columns. 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.
439
+
440
+ ## Authentication
441
+
442
+ The client authenticates to ZeroKMS through `config.authStrategy`. Leave it
443
+ unset for the default **auto** strategy: in local development, authenticate
444
+ once with `npx stash auth login` (preferred — no credentials in your
445
+ environment); in CI/production, set the `CS_*` environment variables. Two
446
+ explicit strategies cover the other cases:
464
447
 
465
- const lockContext = identifyResult.data
448
+ - **`AccessKeyStrategy`** service-to-service / CI. Authenticates a *service*
449
+ with a CipherStash access key.
450
+ - **`OidcFederationStrategy`** — authenticates the client **as the end user**
451
+ by federating a third-party OIDC JWT (Clerk, Supabase, Auth0, Okta, ...)
452
+ into a CipherStash service token:
453
+
454
+ ```typescript
455
+ import { OidcFederationStrategy } from "@cipherstash/stack"
456
+ import { EncryptionV3 } from "@cipherstash/stack/v3"
457
+
458
+ // The callback is re-invoked on every (re-)federation and must return the
459
+ // CURRENT third-party OIDC JWT.
460
+ const strategy = OidcFederationStrategy.create(
461
+ process.env.CS_WORKSPACE_CRN!,
462
+ () => getUserJwt(),
463
+ )
464
+ if (strategy.failure) throw new Error(strategy.failure.error.message)
465
+
466
+ const client = await EncryptionV3({
467
+ schemas: [users],
468
+ config: { authStrategy: strategy.data },
469
+ })
470
+ ```
471
+
472
+ Authentication stands on its own — an OIDC-authenticated client encrypts and
473
+ decrypts normally. Binding *data* to the authenticated user is a separate,
474
+ optional step: the lock context, below.
475
+
476
+ ## Identity-Aware Encryption (Lock Contexts)
477
+
478
+ Bind a data key to a claim from the end user's JWT, so only that user can
479
+ decrypt. Chain `.withLockContext({ identityClaim })` on any operation:
480
+
481
+ ```typescript
482
+ // Requires a client authenticated with OidcFederationStrategy (above) — the
483
+ // claim's value resolves from the federated JWT.
484
+ const IDENTITY = { identityClaim: ["sub"] }
466
485
 
467
- // 3. Encrypt with lock context
468
486
  const encrypted = await client
469
487
  .encrypt("sensitive data", { column: users.email, table: users })
470
- .withLockContext(lockContext)
488
+ .withLockContext(IDENTITY)
489
+ if (encrypted.failure) throw new Error(encrypted.failure.message)
471
490
 
472
- // 4. Decrypt with the same lock context
473
491
  const decrypted = await client
474
492
  .decrypt(encrypted.data)
475
- .withLockContext(lockContext)
493
+ .withLockContext(IDENTITY)
476
494
  ```
477
495
 
478
- Lock contexts work with all operations: `encrypt`, `decrypt`, `encryptModel`, `decryptModel`, `bulkEncryptModels`, `bulkDecryptModels`, `bulkEncrypt`, `bulkDecrypt`.
496
+ Lock contexts **require** an `OidcFederationStrategy`-authenticated client
497
+ (the auto and access-key strategies authenticate no end user, so there is no
498
+ JWT to resolve claims from); plain authentication never requires a lock
499
+ context.
500
+
501
+ `identityClaim` is an array of JWT claim *names* (`["sub"]`), not values, and the
502
+ same claim must be supplied to encrypt and decrypt. Lock contexts work with all
503
+ operations: `encrypt`, `decrypt`, `encryptModel`, `decryptModel`,
504
+ `bulkEncryptModels`, `bulkDecryptModels`, `bulkEncrypt`, `bulkDecrypt`,
505
+ `encryptQuery`. `.withLockContext()` also accepts a `LockContext` instance.
506
+ On the typed client, `decryptModel` / `bulkDecryptModels` take the lock
507
+ context as an optional third argument instead of chaining.
508
+
509
+ > **Deprecated: `LockContext.identify()`.** Per-operation CTS tokens were removed
510
+ > in `protect-ffi` 0.25; the token `identify()` fetches is no longer used by
511
+ > encryption. Authenticate with `OidcFederationStrategy` and pass the claim
512
+ > directly, as above.
479
513
 
480
514
  ## CLI Reference
481
515
 
@@ -502,22 +536,24 @@ npx stash init --supabase
502
536
 
503
537
  The wizard will:
504
538
  1. Authenticate with CipherStash (device code flow)
505
- 2. Bind your device to the default Keyset
539
+ 2. Introspect your database and install the EQL v3 SQL
506
540
  3. Choose your database connection method (Drizzle ORM, Supabase JS, Prisma, or Raw SQL)
507
541
  4. Build an encryption schema interactively or use a placeholder, then generate the encryption client file
508
542
  5. Install `stash` as a dev dependency for database tooling
509
543
 
510
- After init, run `npx stash db setup` to configure your database.
544
+ `init` installs EQL for you — no separate `eql install` step is needed afterward.
511
545
 
512
546
  | Flag | Description |
513
547
  |------|-------------|
514
- | `--supabase` | Use Supabase-specific setup flow |
548
+ | `--supabase` / `--drizzle` / `--prisma-next` | Target a specific integration's setup flow |
549
+ | `--proxy` / `--no-proxy` | Opt in/out of the CipherStash Proxy path |
550
+ | `--region <slug>` | Workspace region (env `STASH_REGION`); **required for non-interactive init when not already logged in** |
515
551
 
516
552
  ## Configuration
517
553
 
518
554
  ### Local Development
519
555
 
520
- No environment variables or credentials are needed for local development. Run `npx @cipherstash/stack auth login` to authenticate via the device code flow, and the SDK and CLI will use the token saved to `~/.cipherstash/auth.json`.
556
+ 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`.
521
557
 
522
558
  ### Going to Production
523
559
 
@@ -537,10 +573,10 @@ See the [Going to Production](https://cipherstash.com/docs/stack/deploy/going-to
537
573
  Pass config directly when initializing the client:
538
574
 
539
575
  ```typescript
540
- import { Encryption } from "@cipherstash/stack"
576
+ import { EncryptionV3 } from "@cipherstash/stack/v3"
541
577
  import { users } from "./schema"
542
578
 
543
- const client = await Encryption({
579
+ const client = await EncryptionV3({
544
580
  schemas: [users],
545
581
  config: {
546
582
  workspaceCrn: "crn:ap-southeast-2.aws:your-workspace-id",
@@ -557,7 +593,7 @@ const client = await Encryption({
557
593
  Isolate encryption keys per tenant using keysets:
558
594
 
559
595
  ```typescript
560
- const client = await Encryption({
596
+ const client = await EncryptionV3({
561
597
  schemas: [users],
562
598
  config: {
563
599
  keyset: { id: "123e4567-e89b-12d3-a456-426614174000" },
@@ -565,7 +601,7 @@ const client = await Encryption({
565
601
  })
566
602
 
567
603
  // or by name
568
- const client2 = await Encryption({
604
+ const client2 = await EncryptionV3({
569
605
  schemas: [users],
570
606
  config: {
571
607
  keyset: { name: "Company A" },
@@ -622,73 +658,120 @@ if (result.failure) {
622
658
 
623
659
  ## API Reference
624
660
 
625
- ### `Encryption(config)` - Initialize the client
661
+ ### `EncryptionV3(config)` - Initialize the typed client
626
662
 
627
663
  ```typescript
628
- function Encryption(config: EncryptionClientConfig): Promise<EncryptionClient>
664
+ function EncryptionV3(config: {
665
+ schemas: AnyV3Table[]
666
+ config?: ClientConfig
667
+ }): Promise<TypedEncryptionClient>
629
668
  ```
630
669
 
631
- ### `EncryptionClient` Methods
670
+ The wire format is pinned to EQL v3 — you don't set it yourself. `typedClient(client, ...schemas)` (same subpath) wraps an already-built `EncryptionClient` in the typed surface.
671
+
672
+ ### `TypedEncryptionClient` Methods
673
+
674
+ 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.
632
675
 
633
676
  | Method | Signature | Returns |
634
677
  |----|------|-----|
635
678
  | `encrypt` | `(plaintext, { column, table })` | `EncryptOperation` (thenable) |
636
679
  | `decrypt` | `(encryptedData)` | `DecryptOperation` (thenable) |
637
680
  | `encryptQuery` | `(plaintext, { column, table, queryType?, returnType? })` | `EncryptQueryOperation` (thenable) |
681
+
682
+ `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.
638
683
  | `encryptQuery` | `(terms: ScalarQueryTerm[])` | `BatchEncryptQueryOperation` (thenable) |
639
- | `encryptModel` | `(model, table)` | `EncryptModelOperation<EncryptedFromSchema<T, S>>` (thenable) |
640
- | `decryptModel` | `(encryptedModel)` | `DecryptModelOperation<T>` (thenable) |
684
+ | `encryptModel` | `(model, table)` | `EncryptModelOperation` (thenable) |
685
+ | `decryptModel` | `(encryptedModel, table, lockContext?)` | `Promise<Result<...>>` |
686
+ | `bulkEncryptModels` | `(models, table)` | `BulkEncryptModelsOperation` (thenable) |
687
+ | `bulkDecryptModels` | `(encryptedModels, table, lockContext?)` | `Promise<Result<...>>` |
641
688
  | `bulkEncrypt` | `(plaintexts, { column, table })` | `BulkEncryptOperation` (thenable) |
642
689
  | `bulkDecrypt` | `(encryptedPayloads)` | `BulkDecryptOperation` (thenable) |
643
- | `bulkEncryptModels` | `(models, table)` | `BulkEncryptModelsOperation<EncryptedFromSchema<T, S>>` (thenable) |
644
- | `bulkDecryptModels` | `(encryptedModels)` | `BulkDecryptModelsOperation<T>` (thenable) |
690
+ | `getEncryptConfig` | `()` | The resolved encrypt config |
691
+
692
+ The thenable operations support `.withLockContext(lockContext)` for identity-aware encryption. `decryptModel` / `bulkDecryptModels` return a plain `Promise` instead — pass the lock context as the optional third argument. `decrypt` of a single value cannot be strongly typed (a lone ciphertext carries no column identity), and `encryptQuery` rejects storage-only columns at compile time.
693
+
694
+ ### `LockContext` (legacy)
645
695
 
646
- All operations are thenable (awaitable) and support `.withLockContext(lockContext)` for identity-aware encryption.
696
+ Identity-aware encryption is done with `OidcFederationStrategy` +
697
+ `.withLockContext({ identityClaim })` (see [Identity-Aware Encryption](#identity-aware-encryption-lock-contexts)).
698
+ `LockContext` / `identify()` remain for backwards compatibility only — the
699
+ per-operation CTS token `identify()` fetches was removed in `protect-ffi` 0.25
700
+ and is no longer used by encryption.
647
701
 
648
- ### `LockContext`
702
+ ### Schema Builders
649
703
 
650
704
  ```typescript
651
- import { LockContext } from "@cipherstash/stack/identity"
705
+ import { encryptedTable, types } from "@cipherstash/stack/eql/v3"
652
706
 
653
- const lc = new LockContext(options?)
654
- const result = await lc.identify(jwtToken)
707
+ encryptedTable(tableName, columns) // columns: Record<string, types.*(dbColumnName)>
708
+ types.TextSearch("email") // one factory per public.eql_v3_* domain
655
709
  ```
656
710
 
657
- ### Schema Builders
711
+ Type inference helpers live on the same subpath:
658
712
 
659
713
  ```typescript
660
- import { encryptedTable, encryptedColumn, csValue } from "@cipherstash/stack/schema"
714
+ import type { InferPlaintext, InferEncrypted } from "@cipherstash/stack/eql/v3"
661
715
 
662
- encryptedTable(tableName, columns)
663
- encryptedColumn(columnName) // returns EncryptedColumn
664
- csValue(valueName) // returns ProtectValue (for nested values)
716
+ type UserPlaintext = InferPlaintext<typeof users>
717
+ // { email: string; age: number; balance: bigint; metadata: JsonDocument }
718
+
719
+ type UserEncrypted = InferEncrypted<typeof users>
720
+ // { email: Encrypted; age: Encrypted; ... }
665
721
  ```
666
722
 
667
723
  ## Subpath Exports
668
724
 
669
725
  | Import Path | Provides |
670
726
  |-------|-----|
671
- | `@cipherstash/stack` | `Encryption` function (main entry point) |
672
- | `@cipherstash/stack/schema` | `encryptedTable`, `encryptedColumn`, `csValue`, schema types |
727
+ | `@cipherstash/stack/v3` | `EncryptionV3` typed client factory, `typedClient`, plus re-exports of the EQL v3 authoring DSL |
728
+ | `@cipherstash/stack/eql/v3` | EQL v3 authoring DSL: `encryptedTable`, the `types` namespace, `buildEncryptConfig`, inference types (`InferPlaintext`, `InferEncrypted`, ...) |
729
+ | `@cipherstash/stack` | `Encryption` function (legacy v2 entry point), auth strategies |
730
+ | `@cipherstash/stack/schema` | Legacy v2 schema builders (see [Legacy: EQL v2](#legacy-eql-v2)) |
673
731
  | `@cipherstash/stack/identity` | `LockContext` class and identity types |
674
732
  | `@cipherstash/stack/client` | Client-safe exports (schema builders and types only - no native FFI) |
675
733
  | `@cipherstash/stack/types` | All TypeScript types (`Encrypted`, `Decrypted`, `ClientConfig`, `EncryptionClientConfig`, query types, etc.) |
676
- | `@cipherstash/stack/v3` | `EncryptionV3` typed client plus the EQL v3 authoring DSL (`encryptedTable`, `types`, v3 type helpers) |
677
734
 
678
735
  The Drizzle and Supabase integrations are **separate first-party packages** that
679
736
  depend on `@cipherstash/stack` (they are no longer subpaths of it):
680
737
 
681
738
  | Package | Provides |
682
739
  |-------|-----|
683
- | `@cipherstash/stack-drizzle` | Drizzle ORM integration (EQL v2): `encryptedType`, `extractEncryptionSchema`, `createEncryptionOperators` |
684
740
  | `@cipherstash/stack-drizzle/v3` | EQL v3 Drizzle integration: `types` column factories, `createEncryptionOperatorsV3`, `extractEncryptionSchemaV3`, `makeEqlV3Column`, `EncryptionOperatorError` |
685
- | `@cipherstash/stack-supabase` | Supabase integration: `encryptedSupabase` (v2) and `encryptedSupabaseV3` (v3) |
686
-
687
- ## Migration from @cipherstash/protect
688
-
689
- If you are migrating from `@cipherstash/protect`, the following table maps the old API to the new one:
690
-
691
- | `@cipherstash/protect` | `@cipherstash/stack` | Import Path |
741
+ | `@cipherstash/stack-supabase` | Supabase integration: `encryptedSupabaseV3` (and the legacy v2 `encryptedSupabase`) |
742
+ | `@cipherstash/stack-drizzle` | Legacy EQL v2 Drizzle integration (root subpath): `encryptedType`, `extractEncryptionSchema`, `createEncryptionOperators` |
743
+
744
+ ## Legacy: EQL v2
745
+
746
+ Before the concrete-domain types above, encrypted columns were declared with
747
+ chainable capability builders and stored in a single `eql_v2_encrypted`
748
+ composite column type. That surface remains fully supported for existing
749
+ deployments, but new work should use EQL v3:
750
+
751
+ - **Client and schema**: `Encryption` from `@cipherstash/stack` with
752
+ `encryptedColumn("email").equality().freeTextSearch().orderAndRange()` and
753
+ `.searchableJson()` from `@cipherstash/stack/schema`. v2 and v3 tables cannot
754
+ be mixed in one client.
755
+ - **Query formatting**: v2 query terms can be rendered as strings with
756
+ `returnType: 'composite-literal'` / `'escaped-composite-literal'` for
757
+ string-based APIs.
758
+ - **Integrations**: the v2 Drizzle surface is the root of
759
+ `@cipherstash/stack-drizzle` (`encryptedType`, `extractEncryptionSchema`,
760
+ `createEncryptionOperators`); the v2 Supabase surface is `encryptedSupabase`.
761
+ - **DynamoDB still requires v2**: `encryptedDynamoDB` from
762
+ `@cipherstash/stack/dynamodb` works with the v2 API only — v3 support is
763
+ tracked in [#657](https://github.com/cipherstash/stack/issues/657).
764
+
765
+ Full v2 documentation lives at [cipherstash.com/docs](https://cipherstash.com/docs).
766
+
767
+ ### Migrating from @cipherstash/protect
768
+
769
+ `@cipherstash/protect` users land on the legacy v2 surface first — the mapping
770
+ below is 1:1, and method signatures on the encryption client (`encrypt`,
771
+ `decrypt`, `encryptModel`, etc.) and the `Result` pattern (`data` / `failure`)
772
+ are unchanged. From there, adopt EQL v3 for new tables:
773
+
774
+ | `@cipherstash/protect` | `@cipherstash/stack` (legacy v2) | Import Path |
692
775
  |------------|-----------|-------|
693
776
  | `protect(config)` | `Encryption(config)` | `@cipherstash/stack` |
694
777
  | `csTable(name, cols)` | `encryptedTable(name, cols)` | `@cipherstash/stack/schema` |
@@ -696,12 +779,11 @@ If you are migrating from `@cipherstash/protect`, the following table maps the o
696
779
  | `import { LockContext } from "@cipherstash/protect/identify"` | `import { LockContext } from "@cipherstash/stack/identity"` | `@cipherstash/stack/identity` |
697
780
  | N/A | CLI | `npx stash` |
698
781
 
699
- All method signatures on the encryption client (`encrypt`, `decrypt`, `encryptModel`, etc.) remain the same. The `Result` pattern (`data` / `failure`) is unchanged.
700
-
701
782
  ## Requirements
702
783
 
703
- - **Node.js** >= 18
704
- - The package includes a native FFI module (`@cipherstash/protect-ffi`) written in Rust and embedded via [Neon](https://github.com/neon-bindings/neon). You must opt out of bundling this package in tools like Webpack, esbuild, or Next.js (`serverExternalPackages`).
784
+ - **Node.js** >= 22
785
+ - The default entry includes a native FFI module (`@cipherstash/protect-ffi`). On a Node server, externalize it from bundling (e.g. Next.js `serverExternalPackages`).
786
+ - 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).
705
787
 
706
788
  ## License
707
789