@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
@@ -0,0 +1,65 @@
1
+ import type { NorthstarCipher } from '../client.js';
2
+ import { CipherError } from '../errors.js';
3
+ import { type CipherIntegrationOptions, type EncryptedFieldSpec, type IdGeneratorName } from './shared.js';
4
+ import type { SearchDocument } from '../search.js';
5
+ export { CIPHERTEXT_PREFIX, PENDING_SENTINEL } from './shared.js';
6
+ export type { EncryptedFieldOptions, EncryptedFieldSpec, CipherIntegrationOptions } from './shared.js';
7
+ /**
8
+ * How the record id is obtained when `data` does not contain it:
9
+ * * `'sequence'`: reserve from the PostgreSQL serial/identity sequence (`nextval(pg_get_serial_sequence(…))`);
10
+ * * `'uuid' | 'uuidv7' | 'cuid' | 'cuid2' | 'ulid' | 'nanoid'`: generate client-side;
11
+ * * `'explicit'`: the application always supplies the id;
12
+ * * a function returning a new id.
13
+ *
14
+ * Default: `'uuid'` for `String` ids, `'sequence'` for `Int`/`BigInt` ids on
15
+ * PostgreSQL/CockroachDB. Integer ids on other databases are a configuration
16
+ * error (the id is unknown before the insert): use a client-generated id.
17
+ */
18
+ export type PrismaIdStrategy = 'sequence' | 'explicit' | IdGeneratorName | (() => string | number | bigint);
19
+ export interface PrismaIdConfig {
20
+ /** Primary-key field. Default `id`. Composite keys are not supported. */
21
+ field?: string;
22
+ strategy?: PrismaIdStrategy;
23
+ }
24
+ /** Per-model config: `{ title: true, email: { searchable: 'email' }, $id?: … }`. */
25
+ export type PrismaModelCipherConfig = {
26
+ $id?: PrismaIdConfig | PrismaIdStrategy;
27
+ } & {
28
+ [field: string]: EncryptedFieldSpec | PrismaIdConfig | PrismaIdStrategy | undefined;
29
+ };
30
+ export interface NorthstarPrismaOptions extends CipherIntegrationOptions {
31
+ /** Encrypted fields per model (Prisma model names, e.g. `Todo`). */
32
+ models: Record<string, PrismaModelCipherConfig>;
33
+ }
34
+ /** `where` fragment produced by `cipherWhere`: `{ email_bidx: { in: [...] } }`. */
35
+ export type CipherWhere = Record<string, {
36
+ in: string[];
37
+ }>;
38
+ /**
39
+ * Create the Prisma Client extension. Apply it with `prisma.$extends(...)`.
40
+ * The returned value also exposes `cipherWhere(model, field, value)`.
41
+ */
42
+ export declare function withNorthstarCipher(cipher: NorthstarCipher, options: NorthstarPrismaOptions): ((client: any) => import("@prisma/client/extension").PrismaClientExtends<import("@prisma/client/runtime/client").InternalArgs<{}, {
43
+ $allModels: {
44
+ /** Exact-match `where` fragment for a searchable encrypted field: `{ <field>_bidx: { in: tokens } }`. */
45
+ cipherWhere<T>(this: T, field: string, value: string | string[]): Promise<CipherWhere>;
46
+ /** Northstar Search documents for rows of this model (see `searchDocuments` on the extension). */
47
+ cipherSearchDocuments<T>(this: T, rows: Record<string, unknown>[], fields: string[]): SearchDocument[];
48
+ };
49
+ }, {}, {}> & import("@prisma/client/runtime/client").DefaultArgs>) & {
50
+ /**
51
+ * Exact-match filter for a searchable encrypted field:
52
+ * `await nsc.cipherWhere('Todo', 'email', 'ana@example.com')` → `{ email_bidx: { in: [...] } }`.
53
+ * Spread it into `where`. Requires the extension to be applied to a client first.
54
+ */
55
+ cipherWhere(model: string, field: string, value: string | string[]): Promise<CipherWhere>;
56
+ /**
57
+ * Northstar Search documents for rows of `model`. Rows read with the base
58
+ * client (`new PrismaClient()` without the extension, or raw SQL keyed by
59
+ * field name) hold stored ciphertext, which is sent with the context it is
60
+ * bound to and decrypted inside the enclave: indexing needs no decrypt
61
+ * request. Rows read through the extension hold plaintext, which is sent as is.
62
+ */
63
+ searchDocuments(model: string, rows: Record<string, unknown>[], fields: string[]): SearchDocument[];
64
+ };
65
+ export { CipherError };
@@ -0,0 +1,588 @@
1
+ /**
2
+ * Prisma Client extension: transparent field-level encryption for Prisma models.
3
+ *
4
+ * ```ts
5
+ * import { PrismaClient } from './generated/prisma/client.js'
6
+ * import { NorthstarCipher } from '@northstartech/cipher'
7
+ * import { withNorthstarCipher } from '@northstartech/cipher/prisma'
8
+ *
9
+ * const cipher = NorthstarCipher.fromEnv()
10
+ * const nsc = withNorthstarCipher(cipher, { models: { Todo: { title: true, email: { searchable: 'email' } } } })
11
+ * const prisma = new PrismaClient({ adapter }).$extends(nsc)
12
+ *
13
+ * await prisma.todo.create({ data: { title: 'Buy milk', email: 'Ana@example.com' } })
14
+ * const todos = await prisma.todo.findMany() // one decrypt request per query
15
+ * const mine = await prisma.todo.findMany({ where: await prisma.todo.cipherWhere('email', 'ana@example.com') })
16
+ *
17
+ * // Northstar Search: index rows read with the base client (stored ciphertext, decrypted inside the enclave)
18
+ * await cipher.index('todos').upsert(nsc.searchDocuments('Todo', await base.todo.findMany(), ['title', 'email', 'done']))
19
+ * ```
20
+ *
21
+ * Writes encrypt `data` before the statement is sent (one batch per operation),
22
+ * reads decrypt every encrypted field of every returned row (including nested
23
+ * `include`/`select` relations) in one batch. See README "Prisma".
24
+ */
25
+ import { Prisma } from '@prisma/client/extension';
26
+ import { CipherError } from '../errors.js';
27
+ import { ID_GENERATORS, INDEX_SUFFIX, RESERVE_IDS_SQL, configError, decryptValues, encryptValues, isIdGeneratorName, normalizeFieldSpec, plaintextOf, qualifiedTable, recordIdOf, requestError, reservedIdsFrom, searchDocumentOf, searchTokensFor, } from './shared.js';
28
+ export { CIPHERTEXT_PREFIX, PENDING_SENTINEL } from './shared.js';
29
+ const READ_OPS = new Set(['findUnique', 'findUniqueOrThrow', 'findFirst', 'findFirstOrThrow', 'findMany']);
30
+ const RETURNING_WRITE_OPS = new Set(['create', 'update', 'upsert', 'delete', 'createManyAndReturn', 'updateManyAndReturn']);
31
+ const NESTED_WRITE_KEYS = ['create', 'createMany', 'connectOrCreate', 'update', 'updateMany', 'upsert'];
32
+ const NULL_ONLY_FILTER_KEYS = new Set(['equals', 'not', 'isSet']);
33
+ function lowerFirst(s) {
34
+ return s.charAt(0).toLowerCase() + s.slice(1);
35
+ }
36
+ function isPlainObject(v) {
37
+ if (!v || typeof v !== 'object' || Array.isArray(v))
38
+ return false;
39
+ const p = Object.getPrototypeOf(v);
40
+ return p === Object.prototype || p === null;
41
+ }
42
+ function asArray(v) {
43
+ return v === undefined || v === null ? [] : Array.isArray(v) ? v : [v];
44
+ }
45
+ /** Resolve and validate the configuration against the client's runtime data model. */
46
+ function resolveSchema(options, client) {
47
+ const c = client;
48
+ const models = c._runtimeDataModel?.models;
49
+ if (!models)
50
+ throw configError('Prisma: could not read the client data model (unsupported Prisma version?)');
51
+ const provider = String(c._activeProvider ?? '');
52
+ if (!options || typeof options !== 'object' || !options.models || typeof options.models !== 'object') {
53
+ throw configError('Prisma: withNorthstarCipher(cipher, { models: { Model: { field: true } } }) requires a models map');
54
+ }
55
+ const relations = new Map();
56
+ for (const [name, m] of Object.entries(models)) {
57
+ relations.set(name, new Map(m.fields.filter((f) => f.kind === 'object').map((f) => [f.name, f.type])));
58
+ }
59
+ const plans = new Map();
60
+ for (const [model, cfg] of Object.entries(options.models)) {
61
+ const rm = models[model];
62
+ if (!rm)
63
+ throw configError(`Prisma: unknown model "${model}" (use the model name from schema.prisma, e.g. "Todo")`);
64
+ if (!cfg || typeof cfg !== 'object')
65
+ throw configError(`Prisma: config for ${model} must be an object of encrypted fields`);
66
+ const byName = new Map(rm.fields.map((f) => [f.name, f]));
67
+ const idCfg = typeof cfg.$id === 'string' || typeof cfg.$id === 'function' ? { strategy: cfg.$id } : (cfg.$id ?? {});
68
+ const idField = idCfg.field ?? 'id';
69
+ const idf = byName.get(idField);
70
+ if (!idf || idf.kind !== 'scalar') {
71
+ throw configError(`Prisma: ${model} has no scalar "${idField}" field. Set $id: { field: '<primary key>' } (composite primary keys are not supported for encrypted models)`);
72
+ }
73
+ if (idf.type !== 'String' && idf.type !== 'Int' && idf.type !== 'BigInt')
74
+ throw configError(`Prisma: ${model}.${idField} must be String, Int or BigInt to bind ciphertext to it`);
75
+ const idType = idf.type;
76
+ let strategy;
77
+ const pgLike = provider === 'postgresql' || provider === 'cockroachdb';
78
+ if (idCfg.strategy !== undefined) {
79
+ strategy = idCfg.strategy;
80
+ if (typeof strategy !== 'function' && strategy !== 'sequence' && strategy !== 'explicit' && !isIdGeneratorName(strategy)) {
81
+ throw configError(`Prisma: ${model}.$id strategy "${String(strategy)}" is not supported`);
82
+ }
83
+ if (strategy === 'sequence' && !pgLike)
84
+ throw configError(`Prisma: ${model}.$id strategy "sequence" requires PostgreSQL (provider is ${provider})`);
85
+ if (strategy === 'sequence' && idType === 'String')
86
+ throw configError(`Prisma: ${model}.${idField} is a String; "sequence" needs an integer key`);
87
+ if (isIdGeneratorName(strategy) && idType !== 'String')
88
+ throw configError(`Prisma: ${model}.${idField} is ${idType}; "${strategy}" generates strings`);
89
+ }
90
+ else if (idType === 'String')
91
+ strategy = 'uuid';
92
+ else if (pgLike)
93
+ strategy = 'sequence';
94
+ else {
95
+ throw configError(`Prisma: ${model}.${idField} is an auto-increment ${idType} on ${provider}, so the record id is unknown before the insert. ` +
96
+ `Use a client-generated String id (@default(uuid()) / cuid()), or set $id: 'explicit' and always pass ids`);
97
+ }
98
+ const fields = [];
99
+ for (const [attr, spec] of Object.entries(cfg)) {
100
+ if (attr === '$id')
101
+ continue;
102
+ const label = `Prisma: ${model}.${attr}`;
103
+ const f = byName.get(attr);
104
+ if (!f)
105
+ throw configError(`${label}: no such field`);
106
+ if (f.kind !== 'scalar' || f.type !== 'String')
107
+ throw configError(`${label}: encrypted fields must be String scalars (the column stores nsc:v1:… text)`);
108
+ if (attr === idField)
109
+ throw configError(`${label}: the primary key cannot be encrypted`);
110
+ const n = normalizeFieldSpec(spec, label);
111
+ let indexAttr;
112
+ if (n.searchable) {
113
+ indexAttr = n.indexField ?? [`${attr}${INDEX_SUFFIX}`, `${attr}Bidx`].find((k) => byName.has(k));
114
+ const idx = indexAttr ? byName.get(indexAttr) : undefined;
115
+ if (!indexAttr || !idx)
116
+ throw configError(`${label}: searchable fields need a blind-index field (add \`${attr}${INDEX_SUFFIX} String? @db.VarChar(64)\` with an index, or set indexField)`);
117
+ if (idx.kind !== 'scalar' || idx.type !== 'String')
118
+ throw configError(`${label}: index field ${indexAttr} must be a String scalar`);
119
+ }
120
+ fields.push({ attr, column: f.dbName ?? f.name, ...(n.normalization ? { normalization: n.normalization } : {}), ...(indexAttr ? { indexAttr } : {}) });
121
+ }
122
+ if (!fields.length)
123
+ throw configError(`Prisma: ${model} lists no encrypted fields`);
124
+ plans.set(model, {
125
+ model,
126
+ table: rm.dbName ?? model,
127
+ schema: rm.schema ?? null,
128
+ idField,
129
+ idColumn: idf.dbName ?? idf.name,
130
+ idType,
131
+ strategy,
132
+ fields,
133
+ byAttr: new Map(fields.map((f) => [f.attr, f])),
134
+ indexAttrs: new Set(fields.flatMap((f) => (f.indexAttr ? [f.indexAttr] : []))),
135
+ scalars: new Set(rm.fields.filter((f) => f.kind === 'scalar' || f.kind === 'enum').map((f) => f.name)),
136
+ });
137
+ }
138
+ return { plans, relations, provider };
139
+ }
140
+ function signature(s) {
141
+ return JSON.stringify([...s.plans.values()].map((p) => [p.model, p.table, p.idField, p.fields.map((f) => [f.attr, f.column, f.indexAttr ?? ''])]));
142
+ }
143
+ /**
144
+ * Create the Prisma Client extension. Apply it with `prisma.$extends(...)`.
145
+ * The returned value also exposes `cipherWhere(model, field, value)`.
146
+ */
147
+ export function withNorthstarCipher(cipher, options) {
148
+ if (!cipher || typeof cipher.encryptManySettled !== 'function') {
149
+ throw configError('Prisma: withNorthstarCipher(cipher, options) requires a NorthstarCipher client');
150
+ }
151
+ const decryptOpts = { purpose: options?.purpose, readLegacyPlaintext: options?.readLegacyPlaintext === true };
152
+ const keyOpts = { keyId: options?.keyId };
153
+ let applied;
154
+ let ambiguous = false;
155
+ async function tokensWhere(schema, model, field, value) {
156
+ const plan = schema.plans.get(model);
157
+ const f = plan?.byAttr.get(field);
158
+ if (!plan || !f)
159
+ throw configError(`Prisma: ${model}.${field} is not an encrypted field`);
160
+ if (!f.indexAttr)
161
+ throw configError(`Prisma: ${model}.${field} is not searchable (set { searchable: … })`);
162
+ const tokens = await searchTokensFor(cipher, {
163
+ collection: plan.table,
164
+ field: f.column,
165
+ normalization: f.normalization,
166
+ values: value,
167
+ keyId: keyOpts.keyId,
168
+ label: `${plan.table}.${f.column}`,
169
+ });
170
+ return { [f.indexAttr]: { in: tokens } };
171
+ }
172
+ function searchDocs(schema, model, rows, fields) {
173
+ const plan = schema.plans.get(model);
174
+ if (!plan)
175
+ throw configError(`Prisma: ${model} is not configured for Northstar Cipher`);
176
+ const plans = fields.map((attr) => {
177
+ if (!plan.scalars.has(attr))
178
+ throw configError(`Prisma: ${model}.${attr} is not a scalar field`);
179
+ const f = plan.byAttr.get(attr);
180
+ return { attr, ...(f ? { encrypted: { collection: plan.table, field: f.column } } : {}) };
181
+ });
182
+ return rows.map((row) => searchDocumentOf(row, { id: row[plan.idField], fields: plans, label: `${plan.model}` }));
183
+ }
184
+ const extension = Prisma.defineExtension((client) => {
185
+ const schema = resolveSchema(options, client);
186
+ if (applied && signature(applied) !== signature(schema))
187
+ ambiguous = true;
188
+ applied ??= schema;
189
+ const base = client;
190
+ const newIds = async (plan, n) => {
191
+ const s = plan.strategy;
192
+ if (s === 'sequence') {
193
+ const rows = await base.$queryRawUnsafe(RESERVE_IDS_SQL, qualifiedTable(plan.table, plan.schema), plan.idColumn, n);
194
+ return reservedIdsFrom(rows, n, `${plan.model}.${plan.idField}`).map((v) => (plan.idType === 'BigInt' ? BigInt(v) : Number(v)));
195
+ }
196
+ if (s === 'explicit')
197
+ throw requestError(`${plan.model}.${plan.idField} must be provided when writing encrypted fields ($id strategy 'explicit')`);
198
+ if (typeof s === 'function')
199
+ return Array.from({ length: n }, () => s());
200
+ return Array.from({ length: n }, () => ID_GENERATORS[s]());
201
+ };
202
+ /** Encrypt the encrypted fields of `rows` (each bound to its id) in one batch, in place. */
203
+ const encryptRows = async (plan, rows, op) => {
204
+ const items = [];
205
+ const targets = [];
206
+ for (const { data, id } of rows) {
207
+ for (const f of plan.fields) {
208
+ if (!(f.attr in data) || data[f.attr] === undefined)
209
+ continue;
210
+ let v = data[f.attr];
211
+ if (isPlainObject(v)) {
212
+ const keys = Object.keys(v);
213
+ if (op !== 'update' || keys.length !== 1 || keys[0] !== 'set')
214
+ throw requestError(`${plan.model}.${f.attr}: only direct values (or { set }) can be written to encrypted fields`);
215
+ v = v.set;
216
+ }
217
+ if (v === null) {
218
+ data[f.attr] = null;
219
+ if (f.indexAttr)
220
+ data[f.indexAttr] = null;
221
+ continue;
222
+ }
223
+ const label = `${plan.model}.${f.attr}`;
224
+ items.push({ collection: plan.table, field: f.column, recordId: recordIdOf(id, label), plaintext: plaintextOf(v, label), normalization: f.normalization, label });
225
+ targets.push({ data, f });
226
+ }
227
+ }
228
+ const enc = await encryptValues(cipher, items, keyOpts);
229
+ enc.forEach((e, i) => {
230
+ const { data, f } = targets[i];
231
+ data[f.attr] = e.ciphertext;
232
+ if (f.indexAttr)
233
+ data[f.indexAttr] = e.index ?? null;
234
+ });
235
+ };
236
+ const touches = (plan, data) => {
237
+ if (!plan || !isPlainObject(data))
238
+ return false;
239
+ for (const k of plan.indexAttrs) {
240
+ if (data[k] !== undefined)
241
+ throw requestError(`${plan.model}.${k} is the blind index of an encrypted field and is managed by Northstar Cipher`);
242
+ }
243
+ return plan.fields.some((f) => data[f.attr] !== undefined);
244
+ };
245
+ /** Nested writes into encrypted models bypass the extension: refuse them (fail closed). */
246
+ const assertNestedWrites = (model, data) => {
247
+ if (!isPlainObject(data))
248
+ return;
249
+ const rels = schema.relations.get(model);
250
+ if (!rels)
251
+ return;
252
+ for (const [rel, target] of rels) {
253
+ const ops = data[rel];
254
+ if (!isPlainObject(ops))
255
+ continue;
256
+ for (const key of NESTED_WRITE_KEYS) {
257
+ for (const entry of asArray(ops[key])) {
258
+ const payloads = [];
259
+ if (key === 'create')
260
+ payloads.push(entry);
261
+ else if (key === 'createMany')
262
+ payloads.push(...asArray(entry?.data));
263
+ else if (key === 'connectOrCreate')
264
+ payloads.push(entry?.create);
265
+ else if (key === 'upsert')
266
+ payloads.push(entry?.create, entry?.update);
267
+ else if (isPlainObject(entry) && 'data' in entry && Object.keys(entry).every((k) => k === 'data' || k === 'where'))
268
+ payloads.push(entry.data);
269
+ else
270
+ payloads.push(entry);
271
+ for (const p of payloads) {
272
+ const tp = schema.plans.get(target);
273
+ if (touches(tp, p)) {
274
+ throw requestError(`${model}.${rel}: nested ${key} into ${target} would write encrypted fields without encryption; write ${target} through prisma.${lowerFirst(target)} instead`);
275
+ }
276
+ assertNestedWrites(target, p);
277
+ }
278
+ }
279
+ }
280
+ }
281
+ };
282
+ /** Filters, ordering and grouping cannot work on ciphertext: refuse them instead of silently returning nothing. */
283
+ const assertWhere = (model, where) => {
284
+ if (!isPlainObject(where))
285
+ return;
286
+ const plan = schema.plans.get(model);
287
+ const rels = schema.relations.get(model);
288
+ for (const [k, v] of Object.entries(where)) {
289
+ if (k === 'AND' || k === 'OR' || k === 'NOT') {
290
+ for (const w of asArray(v))
291
+ assertWhere(model, w);
292
+ }
293
+ else if (plan?.byAttr.has(k)) {
294
+ const nullOnly = v === null || (isPlainObject(v) && Object.entries(v).every(([op, x]) => NULL_ONLY_FILTER_KEYS.has(op) && (x === null || typeof x === 'boolean')));
295
+ if (!nullOnly) {
296
+ const f = plan.byAttr.get(k);
297
+ throw requestError(`${model}.${k} is encrypted: only null checks can filter it` + (f.indexAttr ? ` (use cipherWhere('${model}', '${k}', value) for exact match)` : ''));
298
+ }
299
+ }
300
+ else if (rels?.has(k) && isPlainObject(v)) {
301
+ const target = rels.get(k);
302
+ const nested = ['some', 'every', 'none', 'is', 'isNot'].filter((x) => x in v);
303
+ if (nested.length)
304
+ for (const x of nested)
305
+ assertWhere(target, v[x]);
306
+ else
307
+ assertWhere(target, v);
308
+ }
309
+ }
310
+ };
311
+ const assertOrder = (model, orderBy) => {
312
+ const plan = schema.plans.get(model);
313
+ const rels = schema.relations.get(model);
314
+ for (const o of asArray(orderBy)) {
315
+ if (!isPlainObject(o))
316
+ continue;
317
+ for (const [k, v] of Object.entries(o)) {
318
+ if (plan?.byAttr.has(k))
319
+ throw requestError(`${model}.${k} is encrypted: ordering by it is not supported`);
320
+ if (rels?.has(k))
321
+ assertOrder(rels.get(k), v);
322
+ }
323
+ }
324
+ };
325
+ const assertFieldList = (model, what, v) => {
326
+ const plan = schema.plans.get(model);
327
+ if (!plan)
328
+ return;
329
+ const keys = Array.isArray(v) ? v.map(String) : isPlainObject(v) ? Object.keys(v).filter((k) => v[k]) : typeof v === 'string' ? [v] : [];
330
+ const bad = keys.find((k) => plan.byAttr.has(k));
331
+ if (bad)
332
+ throw requestError(`${model}.${bad} is encrypted: ${what} is not supported on encrypted fields`);
333
+ };
334
+ /** Copy-on-write the selection so encrypted fields always come back with their id; returns the result shape. */
335
+ const shapeOf = (model, a) => {
336
+ const shape = { model, injectedId: false, children: new Map() };
337
+ if (!isPlainObject(a))
338
+ return { shape, args: a };
339
+ const plan = schema.plans.get(model);
340
+ const rels = schema.relations.get(model) ?? new Map();
341
+ const out = { ...a };
342
+ assertWhere(model, a.where);
343
+ assertOrder(model, a.orderBy);
344
+ if (a.distinct !== undefined)
345
+ assertFieldList(model, 'distinct', a.distinct);
346
+ for (const key of ['select', 'include']) {
347
+ const sel = a[key];
348
+ if (!isPlainObject(sel))
349
+ continue;
350
+ const copy = { ...sel };
351
+ for (const [k, v] of Object.entries(sel)) {
352
+ const target = rels.get(k);
353
+ if (target && v) {
354
+ const r = shapeOf(target, v);
355
+ shape.children.set(k, r.shape);
356
+ copy[k] = r.args;
357
+ }
358
+ }
359
+ if (key === 'select' && plan && plan.fields.some((f) => copy[f.attr]) && !copy[plan.idField]) {
360
+ copy[plan.idField] = true;
361
+ shape.injectedId = true;
362
+ }
363
+ out[key] = copy;
364
+ }
365
+ if (isPlainObject(a.omit) && plan && a.omit[plan.idField] && plan.fields.some((f) => !a.omit[f.attr])) {
366
+ out.omit = { ...a.omit, [plan.idField]: false };
367
+ shape.injectedId = true;
368
+ }
369
+ return { shape, args: out };
370
+ };
371
+ const decryptResult = async (shape, result) => {
372
+ if (!shape || result === null || result === undefined)
373
+ return;
374
+ const items = [];
375
+ const targets = [];
376
+ const strip = [];
377
+ const walk = (s, v) => {
378
+ if (Array.isArray(v))
379
+ return v.forEach((x) => walk(s, x));
380
+ if (!v || typeof v !== 'object')
381
+ return;
382
+ const obj = v;
383
+ const plan = schema.plans.get(s.model);
384
+ if (plan) {
385
+ const present = plan.fields.filter((f) => f.attr in obj);
386
+ if (present.length) {
387
+ const label = `${plan.model}.${present[0].attr}`;
388
+ const recordId = present.some((f) => obj[f.attr] !== null && obj[f.attr] !== undefined) ? recordIdOf(obj[plan.idField], label) : '';
389
+ for (const f of present) {
390
+ items.push({ collection: plan.table, field: f.column, recordId, stored: obj[f.attr], label: `${plan.model}.${f.attr} (${plan.idField}=${recordId})` });
391
+ targets.push({ obj, attr: f.attr });
392
+ }
393
+ }
394
+ if (s.injectedId)
395
+ strip.push({ obj, attr: plan.idField });
396
+ }
397
+ for (const [rel, child] of s.children)
398
+ if (rel in obj)
399
+ walk(child, obj[rel]);
400
+ };
401
+ walk(shape, result);
402
+ const plain = await decryptValues(cipher, items, decryptOpts);
403
+ plain.forEach((p, i) => {
404
+ const t = targets[i];
405
+ t.obj[t.attr] = p;
406
+ });
407
+ for (const s of strip)
408
+ delete s.obj[s.attr];
409
+ };
410
+ const resolveId = async (plan, jsModel, where, required) => {
411
+ if (isPlainObject(where)) {
412
+ const w = where[plan.idField];
413
+ if (w !== undefined && w !== null && !isPlainObject(w))
414
+ return w;
415
+ }
416
+ const delegate = base[jsModel];
417
+ const row = required
418
+ ? await delegate.findUniqueOrThrow({ where, select: { [plan.idField]: true } })
419
+ : await delegate.findUnique({ where, select: { [plan.idField]: true } });
420
+ return row ? row[plan.idField] : undefined;
421
+ };
422
+ return client.$extends({
423
+ name: 'northstar-cipher',
424
+ model: {
425
+ $allModels: {
426
+ /** Exact-match `where` fragment for a searchable encrypted field: `{ <field>_bidx: { in: tokens } }`. */
427
+ async cipherWhere(field, value) {
428
+ const ctx = Prisma.getExtensionContext(this);
429
+ return tokensWhere(schema, String(ctx.$name), field, value);
430
+ },
431
+ /** Northstar Search documents for rows of this model (see `searchDocuments` on the extension). */
432
+ cipherSearchDocuments(rows, fields) {
433
+ const ctx = Prisma.getExtensionContext(this);
434
+ return searchDocs(schema, String(ctx.$name), rows, fields);
435
+ },
436
+ },
437
+ },
438
+ query: {
439
+ $allModels: {
440
+ async $allOperations(params) {
441
+ const { model, operation, args, query } = params;
442
+ // `dataPath` (fluent API) and `jsModelName` are not in Prisma's public callback type.
443
+ const internal = params.__internalParams;
444
+ const jsModel = internal?.jsModelName ?? lowerFirst(model);
445
+ const plan = schema.plans.get(model);
446
+ let a = (args ?? {});
447
+ let reservedId = false;
448
+ if (operation === 'groupBy' || operation === 'aggregate') {
449
+ assertWhere(model, a.where);
450
+ assertOrder(model, a.orderBy);
451
+ if (a.by !== undefined)
452
+ assertFieldList(model, 'groupBy', a.by);
453
+ for (const k of ['_min', '_max'])
454
+ if (a[k] !== undefined)
455
+ assertFieldList(model, k, a[k]);
456
+ return query(args);
457
+ }
458
+ if (operation === 'count' || operation === 'deleteMany') {
459
+ assertWhere(model, a.where);
460
+ return query(args);
461
+ }
462
+ // Writes: encrypt before the statement is built.
463
+ if (operation === 'create' || operation === 'createMany' || operation === 'createManyAndReturn') {
464
+ const rows = asArray(a.data).map((d) => {
465
+ assertNestedWrites(model, d);
466
+ return isPlainObject(d) ? { ...d } : d;
467
+ });
468
+ if (plan) {
469
+ const todo = rows.filter((d) => touches(plan, d));
470
+ const missing = todo.filter((d) => d[plan.idField] === undefined || d[plan.idField] === null);
471
+ reservedId = missing.length > 0 && plan.strategy === 'sequence';
472
+ const ids = missing.length ? await newIds(plan, missing.length) : [];
473
+ missing.forEach((d, i) => (d[plan.idField] = ids[i]));
474
+ await encryptRows(plan, todo.map((d) => ({ data: d, id: d[plan.idField] })), operation);
475
+ }
476
+ a = { ...a, data: Array.isArray(a.data) ? rows : rows[0] };
477
+ }
478
+ else if (operation === 'update' || operation === 'updateMany' || operation === 'updateManyAndReturn') {
479
+ assertNestedWrites(model, a.data);
480
+ assertWhere(model, a.where);
481
+ if (plan && isPlainObject(a.data)) {
482
+ const data = { ...a.data };
483
+ if (data[plan.idField] !== undefined)
484
+ throw requestError(`${model}.${plan.idField}: changing the primary key would invalidate the record's ciphertext`);
485
+ if (touches(plan, data)) {
486
+ if (operation !== 'update') {
487
+ throw requestError(`${model}.${operation} cannot write encrypted fields (each record needs its own ciphertext); update records individually`);
488
+ }
489
+ const id = await resolveId(plan, jsModel, a.where, true);
490
+ await encryptRows(plan, [{ data, id }], 'update');
491
+ a = { ...a, data, where: { ...a.where, [plan.idField]: id } };
492
+ }
493
+ }
494
+ }
495
+ else if (operation === 'upsert') {
496
+ assertNestedWrites(model, a.create);
497
+ assertNestedWrites(model, a.update);
498
+ assertWhere(model, a.where);
499
+ if (plan) {
500
+ const create = isPlainObject(a.create) ? { ...a.create } : a.create;
501
+ const update = isPlainObject(a.update) ? { ...a.update } : a.update;
502
+ if (isPlainObject(update) && update[plan.idField] !== undefined) {
503
+ throw requestError(`${model}.${plan.idField}: changing the primary key would invalidate the record's ciphertext`);
504
+ }
505
+ const tc = touches(plan, create);
506
+ const tu = touches(plan, update);
507
+ if (tc || tu) {
508
+ const c = create;
509
+ if (c[plan.idField] === undefined || c[plan.idField] === null) {
510
+ c[plan.idField] = (await newIds(plan, 1))[0];
511
+ reservedId = plan.strategy === 'sequence';
512
+ }
513
+ const existing = await resolveId(plan, jsModel, a.where, false);
514
+ const updateId = existing ?? c[plan.idField];
515
+ await encryptRows(plan, [
516
+ ...(tc ? [{ data: c, id: c[plan.idField] }] : []),
517
+ ...(tu ? [{ data: update, id: updateId }] : []),
518
+ ], 'update');
519
+ // Pin the target so the update branch can only hit the record its ciphertext is bound to.
520
+ a = { ...a, create: c, update, where: { ...a.where, [plan.idField]: updateId } };
521
+ }
522
+ }
523
+ }
524
+ else if (operation === 'delete') {
525
+ assertWhere(model, a.where);
526
+ }
527
+ else if (!READ_OPS.has(operation)) {
528
+ return query(args);
529
+ }
530
+ // Reads (and writes that return rows): decrypt in one batch.
531
+ const { shape, args: shaped } = shapeOf(model, a);
532
+ let result;
533
+ try {
534
+ result = await query(shaped);
535
+ }
536
+ catch (e) {
537
+ // Prisma's "checked" create input (relation fields such as `owner: { connect }`) has no slot for an
538
+ // auto-increment id, so the reserved id cannot be passed. Nothing was written; explain the fix.
539
+ if (reservedId && plan && e instanceof Error && e.message.includes(`Unknown argument \`${plan.idField}\``)) {
540
+ throw requestError(`${model}.${operation}: the reserved ${plan.idField} cannot be combined with relation inputs (e.g. \`owner: { connect }\`). ` +
541
+ `Set the foreign-key scalar instead (e.g. ownerId), or use a client-generated String id`);
542
+ }
543
+ throw e;
544
+ }
545
+ if (READ_OPS.has(operation) || RETURNING_WRITE_OPS.has(operation)) {
546
+ let s = shape;
547
+ const path = internal?.dataPath ?? [];
548
+ for (let i = 1; i < path.length && s; i += 2)
549
+ s = s.children.get(path[i]);
550
+ await decryptResult(s, result);
551
+ }
552
+ return result;
553
+ },
554
+ },
555
+ },
556
+ });
557
+ });
558
+ return Object.assign(extension, {
559
+ /**
560
+ * Exact-match filter for a searchable encrypted field:
561
+ * `await nsc.cipherWhere('Todo', 'email', 'ana@example.com')` → `{ email_bidx: { in: [...] } }`.
562
+ * Spread it into `where`. Requires the extension to be applied to a client first.
563
+ */
564
+ async cipherWhere(model, field, value) {
565
+ if (!applied)
566
+ throw configError('Prisma: apply the extension (prisma.$extends(...)) before calling cipherWhere');
567
+ if (ambiguous)
568
+ throw configError('Prisma: the extension is applied to clients with different schemas; use prisma.<model>.cipherWhere(field, value)');
569
+ return tokensWhere(applied, model, field, value);
570
+ },
571
+ /**
572
+ * Northstar Search documents for rows of `model`. Rows read with the base
573
+ * client (`new PrismaClient()` without the extension, or raw SQL keyed by
574
+ * field name) hold stored ciphertext, which is sent with the context it is
575
+ * bound to and decrypted inside the enclave: indexing needs no decrypt
576
+ * request. Rows read through the extension hold plaintext, which is sent as is.
577
+ */
578
+ searchDocuments(model, rows, fields) {
579
+ if (!applied)
580
+ throw configError('Prisma: apply the extension (prisma.$extends(...)) before calling searchDocuments');
581
+ if (ambiguous)
582
+ throw configError('Prisma: the extension is applied to clients with different schemas; use prisma.<model>.cipherSearchDocuments(rows, fields)');
583
+ return searchDocs(applied, model, rows, fields);
584
+ },
585
+ });
586
+ }
587
+ export { CipherError };
588
+ //# sourceMappingURL=prisma.js.map