@northstartech/cipher 0.0.0-stage → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +541 -2
  2. package/dist/client.d.ts +237 -0
  3. package/dist/client.js +555 -0
  4. package/dist/client.js.map +1 -0
  5. package/dist/crypto.d.ts +72 -0
  6. package/dist/crypto.js +244 -0
  7. package/dist/crypto.js.map +1 -0
  8. package/dist/encoding.d.ts +17 -0
  9. package/dist/encoding.js +79 -0
  10. package/dist/encoding.js.map +1 -0
  11. package/dist/errors.d.ts +14 -0
  12. package/dist/errors.js +22 -0
  13. package/dist/errors.js.map +1 -0
  14. package/dist/index.d.ts +8 -0
  15. package/dist/index.js +6 -0
  16. package/dist/index.js.map +1 -0
  17. package/dist/integrations/drizzle.d.ts +96 -0
  18. package/dist/integrations/drizzle.js +285 -0
  19. package/dist/integrations/drizzle.js.map +1 -0
  20. package/dist/integrations/mongoose.d.ts +72 -0
  21. package/dist/integrations/mongoose.js +582 -0
  22. package/dist/integrations/mongoose.js.map +1 -0
  23. package/dist/integrations/prisma.d.ts +65 -0
  24. package/dist/integrations/prisma.js +588 -0
  25. package/dist/integrations/prisma.js.map +1 -0
  26. package/dist/integrations/sequelize.d.ts +62 -0
  27. package/dist/integrations/sequelize.js +677 -0
  28. package/dist/integrations/sequelize.js.map +1 -0
  29. package/dist/integrations/shared.d.ts +268 -0
  30. package/dist/integrations/shared.js +513 -0
  31. package/dist/integrations/shared.js.map +1 -0
  32. package/dist/integrations/typeorm.d.ts +109 -0
  33. package/dist/integrations/typeorm.js +457 -0
  34. package/dist/integrations/typeorm.js.map +1 -0
  35. package/dist/object.d.ts +53 -0
  36. package/dist/object.js +226 -0
  37. package/dist/object.js.map +1 -0
  38. package/dist/search.d.ts +250 -0
  39. package/dist/search.js +258 -0
  40. package/dist/search.js.map +1 -0
  41. package/package.json +101 -4
package/README.md CHANGED
@@ -1,3 +1,542 @@
1
- # Temporary Holding Version
1
+ # @northstartech/cipher
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ TypeScript SDK for **Northstar Cipher**, field-level encryption as a service. Keys
4
+ never leave attested enclaves. Your database stores only `nsc:v1:…` ciphertext,
5
+ plus optional blind-index tokens for exact-match search.
6
+
7
+ * Node.js ≥ 20 (ESM). The core client also runs in browsers.
8
+ * ORM integrations for **Prisma, Sequelize, Mongoose, Drizzle and TypeORM** ship
9
+ as subpath exports. The ORMs are optional peer dependencies.
10
+ * **Northstar Search**: typo-tolerant full-text search over encrypted data, run
11
+ inside the enclave (see [Northstar Search](#northstar-search)).
12
+
13
+ ```sh
14
+ npm install @northstartech/cipher
15
+ ```
16
+
17
+ ## Configuration
18
+
19
+ | Variable | Default | Meaning |
20
+ |---|---|---|
21
+ | `NORTHSTAR_CIPHER_ENDPOINT` | `https://api.northstar.mx` | Gateway URL (`http://localhost:7080` locally) |
22
+ | `NORTHSTAR_CIPHER_CREDENTIAL` | (required) | Workload credential `nsc_dev_…` / `nsc_live_…` |
23
+ | `NORTHSTAR_CIPHER_KEY` | `default` | Key alias |
24
+ | `NORTHSTAR_CIPHER_ATTESTATION` | `auto` | `auto`, `development`, `production` |
25
+ | `NORTHSTAR_CIPHER_TRUST_ANCHOR` | none | Pinned verifier key(s) `<kid>:<b64u>`. Required in production. |
26
+
27
+ ## Quickstart
28
+
29
+ ```ts
30
+ import { CipherError, NorthstarCipher } from '@northstartech/cipher'
31
+
32
+ // One client per process. Constructing it performs no network I/O.
33
+ const cipher = NorthstarCipher.fromEnv()
34
+
35
+ // The context names the protected value. Decryption fails if any part differs.
36
+ const context = { collection: 'citizens', field: 'passport', recordId: '123' }
37
+ const { ciphertext } = await cipher.encrypt({ plaintext: 'G12345678', context })
38
+ const { plaintext } = await cipher.decrypt({ ciphertext, context, purpose: 'kyc_review' })
39
+
40
+ // Batches: one request per 1 000 items. Concurrent encrypt()/decrypt() calls are coalesced automatically.
41
+ const encrypted = await cipher.encryptMany(rows.map((r) => ({ plaintext: r.passport, context: { ...context, recordId: r.id } })))
42
+ const settled = await cipher.decryptManySettled(items) // [{ ok: true, value } | { ok: false, error }]
43
+
44
+ // Exact-match search: store the blind index next to the ciphertext, then match tokens.
45
+ const { index } = await cipher.encrypt({ plaintext: 'ana@example.com', context: { collection: 'users', field: 'email', recordId: '7' }, index: { normalization: 'email' } })
46
+ const [tokens] = await cipher.searchTokens({ collection: 'users', field: 'email', values: ['ANA@example.com'], normalization: 'email' })
47
+ // SELECT … WHERE email_bidx IN (…tokens)
48
+ ```
49
+
50
+ Every failure throws `CipherError` (`code`, `message`, `requestId`, `status`,
51
+ `retryable`). Nothing ever falls back to plaintext. Messages never contain
52
+ plaintext, ciphertext or secrets. To watch the sealed traffic (counts,
53
+ latency), pass `debug: { onWire(e) { … } }`.
54
+
55
+ Before the first request, the client verifies the enclave's attestation and
56
+ opens a session to the attested key with HPKE (RFC 9180) using the **X-Wing**
57
+ KEM (ML-KEM-768 + X25519, a post-quantum hybrid). Recorded traffic stays
58
+ confidential unless both ML-KEM and X25519 are broken, which protects against
59
+ harvest-now-decrypt-later attacks. There is no fallback to a classical key
60
+ exchange: an enclave offering anything but an X-Wing key fails with
61
+ `ATTESTATION_FAILED`.
62
+
63
+ ## Encrypting files and large values
64
+
65
+ `encrypt()` takes values up to 1 MiB and sends them to the enclave. For files,
66
+ images, exports and backups, use `encryptObject()`. The SDK seals the bytes in
67
+ your process with a random 32-byte object key (format NSOB v1, AES-256-GCM in
68
+ 64 KiB blocks), and Cipher encrypts only that key. The object never reaches
69
+ Cipher, so the 1 MiB limit does not apply.
70
+
71
+ ```ts
72
+ // The context names where the wrapped key is stored: table, column, record.
73
+ const context = { collection: 'documents', field: 'file_key', recordId: doc.id }
74
+ const { wrappedKey, object } = await cipher.encryptObject({ plaintext: pdfBytes, context })
75
+ await db.documents.update(doc.id, { file_key: wrappedKey }) // nsc:v1:… with the record
76
+ await storage.put(`documents/${doc.id}`, object) // the encrypted bytes
77
+
78
+ const pdf = await cipher.decryptObject({ object, wrappedKey, context, purpose: 'support_case' }) // Uint8Array
79
+ ```
80
+
81
+ * Each call makes one request (`/v1/encrypt` or `/v1/decrypt` for the key), so
82
+ policy, purposes and audit apply as for fields. `keyId` and `blockLog2`
83
+ (12 … 24, default 16) are optional.
84
+ * Rotation: `rewrapMany()` on the stored wrapped keys. Objects are not touched.
85
+ * Deletion: delete the object and its wrapped key. Destroying a key version
86
+ makes every object whose key it wraps unreadable (crypto-shredding).
87
+ * Any failure to open an object (another record's context, another object's
88
+ wrapped key, truncation, reordered blocks, a flipped bit) throws
89
+ `INVALID_CIPHERTEXT` with one message. Plaintext is returned only after every
90
+ block verifies.
91
+ * `sealObject()` and `openObject()` are the format functions without a request,
92
+ for streaming and the vectors (`docs/vectors/object-v1.json`). The object key
93
+ is zeroed after use and never logged or cached.
94
+
95
+ ---
96
+
97
+ ## ORM integrations
98
+
99
+ | ORM | Import | Entry point |
100
+ |---|---|---|
101
+ | Prisma 7 | `@northstartech/cipher/prisma` | `withNorthstarCipher(cipher, { models })` client extension |
102
+ | Sequelize 6 | `@northstartech/cipher/sequelize` | `northstarCipher(Model, cipher, { fields })` |
103
+ | Mongoose 9 | `@northstartech/cipher/mongoose` | `schema.plugin(northstarCipherPlugin, { cipher, fields })` |
104
+ | Drizzle | `@northstartech/cipher/drizzle` | `encrypted(table, fields)`, `northstarDrizzle(cipher)`, `cipherEq()` |
105
+ | TypeORM 1 | `@northstartech/cipher/typeorm` | `@Encrypted()` + `northstarCipherSubscriber(cipher)` |
106
+
107
+ ### Common behaviour
108
+
109
+ All five integrations follow the same conventions, so data written by one SDK
110
+ (any language) can be read by another.
111
+
112
+ * **Context.** `collection` is the table (or Mongo collection) name, `field` is
113
+ the column name, and `record_id` is the primary key as a string. UUIDs are
114
+ lowercased and ObjectIds use their hex form. Changing a primary key would
115
+ invalidate its ciphertext, so the integrations refuse to do it.
116
+ * **Columns.** Encrypted columns are `text`. Ciphertext is about 180
117
+ characters plus 4/3 × the plaintext length. A searchable field `email` stores
118
+ its blind index in `email_bidx` (`varchar(64)`, indexed), written in the same
119
+ statement. Encrypted values are strings; serialize other types first.
120
+ * **Add a CHECK constraint** to every encrypted column so raw SQL can never
121
+ store plaintext:
122
+
123
+ ```sql
124
+ ALTER TABLE todos ADD CONSTRAINT todos_title_nsc CHECK (title IS NULL OR title LIKE 'nsc:%');
125
+ ```
126
+
127
+ * **Writes.** Values are encrypted before the row is written: all encrypted
128
+ fields of a record go in one batch request, and bulk operations send one
129
+ batch for all their records. The record id must be known before the write:
130
+
131
+ | Primary key | Strategy |
132
+ |---|---|
133
+ | Explicit or client-generated (UUID, cuid, ULID, ObjectId, `$defaultFn`) | used directly |
134
+ | PostgreSQL serial/identity | reserved first with `nextval(pg_get_serial_sequence(…))` (Prisma, Sequelize, Drizzle) |
135
+ | Auto-increment elsewhere (SQLite, MySQL), and TypeORM generated integer keys | two-phase write: insert the `nsc:pending:v1` sentinel, then replace it with ciphertext bound to the generated id, in the same transaction when one is available |
136
+
137
+ If encryption fails, the write is aborted. A failed second phase deletes the
138
+ pending row. The sentinel can never be decrypted, so a crash leaves no
139
+ plaintext behind.
140
+ * **Reads.** Every encrypted value of every row a query returns (including
141
+ eagerly loaded relations) is decrypted in one batch, chunked at 1 000 values.
142
+ Listing 100 rows with two encrypted fields costs one request, not 200.
143
+ * **Fail closed.** A stored value that is not `nsc:v1:` ciphertext (plaintext,
144
+ the pending sentinel, a foreign value) throws `INVALID_CIPHERTEXT`. So does
145
+ ciphertext that was tampered with or moved to another record. Reading legacy
146
+ plaintext requires an explicit `readLegacyPlaintext: true`, intended for
147
+ migrations only.
148
+ * **Search.** Only exact match is supported, through the blind index, using the
149
+ field's normalization (`exact`, `case_insensitive`, `email`, `alphanumeric`,
150
+ `digits`). **`LIKE`, ranges, ordering and aggregates do not work on encrypted
151
+ fields.** Where an integration can see such a query, it throws instead of
152
+ silently returning wrong results. Blind indexes reveal equality (rows with the
153
+ same value share a token), so do not index low-cardinality fields such as
154
+ status, booleans or country.
155
+ * **Options** shared by every integration:
156
+ * `keyId` sets the key alias.
157
+ * `purpose` is the audited decrypt purpose.
158
+ * `readLegacyPlaintext` is for migrations only.
159
+
160
+ | Error code | When |
161
+ |---|---|
162
+ | `CONFIGURATION_ERROR` | Misconfiguration: unknown field, non-text column, no blind-index column, unsupported key |
163
+ | `INVALID_REQUEST` | An operation that cannot be encrypted correctly: bulk update of encrypted fields, filter on ciphertext, primary-key change |
164
+ | `INVALID_CIPHERTEXT` | Stored value tampered, moved, plaintext or pending |
165
+ | service codes | `KEY_NOT_FOUND`, `PERMISSION_DENIED`, `RATE_LIMITED`, … as returned by the service |
166
+
167
+ > **What bypasses the integrations.** Raw SQL and driver calls do, as do
168
+ > `$executeRaw`, `Model.collection.*`, Sequelize `hooks: false` and TypeORM
169
+ > `listeners: false`. The CHECK constraint is the backstop for all of them.
170
+ > Sequelize refuses `hooks: false` writes that would store plaintext.
171
+
172
+ ### Prisma
173
+
174
+ Requires Prisma 7 (the `prisma-client` generator with a driver adapter).
175
+
176
+ ```prisma
177
+ model Todo {
178
+ id Int @id @default(autoincrement())
179
+ title String? // encrypted
180
+ email String? // encrypted + searchable
181
+ email_bidx String? @db.VarChar(64)
182
+ @@index([email_bidx])
183
+ @@map("todos")
184
+ }
185
+ ```
186
+
187
+ ```ts
188
+ import { PrismaPg } from '@prisma/adapter-pg'
189
+ import { withNorthstarCipher } from '@northstartech/cipher/prisma'
190
+ import { PrismaClient } from './generated/prisma/client.js'
191
+
192
+ const nsc = withNorthstarCipher(cipher, {
193
+ models: {
194
+ Todo: { title: true, email: { searchable: 'email' } },
195
+ Note: { $id: 'cuid', body: true }, // match the id format of @default(cuid())
196
+ },
197
+ })
198
+ const prisma = new PrismaClient({ adapter: new PrismaPg({ connectionString }) }).$extends(nsc)
199
+
200
+ await prisma.todo.create({ data: { title: 'Buy milk', email: 'Ana@example.com' } })
201
+ await prisma.todo.update({ where: { id }, data: { email: 'new@example.com' } })
202
+ const todos = await prisma.todo.findMany({ include: { owner: true } }) // one decrypt request
203
+
204
+ const mine = await prisma.todo.findMany({ where: await prisma.todo.cipherWhere('email', 'ana@example.com') })
205
+ // or: where: await nsc.cipherWhere('Todo', 'email', ['a@x.com', 'b@x.com']) → { email_bidx: { in: [...] } }
206
+ ```
207
+
208
+ **Ids.** Configure them with `$id: '<strategy>'` or `$id: { field, strategy }`.
209
+ * `String` ids are generated client-side. The default is `'uuid'`; also
210
+ available are `'uuidv7'`, `'cuid'`, `'cuid2'`, `'ulid'`, `'nanoid'`, or a
211
+ function.
212
+ * `Int`/`BigInt` ids on PostgreSQL/CockroachDB are reserved from the sequence
213
+ (`'sequence'`).
214
+ * Integer auto-increment ids on other databases are a configuration error.
215
+ Use a client-generated id, or `$id: 'explicit'` and always pass ids.
216
+ * Composite primary keys are not supported.
217
+
218
+ The extension handles `create`, `createMany`, `createManyAndReturn`, `update`,
219
+ `upsert`, `delete` and every `find*`, including `select`, `omit`, `include`,
220
+ nested relations and the fluent API. When a selection includes encrypted
221
+ fields but omits the id, the extension fetches the id and strips it from the
222
+ result.
223
+
224
+ **Limitations.**
225
+ * `updateMany`/`updateManyAndReturn` cannot set encrypted fields, because each
226
+ row needs its own ciphertext. Update records individually.
227
+ * Nested writes into an encrypted model (`owner: { create: { ssn } }`) are
228
+ refused. Write that model through its own delegate.
229
+ * With a reserved integer id, Prisma's relation inputs (`owner: { connect }`)
230
+ cannot carry the id. Set the foreign-key scalar (`ownerId`) instead.
231
+ * `update`/`upsert` addressed by a non-id unique field look the id up first
232
+ through the base client, outside any interactive transaction. Inside
233
+ `$transaction(async (tx) => …)`, address rows by id.
234
+ * Reserved sequence values are not rolled back, so gaps are normal.
235
+ * `where`, `orderBy`, `distinct`, `groupBy`, `_min` and `_max` on encrypted
236
+ fields are refused. Null checks are allowed.
237
+ * `$queryRaw` returns ciphertext. Decrypt it with the core client.
238
+
239
+ ### Sequelize
240
+
241
+ ```ts
242
+ import { northstarCipher } from '@northstartech/cipher/sequelize'
243
+
244
+ class Todo extends Model {}
245
+ Todo.init({
246
+ id: { type: DataTypes.INTEGER, autoIncrement: true, primaryKey: true },
247
+ title: { type: DataTypes.TEXT, validate: { len: [1, 60] } }, // validators see plaintext
248
+ email: { type: DataTypes.TEXT, validate: { isEmail: true } },
249
+ email_bidx: DataTypes.STRING(64),
250
+ }, { sequelize, tableName: 'todos' })
251
+
252
+ const T = northstarCipher(Todo, cipher, { fields: ['title', { name: 'email', searchable: 'email' }] })
253
+
254
+ await Todo.create({ title: 'Buy milk', email: 'ana@example.com' })
255
+ await Todo.bulkCreate(rows, { validate: true }) // one encrypt request
256
+ const all = await Todo.findAll({ include: [Owner] }) // one decrypt request, includes too
257
+ const mine = await Todo.findAll({ where: await T.cipherWhere('email', 'ANA@example.com') })
258
+ ```
259
+
260
+ **Ids.** Explicit ids and defaults such as `UUIDV4` are used directly.
261
+ `autoIncrement` keys are reserved from the sequence on PostgreSQL. Other
262
+ dialects use the two-phase write: `afterCreate`/`afterBulkCreate` replace the
263
+ sentinel inside `options.transaction` when given, and delete the row if that
264
+ fails.
265
+
266
+ **Writes and reads.**
267
+ * Instances hold plaintext after `create`/`save`, and `changed()` is clean.
268
+ * Validators always run on plaintext, including bulk validation and
269
+ Sequelize's re-validation of hook-changed attributes.
270
+ * A static `Model.update()` that sets encrypted attributes switches to
271
+ per-instance hooks, so every row gets its own ciphertext.
272
+ * `afterFind` decrypts all returned instances, their eager-loaded includes and
273
+ `raw: true` rows in one batch. Select the primary key together with encrypted
274
+ attributes.
275
+
276
+ **Refused.**
277
+ * `Model.upsert()` with encrypted attributes. Sequelize builds the statement
278
+ before hooks run, so use `findOrCreate`/`save` instead.
279
+ * `updateOnDuplicate` on encrypted attributes.
280
+ * `ignoreDuplicates` combined with the two-phase write.
281
+ * `where` or `order` on encrypted attributes.
282
+ * Primary-key changes.
283
+ * `hooks: false` writes that would store plaintext.
284
+
285
+ ### Mongoose
286
+
287
+ ```ts
288
+ import { northstarCipherPlugin, type CipherModelStatics, type CipherQueryHelpers } from '@northstartech/cipher/mongoose'
289
+
290
+ const schema = new Schema({
291
+ title: { type: String, maxlength: 60 },
292
+ email: { type: String, lowercase: true, trim: true }, // setters and validators apply to plaintext
293
+ profile: { ssn: String },
294
+ })
295
+ schema.plugin(northstarCipherPlugin, { cipher, fields: ['title', { path: 'email', searchable: 'email' }, { path: 'profile.ssn', searchable: 'alphanumeric' }] })
296
+ const Todo = model('Todo', schema)
297
+
298
+ await Todo.create({ title: 'Buy milk', email: 'Ana@example.com' })
299
+ await Todo.insertMany(docs) // one encrypt request
300
+ const todos = await Todo.find().populate('owner') // one decrypt request per model
301
+ const mine = await Todo.find().cipherWhere('email', 'ana@example.com')
302
+ const one = await Todo.findOne(await Todo.cipherFilter('profile.ssn', 'G-1234'))
303
+ ```
304
+
305
+ **Paths and ids.** `_id` is the record id: ObjectIds exist before the insert,
306
+ so no extra round trip is needed. Encrypted paths are top-level or
307
+ nested-object `String` paths; arrays and subdocuments are not supported. The
308
+ plugin adds `<path>_bidx` (indexed) for searchable paths.
309
+
310
+ **Writes.**
311
+ * `save()`, `create()` and `insertMany()` encrypt. `insertMany()` uses one
312
+ batch for all documents.
313
+ * `updateOne`, `findOneAndUpdate`, `replaceOne` and `findOneAndReplace` encrypt
314
+ `$set`, `$setOnInsert`, implicit sets and replacements. The update is bound
315
+ to the single matched document: its `_id` is resolved with the same filter
316
+ and sort, and the filter is pinned to it. Upserts insert under a fresh
317
+ `_id`. `$unset` also clears the blind index.
318
+
319
+ **Refused.**
320
+ * `updateMany` and `bulkWrite` that write encrypted paths.
321
+ * Other update operators on encrypted paths.
322
+ * Filters, `sort` and `distinct` on encrypted paths. Null and `$exists`
323
+ checks are allowed.
324
+ * Aggregation-pipeline updates that touch encrypted paths.
325
+
326
+ **Reads.** `find`, `findOne` and the `findOneAnd*` methods decrypt both
327
+ documents and `lean()` results. `aggregate()`, cursors and change streams
328
+ return ciphertext; pass the results to `Model.cipherDecrypt(docs)`.
329
+
330
+ **TypeScript.** Use `Model<T, CipherQueryHelpers> & CipherModelStatics`.
331
+
332
+ ### Drizzle
333
+
334
+ Drizzle has no async hooks, so encryption is an explicit, typed step around
335
+ your queries.
336
+
337
+ ```ts
338
+ import { cipherEq, encrypted, northstarDrizzle, pendingRow, PENDING_SENTINEL } from '@northstartech/cipher/drizzle'
339
+
340
+ export const todos = encrypted(
341
+ pgTable('todos', { id: serial('id').primaryKey(), title: text('title'), email: text('email'), emailBidx: varchar('email_bidx', { length: 64 }) }),
342
+ { title: true, email: { searchable: 'email' } }, // `email_bidx` is found by column name (or set indexField)
343
+ )
344
+ const nsc = northstarDrizzle(cipher, { db }) // db reserves PostgreSQL serial ids
345
+
346
+ await db.insert(todos).values(await nsc.encryptRows(todos, [{ title: 'Buy milk', email: 'ana@example.com' }]))
347
+ const rows = await nsc.decryptRows(todos, await db.select().from(todos)) // one decrypt request
348
+ const tokens = await nsc.searchTokens(todos.email, 'ANA@example.com')
349
+ const mine = await nsc.decryptRows(todos, await db.select().from(todos).where(cipherEq(todos.email, tokens)))
350
+ await db.update(todos).set(await nsc.encryptRow(todos, { email: 'new@example.com' }, { recordId: id })).where(eq(todos.id, id))
351
+ ```
352
+
353
+ **Ids.**
354
+ * An id already in the row is used.
355
+ * Otherwise the primary key's `$defaultFn` (for example
356
+ `uuid('id').$defaultFn(() => crypto.randomUUID())`) is called.
357
+ * Otherwise a PostgreSQL `serial`/identity key is reserved with one `nextval`
358
+ query. Pass `{ db: tx }` inside transactions. `generatedAlwaysAsIdentity`
359
+ rejects explicit ids; use `generatedByDefaultAsIdentity`.
360
+ * For databases where the id only exists after the insert, use the two-phase
361
+ write:
362
+
363
+ ```ts
364
+ const [{ id }] = await db.insert(tickets).values(pendingRow(tickets, input)).returning({ id: tickets.id })
365
+ await db.update(tickets).set(await nsc.encryptRow(tickets, input, { recordId: id }))
366
+ .where(and(eq(tickets.id, id), eq(tickets.subject, PENDING_SENTINEL)))
367
+ ```
368
+
369
+ **Reads.** `decryptRows` expects rows keyed like the table, including partial
370
+ selects that keep the primary key. For joins, pass each table's sub-object.
371
+ Because writes are explicit, CHECK constraints matter most here.
372
+
373
+ ### TypeORM
374
+
375
+ ```ts
376
+ import { Encrypted, cipherWhere, northstarCipherSubscriber } from '@northstartech/cipher/typeorm'
377
+
378
+ @Entity('todos')
379
+ export class Todo {
380
+ @PrimaryGeneratedColumn('uuid') id!: string
381
+ @Encrypted() @Column({ type: 'text', nullable: true }) title!: string | null
382
+ @Encrypted({ searchable: 'email' }) @Column({ type: 'text', nullable: true }) email!: string | null
383
+ @Index() @Column({ type: 'varchar', length: 64, nullable: true }) email_bidx!: string | null
384
+ }
385
+
386
+ const ds = new DataSource({ /* … */ entities: [Todo], subscribers: [northstarCipherSubscriber(cipher)] })
387
+ await ds.getRepository(Todo).save(todos) // encryption coalesced into one request
388
+ const all = await ds.getRepository(Todo).find({ relations: { owner: true } })
389
+ const mine = await ds.getRepository(Todo).findBy(await cipherWhere(ds, Todo, 'email', 'ANA@example.com'))
390
+ ```
391
+
392
+ `@Encrypted()` uses TypeORM's legacy decorators (`experimentalDecorators`).
393
+ Declare an explicit `text` column type. You can also register an instance with
394
+ `dataSource.subscribers.push(new NorthstarCipherSubscriber(cipher))`.
395
+
396
+ **Ids.**
397
+ * `uuid` primary keys are generated client-side in `beforeInsert`.
398
+ * Generated integer keys (`increment`, `identity`, `rowid`) use the two-phase
399
+ write in `afterInsert`, inside `save()`'s transaction. TypeORM never sends
400
+ explicit values for them on PostgreSQL. On failure, the row is removed and
401
+ the transaction rolls back.
402
+ * `@PrimaryColumn()` values must be set before saving.
403
+
404
+ **Writes and reads.**
405
+ * `save()` re-encrypts only properties that differ from the loaded entity.
406
+ * `afterLoad` decrypts each value through the SDK's auto-batching, so a query
407
+ costs one request per 1 000 values. Keep the client's `batching` enabled.
408
+
409
+ **Refused and not intercepted.**
410
+ * `repository.update()` or `QueryBuilder.update()` setting encrypted columns
411
+ is refused. Load the entity and `save()` it.
412
+ * TypeORM has no pre-find hook, so `where` and `order` on encrypted columns
413
+ are not intercepted. Use `cipherWhere()`.
414
+ * `upsert()` must conflict on the primary key.
415
+
416
+ ---
417
+
418
+ ## Northstar Search
419
+
420
+ Encrypted full-text search that runs inside the attested enclave. Documents and
421
+ queries travel sealed to the enclave session; index files are encrypted at rest
422
+ under a key only the enclave can unwrap. Indexing needs `cipher.index`, queries
423
+ need `cipher.search`, and returning values (`select`, `highlight`) or indexing
424
+ ciphertext needs `cipher.decrypt`.
425
+
426
+ ```ts
427
+ const customers = cipher.index('customers') // no request until a method is called
428
+
429
+ // Create the index, or confirm it exists with this schema (outcome: created | unchanged).
430
+ await customers.ensure({
431
+ language: 'spanish', // english, spanish, portuguese, french, german, italian, dutch, none
432
+ fields: { name: 'text', email: 'keyword', city: 'keyword', balance: 'number', joined: 'date', vip: 'bool' },
433
+ }) // or fields: [{ name, type, boost?, language? }]
434
+
435
+ // Add or replace documents ({ [id]: fields } or [{ id, fields }]); chunked by 1 000.
436
+ await customers.upsert({
437
+ 42: { name: 'Ana García', city: 'CDMX', balance: 1500, joined: new Date() },
438
+ // Ciphertext is decrypted inside the enclave. A bare `nsc:v1:` string uses the
439
+ // context { collection: <index or `collection` option>, field: <field>, record_id: <id> } …
440
+ 43: { name: storedNameCiphertext, city: 'GDL' },
441
+ // … or give the context explicitly:
442
+ 44: { name: SearchIndex.ciphertext(row.name, { collection: 'customers', field: 'name', recordId: 44 }) },
443
+ }, { wait: true }) // wait: return once searchable
444
+ await customers.upsert({ 42: { city: 'MTY' } }, { mode: 'merge' }) // only these fields change (null removes one)
445
+ await customers.delete([43])
446
+
447
+ const r = await customers.search({
448
+ q: 'ana garsia', // typo tolerant; fuzzy: auto | off | one | two
449
+ filter: { city: ['CDMX', 'MTY'], balance: { gte: 1000 }, vip: { exists: false } }, // value | [values] | {gt,gte,lt,lte} | {exists} | {not}
450
+ sort: [{ field: 'balance', desc: true }],
451
+ limit: 20, offset: 0, facets: ['city'],
452
+ select: ['name', 'city'], highlight: ['name'], purpose: 'support', // values are returned decrypted
453
+ })
454
+ // r.hits = [{ id: '42', score: 3.1, fields: { name: 'Ana García', city: 'MTY' }, highlights: { name: '<mark>Ana</mark> <mark>García</mark>' } }]
455
+ // r.total, r.facets.city = [{ value: 'MTY', count: 1 }], r.tookMs
456
+
457
+ await customers.describe() // { index, keyId, active: { version, documents, diskBytes, … }, building? }
458
+ await cipher.searchIndexes() // every index this credential may use
459
+ await customers.drop() // true, or false when there was none
460
+ ```
461
+
462
+ **Changing the schema without downtime.** `ensure(newSchema)` fails with
463
+ `CONFLICT` when the schema changed. `ensure(newSchema, { rebuild: true })` builds
464
+ a new version next to the live one: writes reach both, queries keep using the
465
+ live one. Reindex every record, then `flush({ optimize: true })` and
466
+ `activate()` to switch over (the old version is deleted).
467
+
468
+ A missing index raises `NOT_FOUND`. Documents the enclave could not index (e.g.
469
+ a field missing from the schema, a ciphertext bound to another context) are
470
+ listed in `errors`; the rest of the request is indexed.
471
+
472
+ ### Search with the ORM integrations
473
+
474
+ Each integration builds documents whose encrypted fields are the **stored
475
+ ciphertext with the exact context the integration encrypted it with**
476
+ (collection = table, field = column, record id = primary key), so indexing
477
+ never decrypts in your process. Plain attributes are sent as values (dates as
478
+ RFC 3339). Integrations with lifecycle hooks keep an index in sync; sync writes
479
+ use `merge` mode, so partially loaded records do not erase indexed fields.
480
+
481
+ ```ts
482
+ const index = cipher.index('todos')
483
+ await index.ensure({ fields: { title: 'text', email: 'keyword', done: 'bool' } })
484
+
485
+ // Sequelize: create/save/destroy, bulkCreate, Model.update/destroy (after commit inside a transaction)
486
+ northstarCipher(Todo, cipher, { fields: ['title', { name: 'email', searchable: 'email' }], search: { index, fields: ['title', 'email', 'done'] } })
487
+ await index.upsert(Todo.cipherSearchDocuments(await Todo.findAll({ hooks: false } as FindOptions))) // reindex
488
+
489
+ // TypeORM: save/remove/softRemove, applied when the transaction commits (dropped on rollback)
490
+ new DataSource({ …, subscribers: [northstarCipherSubscriber(cipher, { search: [{ entity: Todo, index, fields: ['title', 'email', 'done'] }] })] })
491
+ await index.upsert(searchDocuments(ds, Todo, await ds.query('SELECT * FROM todos'), ['title', 'email', 'done']))
492
+
493
+ // Mongoose: save, insertMany, doc.deleteOne(), findOneAndDelete()
494
+ todoSchema.plugin(northstarCipherPlugin, { cipher, fields: ['title'], search: { index, fields: ['title', 'done'] } })
495
+ await index.upsert(Todo.cipherSearchDocuments(await Todo.collection.find().toArray()))
496
+
497
+ // Prisma and Drizzle (no lifecycle hooks): build documents from stored rows
498
+ await index.upsert(nsc.searchDocuments('Todo', await base.todo.findMany(), ['title', 'email', 'done'])) // base = client without the extension
499
+ await index.upsert(searchDocuments(todos, await db.select().from(todos), ['title', 'email', 'done'])) // @northstartech/cipher/drizzle
500
+ ```
501
+
502
+ Sync options: `{ index, fields, wait?, onError? }`. Without `onError`, an index
503
+ failure rejects the ORM call, or is logged when it happens after a commit.
504
+ Writes the hooks cannot see (raw SQL, `QueryBuilder`/query updates,
505
+ `deleteMany`, Sequelize `upsert()`) are not tracked: reindex after them. Load
506
+ hits back with your ORM (`where: { id: hits.map((h) => h.id) }`) and keep the
507
+ hit order.
508
+
509
+ ---
510
+
511
+ ## Running the tests
512
+
513
+ `npx vitest run` runs three groups:
514
+
515
+ * The protocol vector tests (`docs/vectors/protocol-v1.json`, plus the X-Wing
516
+ vectors `xwing-v1.json` and the draft-ietf-hpke-pq reference `xwing-reference.json`)
517
+ and the object vectors (`object-v1.json`, `test/object-vectors.test.ts`).
518
+ * The object encryption tests (`test/object.test.ts`): mocked transport, and a
519
+ live flow with a 3 MiB object (one small `/v1/encrypt`, tampering, rewrap).
520
+ * The Northstar Search unit tests (`test/search.test.ts`, mocked transport) and
521
+ its live flow (index `sdk_ts_<random>`: ensure, ciphertext indexing, typos,
522
+ filters, facets, highlights, merge, delete, rebuild + activate, drop).
523
+ * The live ORM integration tests, including search sync. They need the local stack
524
+ (`curl localhost:7080/health/ready`) and a credential:
525
+
526
+ ```sh
527
+ node ../../scripts/dev-provision.mjs --email node-orm@northstar.mx --project "Node ORMs" --out .test-credential.env
528
+ npx vitest run
529
+ ```
530
+
531
+ * PostgreSQL defaults to `postgres://postgres@127.0.0.1:5432`; override it
532
+ with `NSC_TEST_PG_URL`. The databases `nsc_sdk_node_{prisma,sequelize,sequelize_search,drizzle,typeorm}`
533
+ are dropped and recreated on each run; search indexes `sdk_ts_*` are dropped at the end.
534
+ * MongoDB runs in `mongodb-memory-server`, and SQLite runs in memory.
535
+ * The Prisma test client is generated into `test/prisma/generated/`.
536
+ * Without a credential, the live suites are skipped with a notice.
537
+
538
+ Each suite proves four things:
539
+ * Only ciphertext and tokens reach the database (checked with raw queries).
540
+ * Listing 100 rows takes at most 2 decrypt requests (counted with `debug.onWire`).
541
+ * Exact-match search works.
542
+ * Tampered, swapped, pending and plaintext values throw.