@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.
- package/CHANGELOG.md +596 -0
- package/README.md +376 -276
- package/dist/adapter-kit.cjs +1002 -0
- package/dist/adapter-kit.cjs.map +1 -0
- package/dist/adapter-kit.d.cts +120 -0
- package/dist/adapter-kit.d.ts +120 -0
- package/dist/adapter-kit.js +122 -0
- package/dist/adapter-kit.js.map +1 -0
- package/dist/base-operation-AOAIvsSB.d.cts +32 -0
- package/dist/base-operation-FXEzUXIq.d.ts +32 -0
- package/dist/{chunk-4AVL4VZD.js → chunk-3B5ZX3IS.js} +3 -1
- package/dist/chunk-3B5ZX3IS.js.map +1 -0
- package/dist/{chunk-U66S7VIF.js → chunk-6SGN52W6.js} +166 -107
- package/dist/chunk-6SGN52W6.js.map +1 -0
- package/dist/chunk-7333ZC6L.js +48 -0
- package/dist/chunk-7333ZC6L.js.map +1 -0
- package/dist/{chunk-MP3SSDNN.js → chunk-CLM7E4I6.js} +15 -15
- package/dist/{chunk-MP3SSDNN.js.map → chunk-CLM7E4I6.js.map} +1 -1
- package/dist/{chunk-OFQ555AX.js → chunk-IDKP6ABU.js} +2 -2
- package/dist/{chunk-36AA7IBJ.js → chunk-L7ISHSG7.js} +53 -20
- package/dist/chunk-L7ISHSG7.js.map +1 -0
- package/dist/{chunk-LBMC4D6D.js → chunk-NVKK7UDN.js} +1 -1
- package/dist/chunk-NVKK7UDN.js.map +1 -0
- package/dist/chunk-X3JRXEIB.js +98 -0
- package/dist/chunk-X3JRXEIB.js.map +1 -0
- package/dist/client.cjs +29 -12
- package/dist/client.cjs.map +1 -1
- package/dist/client.d.cts +3 -2
- package/dist/client.d.ts +3 -2
- package/dist/client.js +2 -2
- package/dist/{table-CIH7jZ2h.d.ts → columns-0lbT9stl.d.ts} +157 -138
- package/dist/{table-DihEAlxG.d.cts → columns-Bxv7Oo9o.d.cts} +157 -138
- package/dist/dynamodb/index.d.cts +3 -2
- package/dist/dynamodb/index.d.ts +3 -2
- package/dist/encryption/index.cjs +54 -16
- package/dist/encryption/index.cjs.map +1 -1
- package/dist/encryption/index.d.cts +807 -6
- package/dist/encryption/index.d.ts +807 -6
- package/dist/encryption/index.js +6 -6
- package/dist/encryption/v3.cjs +1078 -973
- package/dist/encryption/v3.cjs.map +1 -1
- package/dist/encryption/v3.d.cts +15 -12
- package/dist/encryption/v3.d.ts +15 -12
- package/dist/encryption/v3.js +50 -22
- package/dist/encryption/v3.js.map +1 -1
- package/dist/eql/v3/index.cjs +130 -79
- package/dist/eql/v3/index.cjs.map +1 -1
- package/dist/eql/v3/index.d.cts +115 -13
- package/dist/eql/v3/index.d.ts +115 -13
- package/dist/eql/v3/index.js +16 -6
- 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.map +1 -1
- package/dist/identity/index.js +2 -2
- package/dist/index-BquA71_Y.d.ts +24 -0
- package/dist/index-fhWTOV0K.d.cts +24 -0
- package/dist/index.cjs +79 -27
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -17
- package/dist/index.d.ts +5 -17
- package/dist/index.js +6 -6
- package/dist/schema/index.cjs +29 -12
- package/dist/schema/index.cjs.map +1 -1
- package/dist/schema/index.d.cts +1 -1
- package/dist/schema/index.d.ts +1 -1
- package/dist/schema/index.js +2 -2
- package/dist/{types-public-CpS5KjwX.d.ts → types-public-QMjYNfQO.d.cts} +765 -726
- package/dist/{types-public-CpS5KjwX.d.cts → types-public-QMjYNfQO.d.ts} +765 -726
- package/dist/types-public.cjs.map +1 -1
- package/dist/types-public.d.cts +1 -1
- package/dist/types-public.d.ts +1 -1
- package/dist/types-public.js +1 -1
- package/dist/wasm-inline.d.ts +779 -405
- package/dist/wasm-inline.js +606 -299
- package/dist/wasm-inline.js.map +1 -1
- package/package.json +25 -53
- package/dist/chunk-36AA7IBJ.js.map +0 -1
- package/dist/chunk-4AVL4VZD.js.map +0 -1
- package/dist/chunk-IADZCZEA.js +0 -23
- package/dist/chunk-IADZCZEA.js.map +0 -1
- package/dist/chunk-IBSK6P33.js +0 -209
- package/dist/chunk-IBSK6P33.js.map +0 -1
- package/dist/chunk-LBMC4D6D.js.map +0 -1
- package/dist/chunk-U66S7VIF.js.map +0 -1
- package/dist/client-DSGHBN-g.d.cts +0 -834
- package/dist/client-DfCrlHXh.d.ts +0 -834
- package/dist/drizzle/index.cjs +0 -5617
- package/dist/drizzle/index.cjs.map +0 -1
- package/dist/drizzle/index.d.cts +0 -358
- package/dist/drizzle/index.d.ts +0 -358
- package/dist/drizzle/index.js +0 -1220
- package/dist/drizzle/index.js.map +0 -1
- package/dist/supabase/index.cjs +0 -5951
- package/dist/supabase/index.cjs.map +0 -1
- package/dist/supabase/index.d.cts +0 -223
- package/dist/supabase/index.d.ts +0 -223
- package/dist/supabase/index.js +0 -1217
- package/dist/supabase/index.js.map +0 -1
- /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
|
[](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,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
|
-
- [
|
|
20
|
-
- [
|
|
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
|
-
- [
|
|
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 {
|
|
59
|
-
import { encryptedTable,
|
|
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:
|
|
65
|
+
email: types.TextSearch("email"), // equality + order/range + free-text search
|
|
64
66
|
})
|
|
65
67
|
|
|
66
|
-
// Create a client
|
|
67
|
-
const client = await
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 `
|
|
97
|
-
- **
|
|
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 `
|
|
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,
|
|
111
|
+
import { encryptedTable, types } from "@cipherstash/stack/eql/v3"
|
|
107
112
|
|
|
108
113
|
const users = encryptedTable("users", {
|
|
109
|
-
email:
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
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
|
-
|
|
|
124
|
-
|
|
125
|
-
|
|
|
126
|
-
|
|
|
127
|
-
|
|
|
128
|
-
|
|
|
129
|
-
|
|
|
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
|
-
|
|
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
|
-
|
|
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",
|
|
160
|
-
|
|
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
|
|
166
|
-
// encryptedResult.data.id
|
|
167
|
-
// encryptedResult.data.createdAt -> Date
|
|
206
|
+
// encryptedResult.data.email -> Encrypted
|
|
207
|
+
// encryptedResult.data.id -> string
|
|
168
208
|
|
|
169
|
-
|
|
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
|
|
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("
|
|
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(
|
|
238
|
-
column: users.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
256
|
-
|
|
257
|
-
column:
|
|
258
|
-
|
|
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",
|
|
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
|
-
###
|
|
334
|
+
### Ordering Encrypted Data
|
|
276
335
|
|
|
277
|
-
|
|
336
|
+
`ORDER BY` works on encrypted ordering columns via the domain's order term:
|
|
278
337
|
|
|
279
|
-
|
|
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
|
-
|
|
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
|
-
|
|
295
|
-
await supabase.from("users").select().eq("email", term.data)
|
|
296
|
-
```
|
|
343
|
+
### Drizzle Integration
|
|
297
344
|
|
|
298
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
359
|
+
// Capabilities come from the concrete type — no 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
|
-
|
|
369
|
+
Derive the v3 schema from the table, build the typed client, and create the operators:
|
|
307
370
|
|
|
308
|
-
|
|
371
|
+
```ts
|
|
372
|
+
const usersSchema = extractEncryptionSchemaV3(users)
|
|
373
|
+
const client = await EncryptionV3({ schemas: [usersSchema] })
|
|
374
|
+
const ops = createEncryptionOperatorsV3(client)
|
|
309
375
|
|
|
310
|
-
|
|
376
|
+
const db = drizzle({ client: sqlClient })
|
|
377
|
+
```
|
|
311
378
|
|
|
312
|
-
The
|
|
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
|
-
```
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
//
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
334
|
-
|
|
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
|
-
//
|
|
339
|
-
const
|
|
340
|
-
.where(await ops.
|
|
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
|
-
//
|
|
343
|
-
const
|
|
344
|
-
.where(await ops.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
415
|
+
await db.insert(users).values(rows.data)
|
|
416
|
+
```
|
|
360
417
|
|
|
361
|
-
|
|
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
|
-
|
|
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
|
-
|
|
424
|
+
### Supabase Integration
|
|
370
425
|
|
|
371
|
-
|
|
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 {
|
|
429
|
+
import { encryptedSupabaseV3 } from "@cipherstash/stack-supabase"
|
|
375
430
|
|
|
376
|
-
|
|
377
|
-
const lc = new LockContext()
|
|
431
|
+
const es = await encryptedSupabaseV3(supabaseUrl, supabaseKey)
|
|
378
432
|
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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
|
-
|
|
440
|
+
## Authentication
|
|
387
441
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
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
|
-
##
|
|
476
|
+
## Identity-Aware Encryption (Lock Contexts)
|
|
402
477
|
|
|
403
|
-
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
const
|
|
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
|
-
|
|
417
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
}
|
|
491
|
+
const decrypted = await client
|
|
492
|
+
.decrypt(encrypted.data)
|
|
493
|
+
.withLockContext(IDENTITY)
|
|
494
|
+
```
|
|
424
495
|
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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
|
-
|
|
433
|
-
|
|
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
|
-
|
|
436
|
-
|
|
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.
|
|
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
|
-
|
|
544
|
+
`init` installs EQL for you — no separate `eql install` step is needed afterward.
|
|
470
545
|
|
|
471
546
|
| Flag | Description |
|
|
472
547
|
|------|-------------|
|
|
473
|
-
| `--supabase` |
|
|
474
|
-
|
|
475
|
-
|
|
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
|
|
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 {
|
|
576
|
+
import { EncryptionV3 } from "@cipherstash/stack/v3"
|
|
518
577
|
import { users } from "./schema"
|
|
519
578
|
|
|
520
|
-
const client = await
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
### `
|
|
661
|
+
### `EncryptionV3(config)` - Initialize the typed client
|
|
603
662
|
|
|
604
663
|
```typescript
|
|
605
|
-
function
|
|
664
|
+
function EncryptionV3(config: {
|
|
665
|
+
schemas: AnyV3Table[]
|
|
666
|
+
config?: ClientConfig
|
|
667
|
+
}): Promise<TypedEncryptionClient>
|
|
606
668
|
```
|
|
607
669
|
|
|
608
|
-
|
|
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
|
|
617
|
-
| `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<...>>` |
|
|
618
688
|
| `bulkEncrypt` | `(plaintexts, { column, table })` | `BulkEncryptOperation` (thenable) |
|
|
619
689
|
| `bulkDecrypt` | `(encryptedPayloads)` | `BulkDecryptOperation` (thenable) |
|
|
620
|
-
| `
|
|
621
|
-
| `bulkDecryptModels` | `(encryptedModels)` | `BulkDecryptModelsOperation<T>` (thenable) |
|
|
690
|
+
| `getEncryptConfig` | `()` | The resolved encrypt config |
|
|
622
691
|
|
|
623
|
-
|
|
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
|
-
|
|
628
|
-
|
|
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
|
-
|
|
631
|
-
const result = await lc.identify(jwtToken)
|
|
632
|
-
```
|
|
633
|
-
|
|
634
|
-
### `Secrets`
|
|
702
|
+
### Schema Builders
|
|
635
703
|
|
|
636
704
|
```typescript
|
|
637
|
-
import {
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
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
|
-
|
|
711
|
+
Type inference helpers live on the same subpath:
|
|
648
712
|
|
|
649
713
|
```typescript
|
|
650
|
-
import {
|
|
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
|
-
|
|
653
|
-
|
|
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` | `
|
|
662
|
-
| `@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)) |
|
|
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
|
-
|
|
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
|
-
|
|
671
|
-
|
|
672
|
-
| `@cipherstash/
|
|
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** >=
|
|
686
|
-
- 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).
|
|
687
787
|
|
|
688
788
|
## License
|
|
689
789
|
|