@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.
- package/CHANGELOG.md +84 -0
- package/README.md +321 -239
- package/dist/adapter-kit.cjs +83 -4
- package/dist/adapter-kit.cjs.map +1 -1
- package/dist/adapter-kit.d.cts +41 -3
- package/dist/adapter-kit.d.ts +41 -3
- package/dist/adapter-kit.js +78 -3
- package/dist/adapter-kit.js.map +1 -1
- package/dist/{chunk-V2Q3NYIH.js → chunk-6SGN52W6.js} +1 -1
- package/dist/{chunk-V2Q3NYIH.js.map → chunk-6SGN52W6.js.map} +1 -1
- package/dist/{chunk-HQANMV7R.js → chunk-BCY6WB24.js} +3 -3
- package/dist/{chunk-HQANMV7R.js.map → chunk-BCY6WB24.js.map} +1 -1
- package/dist/{chunk-LBMC4D6D.js → chunk-NVKK7UDN.js} +1 -1
- package/dist/chunk-NVKK7UDN.js.map +1 -0
- package/dist/{chunk-OFQ555AX.js → chunk-Q4WTOYVI.js} +3 -3
- package/dist/{chunk-ZTP5QJ27.js → chunk-UI7V2QCK.js} +6 -6
- package/dist/{chunk-ZTP5QJ27.js.map → chunk-UI7V2QCK.js.map} +1 -1
- package/dist/{columns-rZc7fQHI.d.ts → columns-0lbT9stl.d.ts} +2 -2
- package/dist/{columns-D2_YzrCX.d.cts → columns-Bxv7Oo9o.d.cts} +2 -2
- package/dist/dynamodb/index.cjs +2 -2
- package/dist/dynamodb/index.cjs.map +1 -1
- package/dist/dynamodb/index.js +1 -1
- package/dist/encryption/index.cjs +4 -4
- package/dist/encryption/index.cjs.map +1 -1
- package/dist/encryption/index.js +4 -4
- package/dist/encryption/v3.cjs +4 -4
- package/dist/encryption/v3.cjs.map +1 -1
- package/dist/encryption/v3.d.cts +2 -2
- package/dist/encryption/v3.d.ts +2 -2
- package/dist/encryption/v3.js +5 -5
- package/dist/eql/v3/index.cjs.map +1 -1
- package/dist/eql/v3/index.d.cts +2 -2
- package/dist/eql/v3/index.d.ts +2 -2
- package/dist/eql/v3/index.js +1 -1
- package/dist/errors/index.cjs.map +1 -1
- package/dist/errors/index.d.cts +5 -5
- package/dist/errors/index.d.ts +5 -5
- package/dist/errors/index.js +1 -1
- package/dist/identity/index.cjs +2 -2
- package/dist/identity/index.cjs.map +1 -1
- package/dist/identity/index.js +3 -3
- package/dist/index.cjs +4 -4
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +4 -4
- package/dist/wasm-inline.d.ts +243 -58
- package/dist/wasm-inline.js +268 -0
- package/dist/wasm-inline.js.map +1 -1
- package/package.json +16 -16
- package/dist/chunk-LBMC4D6D.js.map +0 -1
- /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
|
[](https://github.com/cipherstash/stack/blob/main/LICENSE.md)
|
|
7
7
|
[](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
|
-
- [
|
|
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
|
-
- [
|
|
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 {
|
|
58
|
-
import { encryptedTable,
|
|
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:
|
|
65
|
+
email: types.TextSearch("email"), // equality + order/range + free-text search
|
|
63
66
|
})
|
|
64
67
|
|
|
65
|
-
// Create a client
|
|
66
|
-
const client = await
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 `
|
|
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 `
|
|
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,
|
|
111
|
+
import { encryptedTable, types } from "@cipherstash/stack/eql/v3"
|
|
105
112
|
|
|
106
113
|
const users = encryptedTable("users", {
|
|
107
|
-
email:
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
|
122
|
-
|
|
123
|
-
|
|
|
124
|
-
|
|
|
125
|
-
|
|
|
126
|
-
|
|
|
127
|
-
|
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
158
|
-
|
|
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
|
|
164
|
-
// encryptedResult.data.id
|
|
165
|
-
// encryptedResult.data.createdAt -> Date
|
|
206
|
+
// encryptedResult.data.email -> Encrypted
|
|
207
|
+
// encryptedResult.data.id -> string
|
|
166
208
|
|
|
167
|
-
|
|
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
|
|
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("
|
|
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(
|
|
236
|
-
column: users.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
254
|
-
const containsQuery = await client.encryptQuery({ role: "admin" }, {
|
|
255
|
-
column: documents.metadata,
|
|
256
|
-
table: documents,
|
|
257
|
-
})
|
|
258
|
-
```
|
|
294
|
+
### Encrypted JSON
|
|
259
295
|
|
|
260
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
309
|
+
**JSONPath selectors** — equality and ordering at a path (`$.a`, `$.a.b` dot-notation object paths):
|
|
297
310
|
|
|
298
|
-
|
|
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
|
-
|
|
314
|
+
Two semantics to know:
|
|
303
315
|
|
|
304
|
-
|
|
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
|
-
|
|
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
|
-
|
|
321
|
+
### Batch Query Encryption
|
|
309
322
|
|
|
310
|
-
|
|
323
|
+
Encrypt multiple query terms in one call:
|
|
311
324
|
|
|
312
325
|
```typescript
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
336
|
+
`ORDER BY` works on encrypted ordering columns via the domain's order term:
|
|
358
337
|
|
|
359
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
343
|
+
### Drizzle Integration
|
|
368
344
|
|
|
369
|
-
The `@cipherstash/stack-drizzle/v3`
|
|
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
|
|
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:
|
|
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
|
|
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
|
|
398
|
+
// Free-text token match — bio is TextMatch
|
|
423
399
|
const coffee = await db.select().from(users)
|
|
424
|
-
.where(await ops.
|
|
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
|
|
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`;
|
|
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
|
-
|
|
424
|
+
### Supabase Integration
|
|
449
425
|
|
|
450
|
-
|
|
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 {
|
|
429
|
+
import { encryptedSupabaseV3 } from "@cipherstash/stack-supabase"
|
|
454
430
|
|
|
455
|
-
|
|
456
|
-
const lc = new LockContext()
|
|
431
|
+
const es = await encryptedSupabaseV3(supabaseUrl, supabaseKey)
|
|
457
432
|
|
|
458
|
-
|
|
459
|
-
|
|
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
|
-
|
|
462
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
493
|
+
.withLockContext(IDENTITY)
|
|
476
494
|
```
|
|
477
495
|
|
|
478
|
-
Lock contexts
|
|
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.
|
|
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
|
-
|
|
544
|
+
`init` installs EQL for you — no separate `eql install` step is needed afterward.
|
|
511
545
|
|
|
512
546
|
| Flag | Description |
|
|
513
547
|
|------|-------------|
|
|
514
|
-
| `--supabase` |
|
|
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
|
|
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 {
|
|
576
|
+
import { EncryptionV3 } from "@cipherstash/stack/v3"
|
|
541
577
|
import { users } from "./schema"
|
|
542
578
|
|
|
543
|
-
const client = await
|
|
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
|
|
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
|
|
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
|
-
### `
|
|
661
|
+
### `EncryptionV3(config)` - Initialize the typed client
|
|
626
662
|
|
|
627
663
|
```typescript
|
|
628
|
-
function
|
|
664
|
+
function EncryptionV3(config: {
|
|
665
|
+
schemas: AnyV3Table[]
|
|
666
|
+
config?: ClientConfig
|
|
667
|
+
}): Promise<TypedEncryptionClient>
|
|
629
668
|
```
|
|
630
669
|
|
|
631
|
-
|
|
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
|
|
640
|
-
| `decryptModel` | `(encryptedModel)` | `
|
|
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
|
-
| `
|
|
644
|
-
|
|
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
|
-
|
|
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
|
-
###
|
|
702
|
+
### Schema Builders
|
|
649
703
|
|
|
650
704
|
```typescript
|
|
651
|
-
import {
|
|
705
|
+
import { encryptedTable, types } from "@cipherstash/stack/eql/v3"
|
|
652
706
|
|
|
653
|
-
|
|
654
|
-
|
|
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
|
-
|
|
711
|
+
Type inference helpers live on the same subpath:
|
|
658
712
|
|
|
659
713
|
```typescript
|
|
660
|
-
import {
|
|
714
|
+
import type { InferPlaintext, InferEncrypted } from "@cipherstash/stack/eql/v3"
|
|
661
715
|
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
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` | `
|
|
672
|
-
| `@cipherstash/stack/
|
|
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: `
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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** >=
|
|
704
|
-
- The
|
|
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
|
|