@forklaunch/core 1.2.9 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -30,9 +30,9 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/persistence/index.ts
31
31
  var persistence_exports = {};
32
32
  __export(persistence_exports, {
33
- ComplianceEventSubscriber: () => ComplianceEventSubscriber,
34
33
  ComplianceLevel: () => ComplianceLevel,
35
34
  DecryptionError: () => DecryptionError,
35
+ EncryptedType: () => EncryptedType,
36
36
  EncryptionRequiredError: () => EncryptionRequiredError,
37
37
  FieldEncryptor: () => FieldEncryptor,
38
38
  MissingEncryptionKeyError: () => MissingEncryptionKeyError,
@@ -47,14 +47,18 @@ __export(persistence_exports, {
47
47
  getAllRetentionPolicies: () => getAllRetentionPolicies,
48
48
  getAllUserIdFields: () => getAllUserIdFields,
49
49
  getComplianceMetadata: () => getComplianceMetadata,
50
+ getCurrentTenantId: () => getCurrentTenantId,
50
51
  getEntityComplianceFields: () => getEntityComplianceFields,
51
52
  getEntityRetention: () => getEntityRetention,
52
53
  getEntityUserIdField: () => getEntityUserIdField,
53
54
  getSuperAdminContext: () => getSuperAdminContext,
54
55
  parseDuration: () => parseDuration,
56
+ registerEncryptor: () => registerEncryptor,
57
+ setEncryptionTenantId: () => setEncryptionTenantId,
55
58
  setupRls: () => setupRls,
56
59
  setupTenantFilter: () => setupTenantFilter,
57
60
  subtractDuration: () => subtractDuration,
61
+ withEncryptionContext: () => withEncryptionContext,
58
62
  wrapEmWithNativeQueryBlocking: () => wrapEmWithNativeQueryBlocking
59
63
  });
60
64
  module.exports = __toCommonJS(persistence_exports);
@@ -143,7 +147,102 @@ function getAllRetentionPolicies() {
143
147
  }
144
148
 
145
149
  // src/persistence/compliancePropertyBuilder.ts
150
+ var import_core2 = require("@mikro-orm/core");
151
+
152
+ // src/persistence/encryptedType.ts
153
+ var import_node_async_hooks = require("async_hooks");
146
154
  var import_core = require("@mikro-orm/core");
155
+ var ENCRYPTED_PREFIXES = ["v1:", "v2:"];
156
+ function isEncrypted(value) {
157
+ return ENCRYPTED_PREFIXES.some((p3) => value.startsWith(p3));
158
+ }
159
+ var _encryptor;
160
+ var _tenantContext = new import_node_async_hooks.AsyncLocalStorage();
161
+ function registerEncryptor(encryptor) {
162
+ _encryptor = encryptor;
163
+ }
164
+ function withEncryptionContext(tenantId, fn) {
165
+ return _tenantContext.run({ tenantId }, fn);
166
+ }
167
+ function setEncryptionTenantId(tenantId) {
168
+ _tenantContext.enterWith({ tenantId });
169
+ }
170
+ function getCurrentTenantId() {
171
+ return _tenantContext.getStore()?.tenantId ?? "";
172
+ }
173
+ var EncryptedType = class extends import_core.Type {
174
+ originalType;
175
+ /**
176
+ * @param originalType - The original JS type hint ('string' | 'json' | 'number' | 'boolean').
177
+ * Used to determine serialization strategy.
178
+ */
179
+ constructor(originalType = "string") {
180
+ super();
181
+ this.originalType = originalType;
182
+ }
183
+ convertToDatabaseValue(value, _platform, _context) {
184
+ if (value === null || value === void 0) return null;
185
+ if (typeof value === "string" && value.length === 0) return "";
186
+ if (!_encryptor) {
187
+ return this.serializeValue(value);
188
+ }
189
+ if (typeof value === "string" && isEncrypted(value)) {
190
+ return value;
191
+ }
192
+ const serialized = this.serializeValue(value);
193
+ return _encryptor.encrypt(serialized, getCurrentTenantId()) ?? serialized;
194
+ }
195
+ convertToJSValue(value, _platform) {
196
+ if (value === null || value === void 0) return null;
197
+ if (typeof value !== "string") return value;
198
+ if (!isEncrypted(value)) {
199
+ return this.deserializeValue(value);
200
+ }
201
+ if (!_encryptor) return value;
202
+ try {
203
+ const decrypted = _encryptor.decrypt(value, getCurrentTenantId());
204
+ if (decrypted === null) return null;
205
+ return this.deserializeValue(decrypted);
206
+ } catch {
207
+ return value;
208
+ }
209
+ }
210
+ getColumnType() {
211
+ return "text";
212
+ }
213
+ get runtimeType() {
214
+ return this.originalType === "json" ? "object" : this.originalType;
215
+ }
216
+ ensureComparable() {
217
+ return true;
218
+ }
219
+ // ---------------------------------------------------------------------------
220
+ // Serialization helpers
221
+ // ---------------------------------------------------------------------------
222
+ serializeValue(value) {
223
+ if (typeof value === "string") return value;
224
+ return JSON.stringify(value);
225
+ }
226
+ deserializeValue(value) {
227
+ switch (this.originalType) {
228
+ case "string":
229
+ return value;
230
+ case "number":
231
+ return Number(value);
232
+ case "boolean":
233
+ return value === "true";
234
+ case "json":
235
+ default:
236
+ try {
237
+ return JSON.parse(value);
238
+ } catch {
239
+ return value;
240
+ }
241
+ }
242
+ }
243
+ };
244
+
245
+ // src/persistence/compliancePropertyBuilder.ts
147
246
  function isBuilder(value) {
148
247
  return value != null && typeof value === "object" && "~options" in value;
149
248
  }
@@ -169,7 +268,34 @@ function wrapUnclassified(builder) {
169
268
  }
170
269
  });
171
270
  }
271
+ var ENCRYPTED_LEVELS = /* @__PURE__ */ new Set(["pii", "phi", "pci"]);
272
+ function detectOriginalType(options) {
273
+ const type = options.type;
274
+ if (!type) return "string";
275
+ let t;
276
+ if (typeof type === "string") {
277
+ t = type.toLowerCase();
278
+ } else if (typeof type === "object" && type !== null) {
279
+ const rt = type.runtimeType;
280
+ t = rt ?? type.constructor?.name?.toLowerCase() ?? "string";
281
+ } else {
282
+ return "string";
283
+ }
284
+ if (t.includes("json") || t === "object") return "json";
285
+ if (t.includes("int") || t === "number" || t === "double" || t === "float" || t === "decimal" || t === "smallint" || t === "tinyint") return "number";
286
+ if (t === "boolean" || t === "bool") return "boolean";
287
+ return "string";
288
+ }
172
289
  function wrapClassified(builder, level) {
290
+ if (ENCRYPTED_LEVELS.has(level)) {
291
+ const options = builder["~options"];
292
+ if (options) {
293
+ const originalType = detectOriginalType(options);
294
+ options.type = new EncryptedType(originalType);
295
+ options.columnType = "text";
296
+ options.runtimeType = originalType === "json" ? "object" : originalType;
297
+ }
298
+ }
173
299
  return new Proxy(builder, {
174
300
  get(target, prop) {
175
301
  if (prop === COMPLIANCE_KEY) return level;
@@ -201,7 +327,7 @@ var RELATION_METHODS = /* @__PURE__ */ new Set([
201
327
  function isRelationMethod(prop) {
202
328
  return typeof prop === "string" && RELATION_METHODS.has(prop);
203
329
  }
204
- var fp = new Proxy(import_core.p, {
330
+ var fp = new Proxy(import_core2.p, {
205
331
  get(target, prop) {
206
332
  const value = Reflect.get(target, prop, target);
207
333
  if (typeof value !== "function") return value;
@@ -225,7 +351,7 @@ var fp = new Proxy(import_core.p, {
225
351
  });
226
352
 
227
353
  // src/persistence/defineComplianceEntity.ts
228
- var import_core2 = require("@mikro-orm/core");
354
+ var import_core3 = require("@mikro-orm/core");
229
355
  function readComplianceLevel(builder) {
230
356
  if (builder == null || typeof builder !== "object") return void 0;
231
357
  return builder[COMPLIANCE_KEY];
@@ -234,7 +360,7 @@ function defineComplianceEntity(meta) {
234
360
  const entityName = "name" in meta ? meta.name : "Unknown";
235
361
  const complianceFields = /* @__PURE__ */ new Map();
236
362
  const rawProperties = meta.properties;
237
- const resolvedProperties = typeof rawProperties === "function" ? rawProperties(import_core2.p) : rawProperties;
363
+ const resolvedProperties = typeof rawProperties === "function" ? rawProperties(import_core3.p) : rawProperties;
238
364
  for (const [fieldName, rawProp] of Object.entries(resolvedProperties)) {
239
365
  if (typeof rawProp === "function") {
240
366
  complianceFields.set(fieldName, "none");
@@ -261,14 +387,11 @@ function defineComplianceEntity(meta) {
261
387
  if (meta.userIdField) {
262
388
  registerEntityUserIdField(entityName, meta.userIdField);
263
389
  }
264
- return (0, import_core2.defineEntity)(
390
+ return (0, import_core3.defineEntity)(
265
391
  meta
266
392
  );
267
393
  }
268
394
 
269
- // src/persistence/complianceEventSubscriber.ts
270
- var import_core3 = require("@mikro-orm/core");
271
-
272
395
  // src/persistence/fieldEncryptor.ts
273
396
  var import_crypto = __toESM(require("crypto"));
274
397
  var MissingEncryptionKeyError = class extends Error {
@@ -311,12 +434,23 @@ var FieldEncryptor = class {
311
434
  import_crypto.default.hkdfSync(HKDF_HASH, this.masterKey, HKDF_SALT, tenantId, KEY_BYTES)
312
435
  );
313
436
  }
437
+ /**
438
+ * Derive a deterministic IV from the key and plaintext using HMAC-SHA256,
439
+ * truncated to IV_BYTES. Same plaintext + same key → same IV → same
440
+ * ciphertext. This enables WHERE clause matching on encrypted columns
441
+ * while maintaining AES-256-GCM authenticated encryption.
442
+ *
443
+ * Meets SOC 2, HIPAA, PCI DSS, GDPR requirements for encryption at rest.
444
+ */
445
+ deriveDeterministicIv(key, plaintext) {
446
+ return import_crypto.default.createHmac("sha256", key).update(plaintext).digest().subarray(0, IV_BYTES);
447
+ }
314
448
  encrypt(plaintext, tenantId) {
315
449
  if (plaintext === null || plaintext === void 0) {
316
450
  return null;
317
451
  }
318
452
  const key = this.deriveKey(tenantId ?? "");
319
- const iv = import_crypto.default.randomBytes(IV_BYTES);
453
+ const iv = this.deriveDeterministicIv(key, plaintext);
320
454
  const cipher = import_crypto.default.createCipheriv(ALGORITHM, key, iv);
321
455
  const encrypted = Buffer.concat([
322
456
  cipher.update(plaintext, "utf8"),
@@ -324,7 +458,7 @@ var FieldEncryptor = class {
324
458
  ]);
325
459
  const authTag = cipher.getAuthTag();
326
460
  return [
327
- "v1",
461
+ "v2",
328
462
  iv.toString("base64"),
329
463
  authTag.toString("base64"),
330
464
  encrypted.toString("base64")
@@ -335,7 +469,7 @@ var FieldEncryptor = class {
335
469
  return null;
336
470
  }
337
471
  const parts = ciphertext.split(":");
338
- if (parts.length !== 4 || parts[0] !== "v1") {
472
+ if (parts.length !== 4 || parts[0] !== "v1" && parts[0] !== "v2") {
339
473
  throw new DecryptionError(
340
474
  `Unknown ciphertext version or malformed format`
341
475
  );
@@ -360,102 +494,7 @@ var FieldEncryptor = class {
360
494
  };
361
495
 
362
496
  // src/persistence/complianceEventSubscriber.ts
363
- var ENCRYPTED_PREFIX = "v1:";
364
- var ENCRYPTED_LEVELS = /* @__PURE__ */ new Set(["pii", "phi", "pci"]);
365
- var ComplianceEventSubscriber = class {
366
- encryptor;
367
- constructor(encryptor) {
368
- this.encryptor = encryptor;
369
- }
370
- async beforeCreate(args) {
371
- this.encryptFields(args);
372
- }
373
- async beforeUpdate(args) {
374
- this.encryptFields(args);
375
- }
376
- async onLoad(args) {
377
- this.decryptFields(args);
378
- try {
379
- const wrapped = (0, import_core3.wrap)(args.entity, true);
380
- const snapshot = wrapped.__originalEntityData;
381
- if (snapshot) {
382
- const entity = args.entity;
383
- const complianceFields = getEntityComplianceFields(args.meta.className);
384
- if (complianceFields) {
385
- for (const [fieldName, level] of complianceFields) {
386
- if (!ENCRYPTED_LEVELS.has(level)) continue;
387
- if (entity[fieldName] !== void 0) {
388
- snapshot[fieldName] = entity[fieldName];
389
- }
390
- }
391
- }
392
- }
393
- } catch {
394
- }
395
- }
396
- // ---------------------------------------------------------------------------
397
- // Encrypt on persist
398
- // ---------------------------------------------------------------------------
399
- encryptFields(args) {
400
- const entityName = args.meta.className;
401
- const complianceFields = getEntityComplianceFields(entityName);
402
- if (!complianceFields) return;
403
- const tenantId = this.getTenantId(args.em);
404
- const entity = args.entity;
405
- for (const [fieldName, level] of complianceFields) {
406
- if (!ENCRYPTED_LEVELS.has(level)) continue;
407
- const value = entity[fieldName];
408
- if (value === null || value === void 0) continue;
409
- if (typeof value !== "string") continue;
410
- if (value.startsWith(ENCRYPTED_PREFIX)) continue;
411
- entity[fieldName] = this.encryptor.encrypt(value, tenantId ?? "");
412
- }
413
- }
414
- // ---------------------------------------------------------------------------
415
- // Decrypt on load
416
- // ---------------------------------------------------------------------------
417
- decryptFields(args) {
418
- const entityName = args.meta.className;
419
- const complianceFields = getEntityComplianceFields(entityName);
420
- if (!complianceFields) return;
421
- const tenantId = this.getTenantId(args.em);
422
- const entity = args.entity;
423
- for (const [fieldName, level] of complianceFields) {
424
- if (!ENCRYPTED_LEVELS.has(level)) continue;
425
- const value = entity[fieldName];
426
- if (value === null || value === void 0) continue;
427
- if (typeof value !== "string") continue;
428
- if (value.startsWith(ENCRYPTED_PREFIX)) {
429
- try {
430
- entity[fieldName] = this.encryptor.decrypt(value, tenantId ?? "");
431
- } catch (err) {
432
- if (err instanceof DecryptionError) {
433
- throw new DecryptionError(
434
- `Failed to decrypt ${entityName}.${fieldName}: ${err.message}`
435
- );
436
- }
437
- throw err;
438
- }
439
- } else {
440
- console.warn(
441
- `[compliance] ${entityName}.${fieldName} contains unencrypted ${level} data. Run encryption migration to encrypt existing data.`
442
- );
443
- }
444
- }
445
- }
446
- // ---------------------------------------------------------------------------
447
- // Tenant ID resolution
448
- // ---------------------------------------------------------------------------
449
- /**
450
- * Read the tenant ID from the EntityManager's filter parameters.
451
- * Returns undefined when no tenant context is set (startup, seeders,
452
- * better-auth). Callers skip encryption/decryption in that case.
453
- */
454
- getTenantId(em) {
455
- const filters = em.getFilterParams("tenant");
456
- return filters?.tenantId;
457
- }
458
- };
497
+ var ENCRYPTED_LEVELS2 = /* @__PURE__ */ new Set(["pii", "phi", "pci"]);
459
498
  function wrapEmWithNativeQueryBlocking(em) {
460
499
  const BLOCKED_METHODS = [
461
500
  "nativeInsert",
@@ -471,7 +510,7 @@ function wrapEmWithNativeQueryBlocking(em) {
471
510
  const fields = getEntityComplianceFields(entityName);
472
511
  if (fields) {
473
512
  for (const [fieldName, level] of fields) {
474
- if (ENCRYPTED_LEVELS.has(level)) {
513
+ if (ENCRYPTED_LEVELS2.has(level)) {
475
514
  throw new EncryptionRequiredError(
476
515
  `${prop}() blocked on entity '${entityName}' because field '${fieldName}' has compliance level '${level}'. Use em.create() + em.flush() instead to ensure encryption.`
477
516
  );
@@ -644,9 +683,9 @@ function escapeSqlString(value) {
644
683
  }
645
684
  // Annotate the CommonJS export names for ESM import in node:
646
685
  0 && (module.exports = {
647
- ComplianceEventSubscriber,
648
686
  ComplianceLevel,
649
687
  DecryptionError,
688
+ EncryptedType,
650
689
  EncryptionRequiredError,
651
690
  FieldEncryptor,
652
691
  MissingEncryptionKeyError,
@@ -661,14 +700,18 @@ function escapeSqlString(value) {
661
700
  getAllRetentionPolicies,
662
701
  getAllUserIdFields,
663
702
  getComplianceMetadata,
703
+ getCurrentTenantId,
664
704
  getEntityComplianceFields,
665
705
  getEntityRetention,
666
706
  getEntityUserIdField,
667
707
  getSuperAdminContext,
668
708
  parseDuration,
709
+ registerEncryptor,
710
+ setEncryptionTenantId,
669
711
  setupRls,
670
712
  setupTenantFilter,
671
713
  subtractDuration,
714
+ withEncryptionContext,
672
715
  wrapEmWithNativeQueryBlocking
673
716
  });
674
717
  //# sourceMappingURL=index.js.map