@cipherstash/stack 0.19.0 → 1.0.0-rc.1

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 (101) hide show
  1. package/CHANGELOG.md +596 -0
  2. package/README.md +376 -276
  3. package/dist/adapter-kit.cjs +1002 -0
  4. package/dist/adapter-kit.cjs.map +1 -0
  5. package/dist/adapter-kit.d.cts +120 -0
  6. package/dist/adapter-kit.d.ts +120 -0
  7. package/dist/adapter-kit.js +122 -0
  8. package/dist/adapter-kit.js.map +1 -0
  9. package/dist/base-operation-AOAIvsSB.d.cts +32 -0
  10. package/dist/base-operation-FXEzUXIq.d.ts +32 -0
  11. package/dist/{chunk-4AVL4VZD.js → chunk-3B5ZX3IS.js} +3 -1
  12. package/dist/chunk-3B5ZX3IS.js.map +1 -0
  13. package/dist/{chunk-U66S7VIF.js → chunk-6SGN52W6.js} +166 -107
  14. package/dist/chunk-6SGN52W6.js.map +1 -0
  15. package/dist/chunk-7333ZC6L.js +48 -0
  16. package/dist/chunk-7333ZC6L.js.map +1 -0
  17. package/dist/{chunk-MP3SSDNN.js → chunk-CLM7E4I6.js} +15 -15
  18. package/dist/{chunk-MP3SSDNN.js.map → chunk-CLM7E4I6.js.map} +1 -1
  19. package/dist/{chunk-OFQ555AX.js → chunk-IDKP6ABU.js} +2 -2
  20. package/dist/{chunk-36AA7IBJ.js → chunk-L7ISHSG7.js} +53 -20
  21. package/dist/chunk-L7ISHSG7.js.map +1 -0
  22. package/dist/{chunk-LBMC4D6D.js → chunk-NVKK7UDN.js} +1 -1
  23. package/dist/chunk-NVKK7UDN.js.map +1 -0
  24. package/dist/chunk-X3JRXEIB.js +98 -0
  25. package/dist/chunk-X3JRXEIB.js.map +1 -0
  26. package/dist/client.cjs +29 -12
  27. package/dist/client.cjs.map +1 -1
  28. package/dist/client.d.cts +3 -2
  29. package/dist/client.d.ts +3 -2
  30. package/dist/client.js +2 -2
  31. package/dist/{table-CIH7jZ2h.d.ts → columns-0lbT9stl.d.ts} +157 -138
  32. package/dist/{table-DihEAlxG.d.cts → columns-Bxv7Oo9o.d.cts} +157 -138
  33. package/dist/dynamodb/index.d.cts +3 -2
  34. package/dist/dynamodb/index.d.ts +3 -2
  35. package/dist/encryption/index.cjs +54 -16
  36. package/dist/encryption/index.cjs.map +1 -1
  37. package/dist/encryption/index.d.cts +807 -6
  38. package/dist/encryption/index.d.ts +807 -6
  39. package/dist/encryption/index.js +6 -6
  40. package/dist/encryption/v3.cjs +1078 -973
  41. package/dist/encryption/v3.cjs.map +1 -1
  42. package/dist/encryption/v3.d.cts +15 -12
  43. package/dist/encryption/v3.d.ts +15 -12
  44. package/dist/encryption/v3.js +50 -22
  45. package/dist/encryption/v3.js.map +1 -1
  46. package/dist/eql/v3/index.cjs +130 -79
  47. package/dist/eql/v3/index.cjs.map +1 -1
  48. package/dist/eql/v3/index.d.cts +115 -13
  49. package/dist/eql/v3/index.d.ts +115 -13
  50. package/dist/eql/v3/index.js +16 -6
  51. package/dist/errors/index.cjs.map +1 -1
  52. package/dist/errors/index.d.cts +5 -5
  53. package/dist/errors/index.d.ts +5 -5
  54. package/dist/errors/index.js +1 -1
  55. package/dist/identity/index.cjs.map +1 -1
  56. package/dist/identity/index.js +2 -2
  57. package/dist/index-BquA71_Y.d.ts +24 -0
  58. package/dist/index-fhWTOV0K.d.cts +24 -0
  59. package/dist/index.cjs +79 -27
  60. package/dist/index.cjs.map +1 -1
  61. package/dist/index.d.cts +5 -17
  62. package/dist/index.d.ts +5 -17
  63. package/dist/index.js +6 -6
  64. package/dist/schema/index.cjs +29 -12
  65. package/dist/schema/index.cjs.map +1 -1
  66. package/dist/schema/index.d.cts +1 -1
  67. package/dist/schema/index.d.ts +1 -1
  68. package/dist/schema/index.js +2 -2
  69. package/dist/{types-public-CpS5KjwX.d.ts → types-public-QMjYNfQO.d.cts} +765 -726
  70. package/dist/{types-public-CpS5KjwX.d.cts → types-public-QMjYNfQO.d.ts} +765 -726
  71. package/dist/types-public.cjs.map +1 -1
  72. package/dist/types-public.d.cts +1 -1
  73. package/dist/types-public.d.ts +1 -1
  74. package/dist/types-public.js +1 -1
  75. package/dist/wasm-inline.d.ts +779 -405
  76. package/dist/wasm-inline.js +606 -299
  77. package/dist/wasm-inline.js.map +1 -1
  78. package/package.json +25 -53
  79. package/dist/chunk-36AA7IBJ.js.map +0 -1
  80. package/dist/chunk-4AVL4VZD.js.map +0 -1
  81. package/dist/chunk-IADZCZEA.js +0 -23
  82. package/dist/chunk-IADZCZEA.js.map +0 -1
  83. package/dist/chunk-IBSK6P33.js +0 -209
  84. package/dist/chunk-IBSK6P33.js.map +0 -1
  85. package/dist/chunk-LBMC4D6D.js.map +0 -1
  86. package/dist/chunk-U66S7VIF.js.map +0 -1
  87. package/dist/client-DSGHBN-g.d.cts +0 -834
  88. package/dist/client-DfCrlHXh.d.ts +0 -834
  89. package/dist/drizzle/index.cjs +0 -5617
  90. package/dist/drizzle/index.cjs.map +0 -1
  91. package/dist/drizzle/index.d.cts +0 -358
  92. package/dist/drizzle/index.d.ts +0 -358
  93. package/dist/drizzle/index.js +0 -1220
  94. package/dist/drizzle/index.js.map +0 -1
  95. package/dist/supabase/index.cjs +0 -5951
  96. package/dist/supabase/index.cjs.map +0 -1
  97. package/dist/supabase/index.d.cts +0 -223
  98. package/dist/supabase/index.d.ts +0 -223
  99. package/dist/supabase/index.js +0 -1217
  100. package/dist/supabase/index.js.map +0 -1
  101. /package/dist/{chunk-OFQ555AX.js.map → chunk-IDKP6ABU.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,18 +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)
20
- - [Secrets Management](#secrets-management)
19
+ - [Authentication](#authentication)
20
+ - [Identity-Aware Encryption](#identity-aware-encryption-lock-contexts)
21
21
  - [CLI Reference](#cli-reference)
22
22
  - [Configuration](#configuration)
23
23
  - [Error Handling](#error-handling)
24
24
  - [API Reference](#api-reference)
25
25
  - [Subpath Exports](#subpath-exports)
26
- - [Migration from @cipherstash/protect](#migration-from-cipherstashprotect)
26
+ - [Legacy: EQL v2](#legacy-eql-v2)
27
27
  - [Requirements](#requirements)
28
28
  - [License](#license)
29
29
 
30
- --
30
+ ---
31
31
 
32
32
  ## Install
33
33
 
@@ -54,17 +54,19 @@ The wizard will authenticate you, walk you through choosing a database connectio
54
54
 
55
55
  ### 2. Encrypt and decrypt
56
56
 
57
+ Define a table with concrete EQL v3 column types, build the typed client, and encrypt:
58
+
57
59
  ```typescript
58
- import { Encryption } from "@cipherstash/stack"
59
- import { encryptedTable, encryptedColumn } from "@cipherstash/stack/schema"
60
+ import { EncryptionV3 } from "@cipherstash/stack/v3"
61
+ import { encryptedTable, types } from "@cipherstash/stack/eql/v3"
60
62
 
61
- // Define a schema
63
+ // Define a schema — the column type fixes its query capabilities
62
64
  const users = encryptedTable("users", {
63
- email: encryptedColumn("email").equality().freeTextSearch(),
65
+ email: types.TextSearch("email"), // equality + order/range + free-text search
64
66
  })
65
67
 
66
- // Create a client
67
- const client = await Encryption({ schemas: [users] })
68
+ // Create a typed client
69
+ const client = await EncryptionV3({ schemas: [users] })
68
70
 
69
71
  // Encrypt a value
70
72
  const encrypted = await client.encrypt("hello@example.com", {
@@ -72,115 +74,169 @@ const encrypted = await client.encrypt("hello@example.com", {
72
74
  table: users,
73
75
  })
74
76
 
77
+ // Every operation returns `{ data } | { failure }`. Narrow on `.failure` and
78
+ // return/throw before reading `.data` — the failure branch has no `data`.
75
79
  if (encrypted.failure) {
76
- console.error("Encryption failed:", encrypted.failure.message)
77
- } else {
78
- console.log("Encrypted payload:", encrypted.data)
80
+ throw new Error(`Encryption failed: ${encrypted.failure.message}`)
79
81
  }
82
+ console.log("Encrypted payload:", encrypted.data)
80
83
 
81
84
  // Decrypt the value
82
85
  const decrypted = await client.decrypt(encrypted.data)
83
-
84
86
  if (decrypted.failure) {
85
- console.error("Decryption failed:", decrypted.failure.message)
86
- } else {
87
- console.log("Plaintext:", decrypted.data) // "hello@example.com"
87
+ throw new Error(`Decryption failed: ${decrypted.failure.message}`)
88
88
  }
89
+ console.log("Plaintext:", decrypted.data) // "hello@example.com"
89
90
  ```
90
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
+
91
94
  ## Features
92
95
 
93
96
  - **Field-level encryption** - Every value encrypted with its own unique key via [ZeroKMS](https://cipherstash.com/products/zerokms), backed by AWS KMS.
94
- - **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.
95
99
  - **Bulk operations** - Encrypt or decrypt thousands of values in a single ZeroKMS call (`bulkEncrypt`, `bulkDecrypt`, `bulkEncryptModels`, `bulkDecryptModels`).
96
- - **Identity-aware encryption** - Tie encryption to a user's JWT via `LockContext`, so only that user can decrypt.
97
- - **Secrets management** - Store, retrieve, list, and delete encrypted secrets with the `Secrets` class.
98
- - **CLI (`stash`)** - Initialize projects, manage secrets, and set up encryption from the terminal.
100
+ - **Identity-aware encryption** - Tie encryption to a user's JWT via `OidcFederationStrategy` and `.withLockContext()`, so only that user can decrypt.
101
+ - **CLI (`stash`)** - Initialize projects and set up encryption from the terminal.
99
102
  - **TypeScript-first** - Strongly typed schemas, results, and model operations with full generics support.
100
103
 
101
104
  ## Schema Definition
102
105
 
103
- 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.
104
109
 
105
110
  ```typescript
106
- import { encryptedTable, encryptedColumn } from "@cipherstash/stack/schema"
111
+ import { encryptedTable, types } from "@cipherstash/stack/eql/v3"
107
112
 
108
113
  const users = encryptedTable("users", {
109
- email: encryptedColumn("email")
110
- .equality() // exact-match queries
111
- .freeTextSearch() // full-text search queries
112
- .orderAndRange(), // sorting and range queries
113
- })
114
-
115
- const documents = encryptedTable("documents", {
116
- metadata: encryptedColumn("metadata")
117
- .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
118
118
  })
119
119
  ```
120
120
 
121
- ### 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
122
140
 
123
- | Method | Purpose | Query Type |
124
- |----|-----|------|
125
- | `.equality()` | Exact match lookups | `'equality'` |
126
- | `.freeTextSearch()` | Full-text / fuzzy search | `'freeTextSearch'` |
127
- | `.orderAndRange()` | Sorting, comparison, range queries | `'orderAndRange'` |
128
- | `.searchableJson()` | Encrypted JSONB path and containment queries | `'searchableJson'` |
129
- | `.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
+ ```
162
+
163
+ In migrations, declare each encrypted column as its domain type:
130
164
 
131
- Methods are chainable - call as many as you need on a single column.
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
+ ```
132
174
 
133
175
  ## Encryption and Decryption
134
176
 
135
177
  ### Single Values
136
178
 
137
179
  ```typescript
138
- // Encrypt
180
+ // Encrypt — plaintext is pinned to the column's domain type
139
181
  const encrypted = await client.encrypt("secret@example.com", {
140
182
  column: users.email,
141
183
  table: users,
142
184
  })
143
185
 
144
- // Decrypt
186
+ // Decrypt (narrow on `.failure` before reading `.data`)
187
+ if (encrypted.failure) throw new Error(encrypted.failure.message)
145
188
  const decrypted = await client.decrypt(encrypted.data)
146
189
  ```
147
190
 
148
191
  ### Model Operations
149
192
 
150
- 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.
151
194
 
152
- 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`.
153
196
 
154
197
  ```typescript
155
- type User = { id: string; email: string; createdAt: Date }
156
-
157
198
  const user = {
158
- id: "user_123",
159
- email: "alice@example.com", // defined in schema -> encrypted
160
- 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
161
203
  }
162
204
 
163
- // Let TypeScript infer the return type from the schema
164
205
  const encryptedResult = await client.encryptModel(user, users)
165
- // encryptedResult.data.email -> Encrypted
166
- // encryptedResult.data.id -> string
167
- // encryptedResult.data.createdAt -> Date
206
+ // encryptedResult.data.email -> Encrypted
207
+ // encryptedResult.data.id -> string
168
208
 
169
- // Decrypt a model
170
- 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
171
213
  ```
172
214
 
173
215
  ### Bulk Operations
174
216
 
175
217
  All bulk methods make a single call to ZeroKMS regardless of the number of records, while still using a unique key per value.
176
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
+
177
232
  #### Bulk Encrypt / Decrypt (raw values)
178
233
 
234
+ `bulkEncrypt` / `bulkDecrypt` are untyped passthroughs for raw value arrays:
235
+
179
236
  ```typescript
180
237
  const plaintexts = [
181
238
  { id: "u1", plaintext: "alice@example.com" },
182
239
  { id: "u2", plaintext: "bob@example.com" },
183
- { id: "u3", plaintext: "charlie@example.com" },
184
240
  ]
185
241
 
186
242
  const encrypted = await client.bulkEncrypt(plaintexts, {
@@ -190,7 +246,9 @@ const encrypted = await client.bulkEncrypt(plaintexts, {
190
246
 
191
247
  // encrypted.data = [{ id: "u1", data: EncryptedPayload }, ...]
192
248
 
249
+ if (encrypted.failure) throw new Error(encrypted.failure.message)
193
250
  const decrypted = await client.bulkDecrypt(encrypted.data)
251
+ if (decrypted.failure) throw new Error(decrypted.failure.message)
194
252
 
195
253
  // Each item has either { data: "plaintext" } or { error: "message" }
196
254
  for (const item of decrypted.data) {
@@ -202,63 +260,64 @@ for (const item of decrypted.data) {
202
260
  }
203
261
  ```
204
262
 
205
- #### Bulk Encrypt / Decrypt Models
206
-
207
- ```typescript
208
- const userModels = [
209
- { id: "1", email: "alice@example.com" },
210
- { id: "2", email: "bob@example.com" },
211
- ]
212
-
213
- const encrypted = await client.bulkEncryptModels(userModels, users)
214
- const decrypted = await client.bulkDecryptModels(encrypted.data)
215
- ```
216
-
217
263
  ## Searchable Encryption
218
264
 
219
- 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.
220
266
 
221
267
  ```typescript
222
- // Equality query
268
+ // Equality
223
269
  const eqQuery = await client.encryptQuery("alice@example.com", {
224
270
  column: users.email,
225
271
  table: users,
226
272
  queryType: "equality",
227
273
  })
228
274
 
229
- // Free-text search
230
- 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", {
231
277
  column: users.email,
232
278
  table: users,
233
279
  queryType: "freeTextSearch",
234
280
  })
235
281
 
236
282
  // Order and range
237
- const rangeQuery = await client.encryptQuery("alice@example.com", {
238
- column: users.email,
283
+ const rangeQuery = await client.encryptQuery(30, {
284
+ column: users.age,
239
285
  table: users,
240
286
  queryType: "orderAndRange",
241
287
  })
242
288
  ```
243
289
 
244
- ### Searchable JSON
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.
291
+
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.
293
+
294
+ ### Encrypted JSON
295
+
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:
245
297
 
246
- For columns using `.searchableJson()`, the query type is auto-inferred from the plaintext:
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"`:
247
299
 
248
300
  ```typescript
249
- // String -> JSONPath selector query
250
- const pathQuery = await client.encryptQuery("$.user.email", {
251
- column: documents.metadata,
252
- table: documents,
253
- })
301
+ const events = encryptedTable("events", { metadata: types.Json("metadata") })
254
302
 
255
- // Object/Array -> containment query
256
- const containsQuery = await client.encryptQuery({ role: "admin" }, {
257
- column: documents.metadata,
258
- table: documents,
259
- })
303
+ const containsQuery = await client.encryptQuery(
304
+ { roles: ["admin"] },
305
+ { column: events.metadata, table: events }, // queryType inferred: 'searchableJson'
306
+ )
260
307
  ```
261
308
 
309
+ **JSONPath selectors** — equality and ordering at a path (`$.a`, `$.a.b` dot-notation object paths):
310
+
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)`.
313
+
314
+ Two semantics to know:
315
+
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.
318
+
319
+ `types.Json` carries no equality or ordering on the document itself, so applying `eq` / `gt` / `asc` directly to a `Json` column throws.
320
+
262
321
  ### Batch Query Encryption
263
322
 
264
323
  Encrypt multiple query terms in one call:
@@ -266,175 +325,191 @@ Encrypt multiple query terms in one call:
266
325
  ```typescript
267
326
  const terms = [
268
327
  { value: "alice@example.com", column: users.email, table: users, queryType: "equality" as const },
269
- { value: "bob", column: users.email, table: users, queryType: "freeTextSearch" as const },
328
+ { value: "bob", column: users.email, table: users, queryType: "freeTextSearch" as const },
270
329
  ]
271
330
 
272
331
  const results = await client.encryptQuery(terms)
273
332
  ```
274
333
 
275
- ### Query Result Formatting (`returnType`)
334
+ ### Ordering Encrypted Data
276
335
 
277
- By default `encryptQuery` returns an `Encrypted` object (the raw EQL JSON payload). Use `returnType` to change the output format:
336
+ `ORDER BY` works on encrypted ordering columns via the domain's order term:
278
337
 
279
- | `returnType` | Output | Use case |
280
- |---|---|---|
281
- | `'eql'` (default) | `Encrypted` object | Parameterized queries, ORMs accepting JSON |
282
- | `'composite-literal'` | `string` | Supabase `.eq()`, string-based APIs |
283
- | `'escaped-composite-literal'` | `string` | Embedding inside another string or JSON value |
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`.
284
340
 
285
- ```typescript
286
- // Get a composite literal string for use with Supabase
287
- const term = await client.encryptQuery("alice@example.com", {
288
- column: users.email,
289
- table: users,
290
- queryType: "equality",
291
- returnType: "composite-literal",
292
- })
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.
293
342
 
294
- // term.data is a string — use directly with .eq()
295
- await supabase.from("users").select().eq("email", term.data)
296
- ```
343
+ ### Drizzle Integration
297
344
 
298
- Each term in a batch can have its own `returnType`.
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.
299
346
 
300
- ### Ordering Encrypted Data
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.:
301
348
 
302
- **`ORDER BY` on encrypted columns requires operator family support in the database.**
349
+ ```ts
350
+ import { pgTable, integer } from "drizzle-orm/pg-core"
351
+ import { drizzle } from "drizzle-orm/postgres-js"
352
+ import {
353
+ types,
354
+ createEncryptionOperatorsV3,
355
+ extractEncryptionSchemaV3,
356
+ } from "@cipherstash/stack-drizzle/v3"
357
+ import { EncryptionV3 } from "@cipherstash/stack/v3"
303
358
 
304
- 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.
359
+ // Capabilities come from the concrete typeno flags to configure.
360
+ const users = pgTable("users", {
361
+ id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
362
+ email: types.TextEq("email"), // equality: eq / ne / inArray
363
+ age: types.IntegerOrd("age"), // order + range: gt/gte/lt/lte, between, asc/desc
364
+ bio: types.TextMatch("bio"), // free-text search: matches
365
+ balance: types.Bigint("balance"), // storage only (no query capability)
366
+ })
367
+ ```
305
368
 
306
- Operator family support for Supabase is being developed in collaboration with the Supabase and CipherStash teams and will be available in a future release.
369
+ Derive the v3 schema from the table, build the typed client, and create the operators:
307
370
 
308
- ### PostgreSQL / Drizzle Integration Pattern
371
+ ```ts
372
+ const usersSchema = extractEncryptionSchemaV3(users)
373
+ const client = await EncryptionV3({ schemas: [usersSchema] })
374
+ const ops = createEncryptionOperatorsV3(client)
309
375
 
310
- 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.
376
+ const db = drizzle({ client: sqlClient })
377
+ ```
311
378
 
312
- The `@cipherstash/stack/drizzle` module provides `encryptedType` for defining encrypted columns and `createEncryptionOperators` for querying them:
379
+ 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`:
313
380
 
314
- ```typescript
315
- import { pgTable, integer, timestamp } from "drizzle-orm/pg-core"
316
- import { encryptedType, extractEncryptionSchema, createEncryptionOperators } from "@cipherstash/stack/drizzle"
317
- import { Encryption } from "@cipherstash/stack"
381
+ ```ts
382
+ // Equality email is TextEq
383
+ const exact = await db.select().from(users)
384
+ .where(await ops.eq(users.email, "alice@example.com"))
318
385
 
319
- // Define schema with encrypted columns
320
- const usersTable = pgTable("users", {
321
- id: integer("id").primaryKey().generatedAlwaysAsIdentity(),
322
- email: encryptedType<string>("email", {
323
- equality: true,
324
- freeTextSearch: true,
325
- orderAndRange: true,
326
- }),
327
- profile: encryptedType<{ name: string; bio: string }>("profile", {
328
- dataType: "json",
329
- searchableJson: true,
330
- }),
331
- })
386
+ // Range + ordering age is IntegerOrd
387
+ const adults = await db.select().from(users)
388
+ .where(await ops.gte(users.age, 18))
389
+ .orderBy(ops.asc(users.age))
332
390
 
333
- // Initialize
334
- const usersSchema = extractEncryptionSchema(usersTable)
335
- const client = await Encryption({ schemas: [usersSchema] })
336
- const ops = createEncryptionOperators(client)
391
+ const midBand = await db.select().from(users)
392
+ .where(await ops.between(users.age, 25, 40))
337
393
 
338
- // Query with auto-encrypting operators
339
- const results = await db.select().from(usersTable)
340
- .where(await ops.eq(usersTable.email, "alice@example.com"))
394
+ // Set membership built on equality
395
+ const listed = await db.select().from(users)
396
+ .where(await ops.inArray(users.email, ["alice@example.com", "bob@example.com"]))
341
397
 
342
- // JSONB queries on encrypted JSON columns
343
- const jsonResults = await db.select().from(usersTable)
344
- .where(await ops.jsonbPathExists(usersTable.profile, "$.bio"))
398
+ // Free-text token match bio is TextMatch
399
+ const coffee = await db.select().from(users)
400
+ .where(await ops.matches(users.bio, "coffee"))
345
401
  ```
346
402
 
347
- #### Drizzle `encryptedType` Config Options
348
-
349
- | Option | Type | Description |
350
- |---|---|---|
351
- | `dataType` | `"string"` \| `"number"` \| `"json"` | Plaintext data type (default: `"string"`) |
352
- | `equality` | `boolean` \| `TokenFilter[]` | Enable equality index |
353
- | `freeTextSearch` | `boolean` \| `MatchIndexOpts` | Enable free-text search index |
354
- | `orderAndRange` | `boolean` | Enable ORE index for sorting/range queries |
355
- | `searchableJson` | `boolean` | Enable JSONB path queries (requires `dataType: "json"`) |
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`:
356
404
 
357
- #### Drizzle JSONB Operators
405
+ ```ts
406
+ const rows = await client.bulkEncryptModels(
407
+ [
408
+ { email: "alice@example.com", age: 30, bio: "climbing and coffee", balance: 100_000n },
409
+ { email: "bob@example.com", age: 41, bio: "cycling and coffee", balance: 250_000n },
410
+ ],
411
+ usersSchema,
412
+ )
413
+ if (rows.failure) throw new Error(rows.failure.message)
358
414
 
359
- For columns with `searchableJson: true`, three JSONB operators are available:
415
+ await db.insert(users).values(rows.data)
416
+ ```
360
417
 
361
- | Operator | Description |
362
- |---|---|
363
- | `jsonbPathExists(col, selector)` | Check if a JSONB path exists (boolean, use in `WHERE`) |
364
- | `jsonbPathQueryFirst(col, selector)` | Extract first value at a JSONB path |
365
- | `jsonbGet(col, selector)` | Get value using the JSONB `->` operator |
418
+ Notes:
366
419
 
367
- 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.
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`.
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).
368
423
 
369
- ## Identity-Aware Encryption
424
+ ### Supabase Integration
370
425
 
371
- 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:
372
427
 
373
428
  ```typescript
374
- import { LockContext } from "@cipherstash/stack/identity"
429
+ import { encryptedSupabaseV3 } from "@cipherstash/stack-supabase"
375
430
 
376
- // 1. Create a lock context (defaults to the "sub" claim)
377
- const lc = new LockContext()
431
+ const es = await encryptedSupabaseV3(supabaseUrl, supabaseKey)
378
432
 
379
- // 2. Identify the user with their JWT
380
- 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
+ ```
381
437
 
382
- if (identifyResult.failure) {
383
- throw new Error(identifyResult.failure.message)
384
- }
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.
385
439
 
386
- const lockContext = identifyResult.data
440
+ ## Authentication
387
441
 
388
- // 3. Encrypt with lock context
389
- const encrypted = await client
390
- .encrypt("sensitive data", { column: users.email, table: users })
391
- .withLockContext(lockContext)
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:
392
447
 
393
- // 4. Decrypt with the same lock context
394
- const decrypted = await client
395
- .decrypt(encrypted.data)
396
- .withLockContext(lockContext)
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
+ })
397
470
  ```
398
471
 
399
- Lock contexts work with all operations: `encrypt`, `decrypt`, `encryptModel`, `decryptModel`, `bulkEncryptModels`, `bulkDecryptModels`, `bulkEncrypt`, `bulkDecrypt`.
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.
400
475
 
401
- ## Secrets Management
476
+ ## Identity-Aware Encryption (Lock Contexts)
402
477
 
403
- The `Secrets` class provides end-to-end encrypted secret storage. Values are encrypted locally before being sent to the CipherStash API.
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:
404
480
 
405
481
  ```typescript
406
- import { Secrets } from "@cipherstash/stack/secrets"
407
-
408
- const secrets = new Secrets({
409
- workspaceCRN: process.env.CS_WORKSPACE_CRN!,
410
- clientId: process.env.CS_CLIENT_ID!,
411
- clientKey: process.env.CS_CLIENT_KEY!,
412
- apiKey: process.env.CS_CLIENT_ACCESS_KEY!,
413
- environment: "production",
414
- })
482
+ // Requires a client authenticated with OidcFederationStrategy (above) — the
483
+ // claim's value resolves from the federated JWT.
484
+ const IDENTITY = { identityClaim: ["sub"] }
415
485
 
416
- // Store a secret
417
- await secrets.set("DATABASE_URL", "postgres://user:pass@host:5432/db")
486
+ const encrypted = await client
487
+ .encrypt("sensitive data", { column: users.email, table: users })
488
+ .withLockContext(IDENTITY)
489
+ if (encrypted.failure) throw new Error(encrypted.failure.message)
418
490
 
419
- // Retrieve and decrypt a single secret
420
- const result = await secrets.get("DATABASE_URL")
421
- if (!result.failure) {
422
- console.log(result.data) // "postgres://user:pass@host:5432/db"
423
- }
491
+ const decrypted = await client
492
+ .decrypt(encrypted.data)
493
+ .withLockContext(IDENTITY)
494
+ ```
424
495
 
425
- // Retrieve multiple secrets in one call
426
- const many = await secrets.getMany(["DATABASE_URL", "API_KEY"])
427
- if (!many.failure) {
428
- console.log(many.data.DATABASE_URL)
429
- console.log(many.data.API_KEY)
430
- }
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.
431
500
 
432
- // List secret names (values stay encrypted)
433
- const list = await secrets.list()
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.
434
508
 
435
- // Delete a secret
436
- await secrets.delete("DATABASE_URL")
437
- ```
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.
438
513
 
439
514
  ## CLI Reference
440
515
 
@@ -461,40 +536,24 @@ npx stash init --supabase
461
536
 
462
537
  The wizard will:
463
538
  1. Authenticate with CipherStash (device code flow)
464
- 2. Bind your device to the default Keyset
539
+ 2. Introspect your database and install the EQL v3 SQL
465
540
  3. Choose your database connection method (Drizzle ORM, Supabase JS, Prisma, or Raw SQL)
466
541
  4. Build an encryption schema interactively or use a placeholder, then generate the encryption client file
467
542
  5. Install `stash` as a dev dependency for database tooling
468
543
 
469
- 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.
470
545
 
471
546
  | Flag | Description |
472
547
  |------|-------------|
473
- | `--supabase` | Use Supabase-specific setup flow |
474
-
475
- ### `npx stash secrets`
476
-
477
- Manage encrypted secrets from the terminal.
478
-
479
- ```bash
480
- npx stash secrets set -name DATABASE_URL -value "postgres://..." -environment production
481
- npx stash secrets get -name DATABASE_URL -environment production
482
- npx stash secrets list -environment production
483
- npx stash secrets delete -name DATABASE_URL -environment production
484
- ```
485
-
486
- | Command | Flags | Aliases | Description |
487
- |-----|----|-----|-------|
488
- | `npx stash secrets set` | `-name`, `-value`, `-environment` | `-n`, `-V`, `-e` | Encrypt and store a secret |
489
- | `npx stash secrets get` | `-name`, `-environment` | `-n`, `-e` | Retrieve and decrypt a secret |
490
- | `npx stash secrets list` | `-environment` | `-e` | List all secret names in an environment |
491
- | `npx stash secrets delete` | `-name`, `-environment`, `-yes` | `-n`, `-e`, `-y` | Delete a secret (prompts for confirmation unless `-yes`) |
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** |
492
551
 
493
552
  ## Configuration
494
553
 
495
554
  ### Local Development
496
555
 
497
- 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`.
498
557
 
499
558
  ### Going to Production
500
559
 
@@ -514,10 +573,10 @@ See the [Going to Production](https://cipherstash.com/docs/stack/deploy/going-to
514
573
  Pass config directly when initializing the client:
515
574
 
516
575
  ```typescript
517
- import { Encryption } from "@cipherstash/stack"
576
+ import { EncryptionV3 } from "@cipherstash/stack/v3"
518
577
  import { users } from "./schema"
519
578
 
520
- const client = await Encryption({
579
+ const client = await EncryptionV3({
521
580
  schemas: [users],
522
581
  config: {
523
582
  workspaceCrn: "crn:ap-southeast-2.aws:your-workspace-id",
@@ -534,7 +593,7 @@ const client = await Encryption({
534
593
  Isolate encryption keys per tenant using keysets:
535
594
 
536
595
  ```typescript
537
- const client = await Encryption({
596
+ const client = await EncryptionV3({
538
597
  schemas: [users],
539
598
  config: {
540
599
  keyset: { id: "123e4567-e89b-12d3-a456-426614174000" },
@@ -542,7 +601,7 @@ const client = await Encryption({
542
601
  })
543
602
 
544
603
  // or by name
545
- const client2 = await Encryption({
604
+ const client2 = await EncryptionV3({
546
605
  schemas: [users],
547
606
  config: {
548
607
  keyset: { name: "Company A" },
@@ -552,7 +611,7 @@ const client2 = await Encryption({
552
611
 
553
612
  ### Logging
554
613
 
555
- The SDK uses structured logging across all interfaces (Encryption, Secrets, Supabase, DynamoDB). Each operation emits a single wide event with context such as the operation type, table, column, lock context status, and duration.
614
+ 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.
556
615
 
557
616
  Configure the log level with the `STASH_STACK_LOG` environment variable:
558
617
 
@@ -599,91 +658,132 @@ if (result.failure) {
599
658
 
600
659
  ## API Reference
601
660
 
602
- ### `Encryption(config)` - Initialize the client
661
+ ### `EncryptionV3(config)` - Initialize the typed client
603
662
 
604
663
  ```typescript
605
- function Encryption(config: EncryptionClientConfig): Promise<EncryptionClient>
664
+ function EncryptionV3(config: {
665
+ schemas: AnyV3Table[]
666
+ config?: ClientConfig
667
+ }): Promise<TypedEncryptionClient>
606
668
  ```
607
669
 
608
- ### `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.
609
675
 
610
676
  | Method | Signature | Returns |
611
677
  |----|------|-----|
612
678
  | `encrypt` | `(plaintext, { column, table })` | `EncryptOperation` (thenable) |
613
679
  | `decrypt` | `(encryptedData)` | `DecryptOperation` (thenable) |
614
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.
615
683
  | `encryptQuery` | `(terms: ScalarQueryTerm[])` | `BatchEncryptQueryOperation` (thenable) |
616
- | `encryptModel` | `(model, table)` | `EncryptModelOperation<EncryptedFromSchema<T, S>>` (thenable) |
617
- | `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<...>>` |
618
688
  | `bulkEncrypt` | `(plaintexts, { column, table })` | `BulkEncryptOperation` (thenable) |
619
689
  | `bulkDecrypt` | `(encryptedPayloads)` | `BulkDecryptOperation` (thenable) |
620
- | `bulkEncryptModels` | `(models, table)` | `BulkEncryptModelsOperation<EncryptedFromSchema<T, S>>` (thenable) |
621
- | `bulkDecryptModels` | `(encryptedModels)` | `BulkDecryptModelsOperation<T>` (thenable) |
690
+ | `getEncryptConfig` | `()` | The resolved encrypt config |
622
691
 
623
- All operations are thenable (awaitable) and support `.withLockContext(lockContext)` for identity-aware encryption.
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.
624
693
 
625
- ### `LockContext`
694
+ ### `LockContext` (legacy)
626
695
 
627
- ```typescript
628
- import { LockContext } from "@cipherstash/stack/identity"
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.
629
701
 
630
- const lc = new LockContext(options?)
631
- const result = await lc.identify(jwtToken)
632
- ```
633
-
634
- ### `Secrets`
702
+ ### Schema Builders
635
703
 
636
704
  ```typescript
637
- import { Secrets } from "@cipherstash/stack/secrets"
638
-
639
- const secrets = new Secrets(config)
640
- await secrets.set(name, value)
641
- await secrets.get(name)
642
- await secrets.getMany(names)
643
- await secrets.list()
644
- await secrets.delete(name)
705
+ import { encryptedTable, types } from "@cipherstash/stack/eql/v3"
706
+
707
+ encryptedTable(tableName, columns) // columns: Record<string, types.*(dbColumnName)>
708
+ types.TextSearch("email") // one factory per public.eql_v3_* domain
645
709
  ```
646
710
 
647
- ### Schema Builders
711
+ Type inference helpers live on the same subpath:
648
712
 
649
713
  ```typescript
650
- import { encryptedTable, encryptedColumn, csValue } from "@cipherstash/stack/schema"
714
+ import type { InferPlaintext, InferEncrypted } from "@cipherstash/stack/eql/v3"
715
+
716
+ type UserPlaintext = InferPlaintext<typeof users>
717
+ // { email: string; age: number; balance: bigint; metadata: JsonDocument }
651
718
 
652
- encryptedTable(tableName, columns)
653
- encryptedColumn(columnName) // returns EncryptedColumn
654
- csValue(valueName) // returns ProtectValue (for nested values)
719
+ type UserEncrypted = InferEncrypted<typeof users>
720
+ // { email: Encrypted; age: Encrypted; ... }
655
721
  ```
656
722
 
657
723
  ## Subpath Exports
658
724
 
659
725
  | Import Path | Provides |
660
726
  |-------|-----|
661
- | `@cipherstash/stack` | `Encryption` function (main entry point) |
662
- | `@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)) |
663
731
  | `@cipherstash/stack/identity` | `LockContext` class and identity types |
664
- | `@cipherstash/stack/secrets` | `Secrets` class and secrets types |
665
732
  | `@cipherstash/stack/client` | Client-safe exports (schema builders and types only - no native FFI) |
666
733
  | `@cipherstash/stack/types` | All TypeScript types (`Encrypted`, `Decrypted`, `ClientConfig`, `EncryptionClientConfig`, query types, etc.) |
667
734
 
668
- ## Migration from @cipherstash/protect
735
+ The Drizzle and Supabase integrations are **separate first-party packages** that
736
+ depend on `@cipherstash/stack` (they are no longer subpaths of it):
669
737
 
670
- If you are migrating from `@cipherstash/protect`, the following table maps the old API to the new one:
671
-
672
- | `@cipherstash/protect` | `@cipherstash/stack` | Import Path |
738
+ | Package | Provides |
739
+ |-------|-----|
740
+ | `@cipherstash/stack-drizzle/v3` | EQL v3 Drizzle integration: `types` column factories, `createEncryptionOperatorsV3`, `extractEncryptionSchemaV3`, `makeEqlV3Column`, `EncryptionOperatorError` |
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 |
673
775
  |------------|-----------|-------|
674
776
  | `protect(config)` | `Encryption(config)` | `@cipherstash/stack` |
675
777
  | `csTable(name, cols)` | `encryptedTable(name, cols)` | `@cipherstash/stack/schema` |
676
778
  | `csColumn(name)` | `encryptedColumn(name)` | `@cipherstash/stack/schema` |
677
779
  | `import { LockContext } from "@cipherstash/protect/identify"` | `import { LockContext } from "@cipherstash/stack/identity"` | `@cipherstash/stack/identity` |
678
- | N/A | `Secrets` class | `@cipherstash/stack/secrets` |
679
780
  | N/A | CLI | `npx stash` |
680
781
 
681
- All method signatures on the encryption client (`encrypt`, `decrypt`, `encryptModel`, etc.) remain the same. The `Result` pattern (`data` / `failure`) is unchanged.
682
-
683
782
  ## Requirements
684
783
 
685
- - **Node.js** >= 18
686
- - 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).
687
787
 
688
788
  ## License
689
789