@forklaunch/core 2.1.1 → 3.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 (52) hide show
  1. package/bin/gateway-mock/routes/email.mjs +233 -0
  2. package/bin/gateway-mock/routes/models.mjs +192 -0
  3. package/bin/gateway-mock/routes/payments.mjs +541 -0
  4. package/bin/gateway-mock/routes/sms.mjs +209 -0
  5. package/bin/gateway-mock/routes/voice.mjs +237 -0
  6. package/bin/gateway-mock/routes/whatsapp.mjs +227 -0
  7. package/bin/gateway-mock/server.mjs +321 -0
  8. package/bin/migrate-compliant-fields.mjs +312 -0
  9. package/bin/model-gateway-mock.mjs +13 -0
  10. package/lib/http/emailClient.d.ts +102 -0
  11. package/lib/http/emailClient.d.ts.map +1 -0
  12. package/lib/http/index.d.ts +7 -0
  13. package/lib/http/index.d.ts.map +1 -1
  14. package/lib/http/index.js +850 -4
  15. package/lib/http/index.js.map +1 -1
  16. package/lib/http/index.mjs +809 -1
  17. package/lib/http/index.mjs.map +1 -1
  18. package/lib/http/instanceGateway.d.ts +113 -0
  19. package/lib/http/instanceGateway.d.ts.map +1 -0
  20. package/lib/http/modelGatewayClient.d.ts +126 -0
  21. package/lib/http/modelGatewayClient.d.ts.map +1 -0
  22. package/lib/http/paymentsClient.d.ts +123 -0
  23. package/lib/http/paymentsClient.d.ts.map +1 -0
  24. package/lib/http/smsClient.d.ts +123 -0
  25. package/lib/http/smsClient.d.ts.map +1 -0
  26. package/lib/http/voiceClient.d.ts +105 -0
  27. package/lib/http/voiceClient.d.ts.map +1 -0
  28. package/lib/http/whatsappClient.d.ts +118 -0
  29. package/lib/http/whatsappClient.d.ts.map +1 -0
  30. package/lib/objectstore/interfaces/objectstore.interface.d.ts +45 -0
  31. package/lib/objectstore/interfaces/objectstore.interface.d.ts.map +1 -1
  32. package/lib/persistence/compliancePropertyBuilder.d.ts.map +1 -1
  33. package/lib/persistence/complianceTypes.d.ts +10 -1
  34. package/lib/persistence/complianceTypes.d.ts.map +1 -1
  35. package/lib/persistence/compliantField.d.ts +203 -0
  36. package/lib/persistence/compliantField.d.ts.map +1 -0
  37. package/lib/persistence/defineComplianceEntity.d.ts.map +1 -1
  38. package/lib/persistence/encryptedType.d.ts +8 -1
  39. package/lib/persistence/encryptedType.d.ts.map +1 -1
  40. package/lib/persistence/fieldEncryptor.d.ts +47 -6
  41. package/lib/persistence/fieldEncryptor.d.ts.map +1 -1
  42. package/lib/persistence/index.d.ts +3 -2
  43. package/lib/persistence/index.d.ts.map +1 -1
  44. package/lib/persistence/index.js +987 -259
  45. package/lib/persistence/index.js.map +1 -1
  46. package/lib/persistence/index.mjs +976 -257
  47. package/lib/persistence/index.mjs.map +1 -1
  48. package/lib/persistence/keyRotation.d.ts +5 -0
  49. package/lib/persistence/keyRotation.d.ts.map +1 -1
  50. package/lib/services/index.js.map +1 -1
  51. package/lib/services/index.mjs.map +1 -1
  52. package/package.json +25 -9
@@ -30,7 +30,13 @@ 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
+ Anon: () => Anon,
34
+ BLIND_INDEX_PREFIX: () => BLIND_INDEX_PREFIX,
33
35
  ComplianceLevel: () => ComplianceLevel,
36
+ CompliantField: () => CompliantField,
37
+ CompliantIndexType: () => CompliantIndexType,
38
+ CompliantQueryError: () => CompliantQueryError,
39
+ CompliantType: () => CompliantType,
34
40
  DecryptionError: () => DecryptionError,
35
41
  ENCRYPTED_PREFIXES: () => ENCRYPTED_PREFIXES2,
36
42
  ENCRYPTION_FORMAT_ENV: () => ENCRYPTION_FORMAT_ENV,
@@ -41,6 +47,7 @@ __export(persistence_exports, {
41
47
  FieldEncryptor: () => FieldEncryptor,
42
48
  LEGACY_ENCRYPTION_KEYS_ENV: () => LEGACY_ENCRYPTION_KEYS_ENV,
43
49
  LEGACY_ENCRYPTION_KEY_ENV: () => LEGACY_ENCRYPTION_KEY_ENV,
50
+ LegacyCiphertextError: () => LegacyCiphertextError,
44
51
  MissingEncryptionKeyError: () => MissingEncryptionKeyError,
45
52
  RetentionAction: () => RetentionAction,
46
53
  RetentionDuration: () => RetentionDuration,
@@ -53,6 +60,7 @@ __export(persistence_exports, {
53
60
  collectFallbackTenantIds: () => collectFallbackTenantIds,
54
61
  countValuesByKeyId: () => countValuesByKeyId,
55
62
  createTenantFilterDef: () => createTenantFilterDef,
63
+ deanon: () => deanon,
56
64
  defaultTenantIdsFor: () => defaultTenantIdsFor,
57
65
  defineComplianceEntity: () => defineComplianceEntity,
58
66
  encryptionKeyId: () => encryptionKeyId,
@@ -68,13 +76,17 @@ __export(persistence_exports, {
68
76
  getEntityRetention: () => getEntityRetention,
69
77
  getEntityUserIdField: () => getEntityUserIdField,
70
78
  getSuperAdminContext: () => getSuperAdminContext,
79
+ isAnon: () => isAnon,
80
+ isCompliantField: () => isCompliantField,
71
81
  isEncryptedCiphertext: () => isEncryptedCiphertext,
72
82
  isEncryptedValue: () => isEncryptedValue,
83
+ onComplianceAccess: () => onComplianceAccess,
73
84
  parseDuration: () => parseDuration,
74
85
  parseEncryptionKeyList: () => parseEncryptionKeyList,
75
86
  reencryptEncryptedColumns: () => reencryptEncryptedColumns,
76
87
  registerEncryptor: () => registerEncryptor,
77
88
  rotationTotals: () => rotationTotals,
89
+ sealedAnon: () => sealedAnon,
78
90
  setEncryptionTenantId: () => setEncryptionTenantId,
79
91
  setupRls: () => setupRls,
80
92
  setupTenantFilter: () => setupTenantFilter,
@@ -249,8 +261,8 @@ function asEncryptionSafe(em) {
249
261
  return em;
250
262
  }
251
263
 
252
- // src/persistence/compliancePropertyBuilder.ts
253
- var import_common = require("@forklaunch/common");
264
+ // src/persistence/compliantField.ts
265
+ var import_crypto2 = __toESM(require("crypto"));
254
266
  var import_core2 = require("@mikro-orm/core");
255
267
 
256
268
  // src/persistence/encryptedType.ts
@@ -265,6 +277,9 @@ var _tenantContext = new import_node_async_hooks.AsyncLocalStorage();
265
277
  function registerEncryptor(encryptor) {
266
278
  _encryptor = encryptor;
267
279
  }
280
+ function getRegisteredEncryptor() {
281
+ return _encryptor;
282
+ }
268
283
  var EmptyTenantError = class extends Error {
269
284
  constructor(api) {
270
285
  super(
@@ -420,252 +435,55 @@ var EncryptedType = class extends import_core.Type {
420
435
  // Serialization helpers
421
436
  // ---------------------------------------------------------------------------
422
437
  serialize(value) {
423
- if (typeof value === "string") return value;
424
- if (value instanceof Date) return value.toISOString();
425
- if (typeof value === "bigint") return value.toString();
426
- if (Buffer.isBuffer(value) || value instanceof Uint8Array)
427
- return Buffer.from(value).toString("base64");
428
- return JSON.stringify(value);
438
+ return serializeForEncryption(value);
429
439
  }
430
440
  deserialize(value) {
431
- if (this._isArray) {
432
- try {
433
- const arr = JSON.parse(value);
434
- if (!Array.isArray(arr)) return arr;
435
- return arr.map(
436
- (el) => hydrateValue(el, this._elementRuntimeType)
437
- );
438
- } catch {
439
- return value;
440
- }
441
- }
442
- switch (this._elementRuntimeType) {
443
- case "string":
444
- return value;
445
- case "any":
446
- try {
447
- return JSON.parse(value);
448
- } catch {
449
- return value;
450
- }
451
- // Types serialized via toString/toISOString/base64 — hydrate from string directly
452
- case "bigint":
453
- case "Date":
454
- case "Buffer":
455
- return hydrateValue(value, this._elementRuntimeType);
456
- // Types where JSON.parse recovers the native JS value (number, boolean)
457
- default:
458
- return hydrateValue(this.tryJsonParse(value), this._elementRuntimeType);
459
- }
460
- }
461
- tryJsonParse(value) {
462
- try {
463
- return JSON.parse(value);
464
- } catch {
465
- return value;
466
- }
441
+ return deserializeFromEncryption(
442
+ value,
443
+ this._elementRuntimeType,
444
+ this._isArray
445
+ );
467
446
  }
468
447
  };
469
-
470
- // src/persistence/compliancePropertyBuilder.ts
471
- function isBuilder(value) {
472
- return value != null && typeof value === "object" && "~options" in value;
473
- }
474
- function wrapUnclassified(builder) {
475
- return new Proxy(builder, {
476
- get(target, prop) {
477
- if (prop === "compliance") {
478
- return (level) => wrapClassified(target, level);
479
- }
480
- if (prop === "~options") return Reflect.get(target, prop, target);
481
- if (prop === COMPLIANCE_KEY) return void 0;
482
- const value = Reflect.get(target, prop, target);
483
- if (typeof value === "function") {
484
- return (...args) => {
485
- const result = value.apply(
486
- target,
487
- args
488
- );
489
- return isBuilder(result) ? wrapUnclassified(result) : result;
490
- };
491
- }
492
- return value;
493
- }
494
- });
448
+ function serializeForEncryption(value) {
449
+ if (typeof value === "string") return value;
450
+ if (value instanceof Date) return value.toISOString();
451
+ if (typeof value === "bigint") return value.toString();
452
+ if (Buffer.isBuffer(value) || value instanceof Uint8Array)
453
+ return Buffer.from(value).toString("base64");
454
+ return JSON.stringify(value);
495
455
  }
496
- var ENCRYPTED_LEVELS2 = /* @__PURE__ */ new Set([
497
- "pii",
498
- "phi",
499
- "pci"
500
- ]);
501
- function resolveEncryptedTypeArgs(options) {
502
- const type = options.type;
503
- const isArray = options.array === true;
504
- const resolved = resolveTypeInstance(type);
505
- if (resolved) {
506
- const rt = resolved.runtimeType;
507
- if (rt.endsWith("[]")) {
508
- return { elementRuntimeType: rt.slice(0, -2) || "string", isArray: true };
509
- }
510
- return { elementRuntimeType: rt, isArray };
456
+ function tryJsonParse(value) {
457
+ try {
458
+ return JSON.parse(value);
459
+ } catch {
460
+ return value;
511
461
  }
512
- return { elementRuntimeType: "string", isArray };
513
462
  }
514
- function wrapClassified(builder, level) {
515
- if (ENCRYPTED_LEVELS2.has(level)) {
516
- const options = builder["~options"];
517
- if (options) {
518
- const { elementRuntimeType, isArray } = resolveEncryptedTypeArgs(options);
519
- let enumValues;
520
- if (options.items) {
521
- enumValues = extractEnumValues(options.items);
522
- delete options.items;
523
- delete options.nativeEnumName;
524
- delete options.enum;
525
- }
526
- options.type = new EncryptedType(elementRuntimeType, isArray, enumValues);
527
- options.columnType = "text";
528
- options.runtimeType = isArray ? "object" : elementRuntimeType;
529
- }
530
- }
531
- return new Proxy(builder, {
532
- get(target, prop) {
533
- if (prop === COMPLIANCE_KEY) return level;
534
- if (prop === "~options") return Reflect.get(target, prop, target);
535
- const value = Reflect.get(target, prop, target);
536
- if (typeof value === "function") {
537
- return (...args) => {
538
- const result = value.apply(
539
- target,
540
- args
541
- );
542
- return isBuilder(result) ? wrapClassified(result, level) : result;
543
- };
544
- }
463
+ function deserializeFromEncryption(value, elementRuntimeType, isArray) {
464
+ if (isArray) {
465
+ try {
466
+ const arr = JSON.parse(value);
467
+ if (!Array.isArray(arr)) return arr;
468
+ return arr.map((el) => hydrateValue(el, elementRuntimeType));
469
+ } catch {
545
470
  return value;
546
471
  }
547
- });
548
- }
549
- function wrapRelation(builder) {
550
- return wrapClassified(builder, "none");
551
- }
552
- var RELATION_METHODS = /* @__PURE__ */ new Set([
553
- "manyToOne",
554
- "oneToMany",
555
- "manyToMany",
556
- "oneToOne",
557
- "embedded"
558
- ]);
559
- function isRelationMethod(prop) {
560
- return typeof prop === "string" && RELATION_METHODS.has(prop);
561
- }
562
- function extractEnumValues(items) {
563
- if (typeof items === "function") {
564
- return extractEnumValues(items());
565
- }
566
- if (Array.isArray(items)) return items;
567
- if (items != null && typeof items === "object") {
568
- const obj = items;
569
- const allValues = Object.values(obj);
570
- return allValues.filter((v) => typeof v !== "string" || !(v in obj));
571
- }
572
- return [];
573
- }
574
- var EnumTextType = class extends import_core2.Type {
575
- convertToDatabaseValue(value) {
576
- return (0, import_common.safeStringify)(value);
577
- }
578
- convertToJSValue(value) {
579
- return (0, import_common.safeParse)(value);
580
- }
581
- getColumnType() {
582
- return "text";
583
- }
584
- get runtimeType() {
585
- return "string";
586
- }
587
- };
588
- function inferEnumType(items) {
589
- const values = extractEnumValues(items);
590
- if (values.length === 0) return "text";
591
- const allStrings = values.every((v) => typeof v === "string");
592
- if (allStrings) return "text";
593
- const allNumbers = values.every((v) => typeof v === "number");
594
- if (allNumbers) return "integer";
595
- return new EnumTextType();
596
- }
597
- var fp = new Proxy(import_core2.p, {
598
- get(target, prop) {
599
- const value = Reflect.get(target, prop, target);
600
- if (typeof value !== "function") return value;
601
- if (isRelationMethod(prop)) {
602
- return (...args) => {
603
- const result = value.apply(
604
- target,
605
- args
606
- );
607
- return isBuilder(result) ? wrapRelation(result) : result;
608
- };
609
- }
610
- return (...args) => {
611
- const result = value.apply(
612
- target,
613
- args
614
- );
615
- if (isBuilder(result)) {
616
- if (prop === "enum") {
617
- const options = result["~options"];
618
- if (options && !options.type) {
619
- options.type = inferEnumType(options.items);
620
- }
621
- }
622
- return wrapUnclassified(result);
623
- }
624
- return result;
625
- };
626
- }
627
- });
628
-
629
- // src/persistence/defineComplianceEntity.ts
630
- var import_core3 = require("@mikro-orm/core");
631
- function readComplianceLevel(builder) {
632
- if (builder == null || typeof builder !== "object") return void 0;
633
- return builder[COMPLIANCE_KEY];
634
- }
635
- function defineComplianceEntity(meta) {
636
- const entityName = "name" in meta ? meta.name : "Unknown";
637
- const complianceFields = /* @__PURE__ */ new Map();
638
- const rawProperties = meta.properties;
639
- const resolvedProperties = typeof rawProperties === "function" ? rawProperties(import_core3.p) : rawProperties;
640
- for (const [fieldName, rawProp] of Object.entries(resolvedProperties)) {
641
- if (typeof rawProp === "function") {
642
- complianceFields.set(fieldName, "none");
643
- continue;
644
- }
645
- const level = readComplianceLevel(rawProp);
646
- if (level == null) {
647
- throw new Error(
648
- `Field '${entityName}.${fieldName}' is missing compliance classification. Call .compliance('pii' | 'phi' | 'pci' | 'none') on this property, or use a relation method (fp.manyToOne, etc.) which is auto-classified.`
649
- );
650
- }
651
- complianceFields.set(fieldName, level);
652
- }
653
- registerEntityCompliance(entityName, complianceFields);
654
- if (meta.retention) {
655
- parseDuration(meta.retention.duration);
656
- if (!resolvedProperties["createdAt"]) {
657
- throw new Error(
658
- `Entity '${entityName}' has a retention policy but no 'createdAt' property. Retention requires createdAt to compute expiration.`
659
- );
660
- }
661
- registerEntityRetention(entityName, meta.retention);
662
472
  }
663
- if (meta.userIdField) {
664
- registerEntityUserIdField(entityName, meta.userIdField);
473
+ switch (elementRuntimeType) {
474
+ case "string":
475
+ return value;
476
+ case "any":
477
+ return tryJsonParse(value);
478
+ // Types serialized via toString/toISOString/base64 — hydrate from string directly
479
+ case "bigint":
480
+ case "Date":
481
+ case "Buffer":
482
+ return hydrateValue(value, elementRuntimeType);
483
+ // Types where JSON.parse recovers the native JS value (number, boolean)
484
+ default:
485
+ return hydrateValue(tryJsonParse(value), elementRuntimeType);
665
486
  }
666
- return (0, import_core3.defineEntity)(
667
- meta
668
- );
669
487
  }
670
488
 
671
489
  // src/persistence/fieldEncryptor.ts
@@ -693,9 +511,17 @@ var IV_BYTES = 12;
693
511
  var KEY_BYTES = 32;
694
512
  var HKDF_HASH = "sha256";
695
513
  var HKDF_SALT = Buffer.alloc(0);
514
+ var BLIND_INDEX_INFO = "forklaunch:blind-index\0";
696
515
  var KEY_ID_HEX_CHARS = 12;
697
516
  var DERIVED_KEY_CACHE_LIMIT = 4096;
698
- var ENCRYPTED_PREFIXES2 = ["v1:", "v2:", "v3:"];
517
+ var ENCRYPTED_PREFIXES2 = ["v1:", "v2:", "v3:", "v4:"];
518
+ var BLIND_INDEX_PREFIX = "bi1:";
519
+ function sealedAnon(ciphertext) {
520
+ if (!ciphertext.startsWith("v4:")) return null;
521
+ const parts = ciphertext.split(":");
522
+ if (parts.length !== 6) return null;
523
+ return Buffer.from(parts[2], "base64url").toString("utf8");
524
+ }
699
525
  function isEncryptedCiphertext(value) {
700
526
  return typeof value === "string" && ENCRYPTED_PREFIXES2.some((p3) => value.startsWith(p3));
701
527
  }
@@ -717,9 +543,12 @@ function encryptionKeyId(masterKey) {
717
543
  return import_crypto.default.createHash("sha256").update(masterKey).digest("hex").slice(0, KEY_ID_HEX_CHARS);
718
544
  }
719
545
  function stampedKeyId(ciphertext) {
720
- if (!ciphertext.startsWith("v3:")) return null;
721
546
  const parts = ciphertext.split(":");
722
- return parts.length === 5 ? parts[1] : null;
547
+ if (ciphertext.startsWith("v3:")) return parts.length === 5 ? parts[1] : null;
548
+ if (ciphertext.startsWith("v4:")) return parts.length === 6 ? parts[1] : null;
549
+ if (ciphertext.startsWith(BLIND_INDEX_PREFIX))
550
+ return parts.length === 3 ? parts[1] : null;
551
+ return null;
723
552
  }
724
553
  function stampedPrefix(keyId) {
725
554
  return `v3:${keyId}:`;
@@ -852,7 +681,8 @@ var FieldEncryptor = class _FieldEncryptor {
852
681
  const version = parts[0];
853
682
  let candidates;
854
683
  let body;
855
- if (version === "v3" && parts.length === 5) {
684
+ let anon;
685
+ if (version === "v3" && parts.length === 5 || version === "v4" && parts.length === 6) {
856
686
  const key = this.byId.get(parts[1]);
857
687
  if (!key) {
858
688
  throw new DecryptionError(
@@ -860,7 +690,12 @@ var FieldEncryptor = class _FieldEncryptor {
860
690
  );
861
691
  }
862
692
  candidates = [key];
863
- body = parts.slice(2);
693
+ if (version === "v4") {
694
+ anon = Buffer.from(parts[2], "base64url").toString("utf8");
695
+ body = parts.slice(3);
696
+ } else {
697
+ body = parts.slice(2);
698
+ }
864
699
  } else if ((version === "v1" || version === "v2") && parts.length === 4) {
865
700
  candidates = this.ring;
866
701
  body = parts.slice(1);
@@ -891,7 +726,8 @@ var FieldEncryptor = class _FieldEncryptor {
891
726
  keyId: key.id,
892
727
  current,
893
728
  version,
894
- stale: !current || this.format === "v3" && version !== "v3"
729
+ anon,
730
+ stale: version === "v4" ? !current : !current || this.format === "v3" && version !== "v3"
895
731
  };
896
732
  } catch {
897
733
  }
@@ -917,22 +753,776 @@ var FieldEncryptor = class _FieldEncryptor {
917
753
  if (ciphertext === null || ciphertext === void 0) return null;
918
754
  const opened = this.open(ciphertext, tenantId);
919
755
  if (!opened.stale) return ciphertext;
756
+ if (opened.version === "v4") {
757
+ return this.seal(opened.plaintext, tenantId, opened.anon ?? "");
758
+ }
920
759
  return this.encrypt(opened.plaintext, tenantId);
921
760
  }
761
+ /**
762
+ * Encrypt for a compliant field: a fresh random IV every time (equal
763
+ * plaintexts produce unrelated ciphertexts), stamped with the current key,
764
+ * and carrying `anon` — the field's de-identified value — in clear so it
765
+ * can be read without decrypting.
766
+ *
767
+ * Equality lookups do not use the ciphertext; they use {@link blindIndex}.
768
+ *
769
+ * @returns `v4:{keyId}:{anon}:{iv}:{tag}:{data}`
770
+ */
771
+ seal(plaintext, tenantId, anon) {
772
+ const key = this.deriveKey(tenantId);
773
+ const iv = import_crypto.default.randomBytes(IV_BYTES);
774
+ const cipher = import_crypto.default.createCipheriv(ALGORITHM, key, iv);
775
+ const encrypted = Buffer.concat([
776
+ cipher.update(plaintext, "utf8"),
777
+ cipher.final()
778
+ ]);
779
+ return [
780
+ "v4",
781
+ this.ring[0].id,
782
+ Buffer.from(anon, "utf8").toString("base64url"),
783
+ iv.toString("base64"),
784
+ cipher.getAuthTag().toString("base64"),
785
+ encrypted.toString("base64")
786
+ ].join(":");
787
+ }
788
+ /**
789
+ * Keyed HMAC of a plaintext for equality lookups, under a key derived
790
+ * from the current master key and the tenant (separate from the
791
+ * encryption key). Deterministic per tenant and key, one-way, and never
792
+ * decryptable.
793
+ *
794
+ * After a master key rotation, rows still indexed under the previous key
795
+ * do not match until `reencryptEncryptedColumns` re-indexes them; see
796
+ * {@link blindIndexes} for a lookup that spans the ring.
797
+ *
798
+ * @returns `bi1:{keyId}:{hmac base64url}`
799
+ */
800
+ blindIndex(plaintext, tenantId) {
801
+ return this.blindIndexFor(this.ring[0], plaintext, tenantId);
802
+ }
803
+ /** {@link blindIndex} under every key in the ring, current first. */
804
+ blindIndexes(plaintext, tenantId) {
805
+ return this.ring.map((k) => this.blindIndexFor(k, plaintext, tenantId));
806
+ }
807
+ /** True when a blind index was computed under a previous key. */
808
+ isStaleBlindIndex(index) {
809
+ return stampedKeyId(index) !== this.ring[0].id;
810
+ }
811
+ blindIndexFor(key, plaintext, tenantId) {
812
+ const indexKey = this.deriveKeyFor(key, BLIND_INDEX_INFO + tenantId);
813
+ const mac = import_crypto.default.createHmac("sha256", indexKey).update(plaintext, "utf8").digest("base64url");
814
+ return `${BLIND_INDEX_PREFIX}${key.id}:${mac}`;
815
+ }
922
816
  };
923
817
 
924
- // src/persistence/complianceEventSubscriber.ts
925
- var ENCRYPTED_LEVELS3 = /* @__PURE__ */ new Set([
926
- "pii",
927
- "phi",
928
- "pci"
818
+ // src/persistence/compliantField.ts
819
+ var LISTENERS = globalThis[/* @__PURE__ */ Symbol.for("forklaunch.compliance.listeners")] ??= /* @__PURE__ */ new Set();
820
+ function onComplianceAccess(listener) {
821
+ LISTENERS.add(listener);
822
+ return () => LISTENERS.delete(listener);
823
+ }
824
+ var LegacyCiphertextError = class extends Error {
825
+ name = "LegacyCiphertextError";
826
+ };
827
+ var CompliantQueryError = class extends Error {
828
+ name = "CompliantQueryError";
829
+ };
830
+ var MINT = /* @__PURE__ */ Symbol.for("forklaunch.compliance.mint");
831
+ var ANON_BRAND = /* @__PURE__ */ Symbol.for("forklaunch.compliance.anon");
832
+ var Anon = class {
833
+ value;
834
+ /** @internal */
835
+ constructor(value, key) {
836
+ if (key !== MINT) {
837
+ throw new Error("Anon values can only be produced by a compliant field");
838
+ }
839
+ Object.defineProperty(this, ANON_BRAND, { value: true });
840
+ Object.defineProperty(this, "value", { value, enumerable: true });
841
+ Object.freeze(this);
842
+ }
843
+ };
844
+ function isAnon(value) {
845
+ return value != null && typeof value === "object" && value[ANON_BRAND] === true;
846
+ }
847
+ var RESTRICTED_ZIP3 = /* @__PURE__ */ new Set([
848
+ "036",
849
+ "059",
850
+ "063",
851
+ "102",
852
+ "203",
853
+ "556",
854
+ "692",
855
+ "790",
856
+ "821",
857
+ "823",
858
+ "830",
859
+ "831",
860
+ "878",
861
+ "879",
862
+ "884",
863
+ "890",
864
+ "893"
929
865
  ]);
930
- function wrapEmWithNativeQueryBlocking(em) {
931
- const BLOCKED_METHODS = [
932
- "nativeInsert",
933
- "nativeUpdate",
934
- "nativeDelete"
935
- ];
866
+ var REDACTED = "[redacted]";
867
+ function computeAnon(strategy, value) {
868
+ switch (strategy) {
869
+ case "token":
870
+ return "tok_" + import_crypto2.default.randomBytes(9).toString("base64url");
871
+ case "year": {
872
+ if (value instanceof Date && !isNaN(value.getTime()))
873
+ return String(value.getUTCFullYear());
874
+ const match = /\d{4}/.exec(String(value));
875
+ return match ? match[0] : REDACTED;
876
+ }
877
+ case "zip3": {
878
+ const digits = String(value).replace(/\D/g, "").slice(0, 3);
879
+ if (digits.length < 3 || RESTRICTED_ZIP3.has(digits)) return "000";
880
+ return digits;
881
+ }
882
+ case "last4": {
883
+ const digits = String(value).replace(/\D/g, "");
884
+ return digits.length >= 4 ? `\u2022\u2022\u2022\u2022 ${digits.slice(-4)}` : REDACTED;
885
+ }
886
+ case "redact":
887
+ return REDACTED;
888
+ }
889
+ }
890
+ function defaultAnonStrategy(elementRuntimeType, isArray, hasEnum) {
891
+ if (isArray || hasEnum) return "redact";
892
+ if (elementRuntimeType === "string") return "token";
893
+ if (elementRuntimeType === "Date") return "year";
894
+ return "redact";
895
+ }
896
+ var STATE = globalThis[/* @__PURE__ */ Symbol.for("forklaunch.compliance.state")] ??= /* @__PURE__ */ new WeakMap();
897
+ var FIELD_BRAND = /* @__PURE__ */ Symbol.for("forklaunch.compliance.field");
898
+ function stateOf(field) {
899
+ const state = STATE.get(field);
900
+ if (!state) throw new Error("Not a compliant field");
901
+ return state;
902
+ }
903
+ var CompliantField = class _CompliantField {
904
+ constructor(state) {
905
+ Object.defineProperty(this, FIELD_BRAND, { value: true });
906
+ STATE.set(this, state);
907
+ Object.freeze(this);
908
+ }
909
+ /**
910
+ * @internal Wrap a plaintext value for `spec`. Applications assign plain
911
+ * values to the entity property instead.
912
+ */
913
+ static fromPlain(value, spec) {
914
+ if (spec.enumValues) {
915
+ const values = spec.isArray && Array.isArray(value) ? value : [value];
916
+ for (const v of values) {
917
+ if (!spec.enumValues.includes(v)) {
918
+ throw new Error(
919
+ `Invalid enum value${spec.path ? ` for ${spec.path}` : ""}: ${String(v)}. Allowed values: ${spec.enumValues.join(", ")}`
920
+ );
921
+ }
922
+ }
923
+ }
924
+ return new _CompliantField({
925
+ spec,
926
+ hasPlain: true,
927
+ plain: value,
928
+ anon: computeAnon(spec.anon, value)
929
+ });
930
+ }
931
+ /** @internal Wrap a stored value (any envelope, or legacy plaintext). */
932
+ static fromStored(stored, spec, index) {
933
+ if (!isEncryptedCiphertext(stored)) {
934
+ return new _CompliantField({
935
+ spec,
936
+ hasPlain: true,
937
+ plain: deserializeFromEncryption(
938
+ stored,
939
+ spec.elementRuntimeType,
940
+ spec.isArray
941
+ ),
942
+ index
943
+ });
944
+ }
945
+ return new _CompliantField({
946
+ spec,
947
+ hasPlain: false,
948
+ sealed: stored,
949
+ anon: sealedAnon(stored) ?? void 0,
950
+ index
951
+ });
952
+ }
953
+ /** @internal A queryable field whose ciphertext column was not selected. */
954
+ static fromIndexOnly(index, spec) {
955
+ return new _CompliantField({ spec, hasPlain: false, index });
956
+ }
957
+ /** The field's classification. */
958
+ get level() {
959
+ return stateOf(this).spec.level;
960
+ }
961
+ /** The de-identified value. Never decrypts for values in a `v4` envelope. */
962
+ get anon() {
963
+ const state = stateOf(this);
964
+ if (state.anon === void 0) {
965
+ if (state.spec.anon === "token") {
966
+ throw new LegacyCiphertextError(
967
+ `${state.spec.path} is stored in a pre-v4 envelope and has no token yet; run reencryptEncryptedColumns() once to upgrade it`
968
+ );
969
+ }
970
+ state.anon = computeAnon(state.spec.anon, readPlaintext(this, false));
971
+ }
972
+ return new Anon(state.anon, MINT);
973
+ }
974
+ /** The plaintext. Every read is reported to access listeners. */
975
+ get deanon() {
976
+ return readPlaintext(this, true);
977
+ }
978
+ };
979
+ function readPlaintext(field, report) {
980
+ const state = stateOf(field);
981
+ let tenantId = "";
982
+ if (!state.hasPlain) {
983
+ tenantId = requireBoundTenantId("read");
984
+ if (state.sealed === void 0) {
985
+ throw new Error(
986
+ `${state.spec.path} was not loaded: its ciphertext column was left out of the selection`
987
+ );
988
+ }
989
+ const encryptor = getRegisteredEncryptor();
990
+ if (!encryptor) {
991
+ throw new EncryptionRequiredError(
992
+ `Cannot read ${state.spec.path}: no encryptor registered. Call registerEncryptor() at bootstrap.`
993
+ );
994
+ }
995
+ const opened = encryptor.open(state.sealed, tenantId);
996
+ state.plain = deserializeFromEncryption(
997
+ opened.plaintext,
998
+ state.spec.elementRuntimeType,
999
+ state.spec.isArray
1000
+ );
1001
+ state.hasPlain = true;
1002
+ }
1003
+ if (report) {
1004
+ tenantId ||= getBoundTenantId() ?? "";
1005
+ for (const listener of LISTENERS) {
1006
+ listener({ field: state.spec.path, level: state.spec.level, tenantId });
1007
+ }
1008
+ }
1009
+ return state.plain;
1010
+ }
1011
+ function isCompliantField(value) {
1012
+ return value != null && typeof value === "object" && value[FIELD_BRAND] === true;
1013
+ }
1014
+ function requireEncryptor(path) {
1015
+ const encryptor = getRegisteredEncryptor();
1016
+ if (!encryptor) {
1017
+ throw new EncryptionRequiredError(
1018
+ `Cannot write ${path}: no encryptor registered. Call registerEncryptor() at bootstrap; compliant fields are never stored in plaintext.`
1019
+ );
1020
+ }
1021
+ return encryptor;
1022
+ }
1023
+ function sealedValueOf(field) {
1024
+ const state = stateOf(field);
1025
+ if (state.sealed !== void 0 && state.sealedTenant === void 0) {
1026
+ return state.sealed;
1027
+ }
1028
+ if (!state.hasPlain) return state.sealed;
1029
+ const tenantId = requireBoundTenantId("write");
1030
+ if (state.sealed !== void 0 && state.sealedTenant === tenantId) {
1031
+ return state.sealed;
1032
+ }
1033
+ if (state.anon === void 0) {
1034
+ state.anon = computeAnon(state.spec.anon, state.plain);
1035
+ }
1036
+ state.sealed = requireEncryptor(state.spec.path).seal(
1037
+ serializeForEncryption(state.plain),
1038
+ tenantId,
1039
+ state.anon
1040
+ );
1041
+ state.sealedTenant = tenantId;
1042
+ return state.sealed;
1043
+ }
1044
+ function normalizeSerializedForIndex(serialized, spec) {
1045
+ return spec.normalize === "lowercase" ? serialized.trim().toLowerCase() : serialized;
1046
+ }
1047
+ function normalizedForIndex(value, spec) {
1048
+ return normalizeSerializedForIndex(serializeForEncryption(value), spec);
1049
+ }
1050
+ function blindIndexOfValue(value, spec) {
1051
+ return requireEncryptor(spec.path).blindIndex(
1052
+ normalizedForIndex(value, spec),
1053
+ requireBoundTenantId("query")
1054
+ );
1055
+ }
1056
+ function blindIndexOf(field) {
1057
+ const state = stateOf(field);
1058
+ if (state.index) return state.index;
1059
+ if (!state.hasPlain && state.sealed === void 0) return state.index ?? null;
1060
+ try {
1061
+ const plain = state.hasPlain ? state.plain : readPlaintext(field, false);
1062
+ state.index = blindIndexOfValue(plain, state.spec);
1063
+ } catch {
1064
+ return state.index ?? null;
1065
+ }
1066
+ return state.index;
1067
+ }
1068
+ var PENDING = /* @__PURE__ */ Symbol.for("forklaunch.compliance.pending");
1069
+ function isPendingIndex(value) {
1070
+ return value != null && typeof value === "object" && PENDING in value;
1071
+ }
1072
+ function pendingIndexValue(value) {
1073
+ return value[PENDING];
1074
+ }
1075
+ var CompliantTypeBase = class extends import_core2.Type {
1076
+ constructor(spec) {
1077
+ super();
1078
+ this.spec = spec;
1079
+ }
1080
+ spec;
1081
+ getColumnType() {
1082
+ return "text";
1083
+ }
1084
+ get runtimeType() {
1085
+ return "string";
1086
+ }
1087
+ ensureComparable() {
1088
+ return false;
1089
+ }
1090
+ compareAsType() {
1091
+ return "string";
1092
+ }
1093
+ };
1094
+ var CompliantType = class extends CompliantTypeBase {
1095
+ convertToDatabaseValue(value, _platform, context) {
1096
+ if (context?.fromQuery) {
1097
+ throw new CompliantQueryError(
1098
+ `${this.spec.path} cannot be queried: declare it with .compliance('${this.spec.level}', { queryable: true }) to allow equality lookups`
1099
+ );
1100
+ }
1101
+ if (value === null || value === void 0) return null;
1102
+ if (isCompliantField(value)) return sealedValueOf(value) ?? null;
1103
+ if (isEncryptedCiphertext(value)) return value;
1104
+ return sealedValueOf(CompliantField.fromPlain(value, this.spec)) ?? null;
1105
+ }
1106
+ convertToJSValue(value) {
1107
+ if (value === null || value === void 0) return value;
1108
+ if (isCompliantField(value)) return value;
1109
+ return CompliantField.fromStored(String(value), this.spec);
1110
+ }
1111
+ };
1112
+ var EQUALITY_OPERATORS = /* @__PURE__ */ new Set(["$eq", "$ne", "$in", "$nin"]);
1113
+ var CompliantIndexType = class extends CompliantTypeBase {
1114
+ convertToDatabaseValue(value, _platform, context) {
1115
+ if (value === null || value === void 0) return null;
1116
+ if (context?.fromQuery) {
1117
+ const key = context.key;
1118
+ if (key !== void 0 && !EQUALITY_OPERATORS.has(String(key)) && !/^\d+$/.test(String(key))) {
1119
+ throw new CompliantQueryError(
1120
+ `${this.spec.path} supports equality lookups only ($eq, $ne, $in, $nin); ${String(key)} would need the plaintext`
1121
+ );
1122
+ }
1123
+ }
1124
+ if (isCompliantField(value)) return blindIndexOf(value);
1125
+ if (isPendingIndex(value)) return pendingIndexValue(value);
1126
+ if (typeof value === "string" && value.startsWith("bi1:")) return value;
1127
+ return blindIndexOfValue(value, this.spec);
1128
+ }
1129
+ convertToJSValue(value) {
1130
+ if (isCompliantField(value) || isPendingIndex(value)) return value;
1131
+ return { [PENDING]: value ?? null };
1132
+ }
1133
+ };
1134
+ function isCompliantType(type) {
1135
+ return type instanceof CompliantType || type instanceof CompliantIndexType;
1136
+ }
1137
+ var SLOTS = globalThis[/* @__PURE__ */ Symbol.for("forklaunch.compliance.slots")] ??= /* @__PURE__ */ new WeakMap();
1138
+ function slotsOf(entity) {
1139
+ let slots = SLOTS.get(entity);
1140
+ if (!slots) {
1141
+ slots = {};
1142
+ SLOTS.set(entity, slots);
1143
+ }
1144
+ return slots;
1145
+ }
1146
+ function readProperty(entity, binding) {
1147
+ const slots = slotsOf(entity);
1148
+ const value = slots[binding.property];
1149
+ if (binding.sibling) {
1150
+ const stored = slots[binding.sibling];
1151
+ if (isPendingIndex(value)) {
1152
+ const index = pendingIndexValue(value);
1153
+ const field = typeof stored === "string" ? CompliantField.fromStored(stored, binding.spec, index) : CompliantField.fromIndexOnly(index, binding.spec);
1154
+ slots[binding.property] = field;
1155
+ return field;
1156
+ }
1157
+ if (value == null && typeof stored === "string") {
1158
+ const field = CompliantField.fromStored(stored, binding.spec, null);
1159
+ slots[binding.property] = field;
1160
+ return field;
1161
+ }
1162
+ }
1163
+ return value;
1164
+ }
1165
+ function sameRules(a, b) {
1166
+ return a === b || a.path === b.path && a.anon === b.anon && a.normalize === b.normalize && a.queryable === b.queryable;
1167
+ }
1168
+ function writeProperty(entity, binding, value) {
1169
+ const slots = slotsOf(entity);
1170
+ if (isPendingIndex(value)) {
1171
+ slots[binding.property] = value;
1172
+ return;
1173
+ }
1174
+ if (value === null || value === void 0) {
1175
+ slots[binding.property] = value;
1176
+ if (binding.sibling) slots[binding.sibling] = value;
1177
+ return;
1178
+ }
1179
+ if (isCompliantField(value)) {
1180
+ slots[binding.property] = sameRules(stateOf(value).spec, binding.spec) ? value : CompliantField.fromPlain(readPlaintext(value, false), binding.spec);
1181
+ return;
1182
+ }
1183
+ slots[binding.property] = CompliantField.fromPlain(value, binding.spec);
1184
+ }
1185
+ function installCompliantAccessors(prototype, bindings) {
1186
+ installInspect(prototype, bindings);
1187
+ for (const binding of bindings) {
1188
+ Object.defineProperty(prototype, binding.property, {
1189
+ configurable: true,
1190
+ enumerable: true,
1191
+ get() {
1192
+ return readProperty(this, binding);
1193
+ },
1194
+ set(value) {
1195
+ writeProperty(this, binding, value);
1196
+ }
1197
+ });
1198
+ if (binding.sibling) {
1199
+ const sibling = binding.sibling;
1200
+ Object.defineProperty(prototype, sibling, {
1201
+ configurable: true,
1202
+ enumerable: false,
1203
+ get() {
1204
+ const field = readProperty(this, binding);
1205
+ const stored = slotsOf(this)[sibling];
1206
+ if (field === null || field === void 0) return stored ?? field;
1207
+ return sealedValueOf(field) ?? stored;
1208
+ },
1209
+ set(value) {
1210
+ slotsOf(this)[sibling] = value;
1211
+ }
1212
+ });
1213
+ }
1214
+ }
1215
+ }
1216
+ var INSPECT = /* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom");
1217
+ function installInspect(prototype, bindings) {
1218
+ const hidden = new Set(
1219
+ bindings.map((b) => b.sibling).filter((s) => !!s)
1220
+ );
1221
+ Object.defineProperty(prototype, INSPECT, {
1222
+ configurable: true,
1223
+ writable: true,
1224
+ value(depth, options, inspect) {
1225
+ const shown = {};
1226
+ for (const key of Object.keys(this)) {
1227
+ if (!hidden.has(key)) shown[key] = this[key];
1228
+ }
1229
+ for (const binding of bindings) {
1230
+ const value = this[binding.property];
1231
+ if (value !== void 0) shown[binding.property] = value;
1232
+ }
1233
+ const name = this.constructor?.name ?? "Entity";
1234
+ return `${name} ${inspect(shown, { ...options, depth })}`;
1235
+ }
1236
+ });
1237
+ }
1238
+ function deanon(entity) {
1239
+ const copy = {};
1240
+ for (const key in entity) {
1241
+ const value = entity[key];
1242
+ copy[key] = isCompliantField(value) ? value.deanon : value;
1243
+ }
1244
+ return copy;
1245
+ }
1246
+
1247
+ // src/persistence/compliancePropertyBuilder.ts
1248
+ var import_common = require("@forklaunch/common");
1249
+ var import_core3 = require("@mikro-orm/core");
1250
+ function isBuilder(value) {
1251
+ return value != null && typeof value === "object" && "~options" in value;
1252
+ }
1253
+ function wrapUnclassified(builder) {
1254
+ return new Proxy(builder, {
1255
+ get(target, prop) {
1256
+ if (prop === "compliance") {
1257
+ return (level, options) => wrapClassified(target, level, options);
1258
+ }
1259
+ if (prop === "~options") return Reflect.get(target, prop, target);
1260
+ if (prop === COMPLIANCE_KEY) return void 0;
1261
+ const value = Reflect.get(target, prop, target);
1262
+ if (typeof value === "function") {
1263
+ return (...args) => {
1264
+ const result = value.apply(
1265
+ target,
1266
+ args
1267
+ );
1268
+ return isBuilder(result) ? wrapUnclassified(result) : result;
1269
+ };
1270
+ }
1271
+ return value;
1272
+ }
1273
+ });
1274
+ }
1275
+ var ENCRYPTED_LEVELS2 = /* @__PURE__ */ new Set([
1276
+ "pii",
1277
+ "phi",
1278
+ "pci"
1279
+ ]);
1280
+ function resolveEncryptedTypeArgs(options) {
1281
+ const type = options.type;
1282
+ const isArray = options.array === true;
1283
+ const resolved = resolveTypeInstance(type);
1284
+ if (resolved) {
1285
+ const rt = resolved.runtimeType;
1286
+ if (rt.endsWith("[]")) {
1287
+ return { elementRuntimeType: rt.slice(0, -2) || "string", isArray: true };
1288
+ }
1289
+ return { elementRuntimeType: rt, isArray };
1290
+ }
1291
+ return { elementRuntimeType: "string", isArray };
1292
+ }
1293
+ function wrapClassified(builder, level, complianceOptions) {
1294
+ if (ENCRYPTED_LEVELS2.has(level)) {
1295
+ const options = builder["~options"];
1296
+ if (options) {
1297
+ const { elementRuntimeType, isArray } = resolveEncryptedTypeArgs(options);
1298
+ let enumValues;
1299
+ if (options.items) {
1300
+ enumValues = extractEnumValues(options.items);
1301
+ delete options.items;
1302
+ delete options.nativeEnumName;
1303
+ delete options.enum;
1304
+ }
1305
+ const spec = {
1306
+ level,
1307
+ anon: complianceOptions?.anon ?? defaultAnonStrategy(elementRuntimeType, isArray, !!enumValues),
1308
+ queryable: complianceOptions?.queryable === true,
1309
+ normalize: complianceOptions?.normalize ?? "exact",
1310
+ elementRuntimeType,
1311
+ isArray,
1312
+ enumValues,
1313
+ path: ""
1314
+ // set by defineComplianceEntity once the property name is known
1315
+ };
1316
+ options.type = spec.queryable ? new CompliantIndexType(spec) : new CompliantType(spec);
1317
+ options.columnType = "text";
1318
+ options.runtimeType = "string";
1319
+ options.accessor = true;
1320
+ }
1321
+ }
1322
+ return new Proxy(builder, {
1323
+ get(target, prop) {
1324
+ if (prop === COMPLIANCE_KEY) return level;
1325
+ if (prop === "~options") return Reflect.get(target, prop, target);
1326
+ const value = Reflect.get(target, prop, target);
1327
+ if (typeof value === "function") {
1328
+ return (...args) => {
1329
+ const result = value.apply(
1330
+ target,
1331
+ args
1332
+ );
1333
+ return isBuilder(result) ? wrapClassified(result, level) : result;
1334
+ };
1335
+ }
1336
+ return value;
1337
+ }
1338
+ });
1339
+ }
1340
+ function wrapRelation(builder) {
1341
+ return wrapClassified(builder, "none");
1342
+ }
1343
+ var RELATION_METHODS = /* @__PURE__ */ new Set([
1344
+ "manyToOne",
1345
+ "oneToMany",
1346
+ "manyToMany",
1347
+ "oneToOne",
1348
+ "embedded"
1349
+ ]);
1350
+ function isRelationMethod(prop) {
1351
+ return typeof prop === "string" && RELATION_METHODS.has(prop);
1352
+ }
1353
+ function extractEnumValues(items) {
1354
+ if (typeof items === "function") {
1355
+ return extractEnumValues(items());
1356
+ }
1357
+ if (Array.isArray(items)) return items;
1358
+ if (items != null && typeof items === "object") {
1359
+ const obj = items;
1360
+ const allValues = Object.values(obj);
1361
+ return allValues.filter((v) => typeof v !== "string" || !(v in obj));
1362
+ }
1363
+ return [];
1364
+ }
1365
+ var EnumTextType = class extends import_core3.Type {
1366
+ convertToDatabaseValue(value) {
1367
+ return (0, import_common.safeStringify)(value);
1368
+ }
1369
+ convertToJSValue(value) {
1370
+ return (0, import_common.safeParse)(value);
1371
+ }
1372
+ getColumnType() {
1373
+ return "text";
1374
+ }
1375
+ get runtimeType() {
1376
+ return "string";
1377
+ }
1378
+ };
1379
+ function inferEnumType(items) {
1380
+ const values = extractEnumValues(items);
1381
+ if (values.length === 0) return "text";
1382
+ const allStrings = values.every((v) => typeof v === "string");
1383
+ if (allStrings) return "text";
1384
+ const allNumbers = values.every((v) => typeof v === "number");
1385
+ if (allNumbers) return "integer";
1386
+ return new EnumTextType();
1387
+ }
1388
+ var fp = new Proxy(import_core3.p, {
1389
+ get(target, prop) {
1390
+ const value = Reflect.get(target, prop, target);
1391
+ if (typeof value !== "function") return value;
1392
+ if (isRelationMethod(prop)) {
1393
+ return (...args) => {
1394
+ const result = value.apply(
1395
+ target,
1396
+ args
1397
+ );
1398
+ return isBuilder(result) ? wrapRelation(result) : result;
1399
+ };
1400
+ }
1401
+ return (...args) => {
1402
+ const result = value.apply(
1403
+ target,
1404
+ args
1405
+ );
1406
+ if (isBuilder(result)) {
1407
+ if (prop === "enum") {
1408
+ const options = result["~options"];
1409
+ if (options && !options.type) {
1410
+ options.type = inferEnumType(options.items);
1411
+ }
1412
+ }
1413
+ return wrapUnclassified(result);
1414
+ }
1415
+ return result;
1416
+ };
1417
+ }
1418
+ });
1419
+
1420
+ // src/persistence/defineComplianceEntity.ts
1421
+ var import_core4 = require("@mikro-orm/core");
1422
+ var naming = new import_core4.UnderscoreNamingStrategy();
1423
+ function wireCompliantProperties(entityName, properties) {
1424
+ const wired = {};
1425
+ const bindings = [];
1426
+ for (const [name, prop] of Object.entries(properties)) {
1427
+ wired[name] = prop;
1428
+ if (typeof prop === "function" || prop == null) continue;
1429
+ const options = prop["~options"] ?? prop;
1430
+ const type = options.type;
1431
+ if (!isCompliantType(type)) continue;
1432
+ type.spec.path = `${entityName}.${name}`;
1433
+ if (!(type instanceof CompliantIndexType)) {
1434
+ bindings.push({ property: name, spec: type.spec });
1435
+ continue;
1436
+ }
1437
+ const sibling = `${name}Sealed`;
1438
+ if (sibling in properties) {
1439
+ throw new Error(
1440
+ `Entity '${entityName}': '${sibling}' is reserved for the encrypted value of queryable field '${name}'`
1441
+ );
1442
+ }
1443
+ const explicit = options.fieldName ?? options.fieldNames?.[0];
1444
+ const column = typeof explicit === "string" ? explicit : naming.propertyToColumnName(name);
1445
+ const required = options.nullable !== true;
1446
+ options.fieldName = `${column}_idx`;
1447
+ delete options.fieldNames;
1448
+ options.nullable = true;
1449
+ wired[sibling] = {
1450
+ type: "text",
1451
+ columnType: "text",
1452
+ fieldName: column,
1453
+ nullable: !required,
1454
+ hidden: true,
1455
+ accessor: true
1456
+ };
1457
+ bindings.push({ property: name, spec: type.spec, sibling });
1458
+ }
1459
+ return { properties: wired, bindings };
1460
+ }
1461
+ function readComplianceLevel(builder) {
1462
+ if (builder == null || typeof builder !== "object") return void 0;
1463
+ return builder[COMPLIANCE_KEY];
1464
+ }
1465
+ function defineComplianceEntity(meta) {
1466
+ const entityName = "name" in meta ? meta.name : "Unknown";
1467
+ const complianceFields = /* @__PURE__ */ new Map();
1468
+ const rawProperties = meta.properties;
1469
+ const resolvedProperties = typeof rawProperties === "function" ? rawProperties(import_core4.p) : rawProperties;
1470
+ for (const [fieldName, rawProp] of Object.entries(resolvedProperties)) {
1471
+ if (typeof rawProp === "function") {
1472
+ complianceFields.set(fieldName, "none");
1473
+ continue;
1474
+ }
1475
+ const level = readComplianceLevel(rawProp);
1476
+ if (level == null) {
1477
+ throw new Error(
1478
+ `Field '${entityName}.${fieldName}' is missing compliance classification. Call .compliance('pii' | 'phi' | 'pci' | 'none') on this property, or use a relation method (fp.manyToOne, etc.) which is auto-classified.`
1479
+ );
1480
+ }
1481
+ complianceFields.set(fieldName, level);
1482
+ }
1483
+ registerEntityCompliance(entityName, complianceFields);
1484
+ if (meta.retention) {
1485
+ parseDuration(meta.retention.duration);
1486
+ if (!resolvedProperties["createdAt"]) {
1487
+ throw new Error(
1488
+ `Entity '${entityName}' has a retention policy but no 'createdAt' property. Retention requires createdAt to compute expiration.`
1489
+ );
1490
+ }
1491
+ registerEntityRetention(entityName, meta.retention);
1492
+ }
1493
+ if (meta.userIdField) {
1494
+ registerEntityUserIdField(entityName, meta.userIdField);
1495
+ }
1496
+ const wired = wireCompliantProperties(entityName, resolvedProperties);
1497
+ const schema = (0, import_core4.defineEntity)({
1498
+ ...meta,
1499
+ properties: wired.properties
1500
+ });
1501
+ if (wired.bindings.length > 0) {
1502
+ const entityClass = schema.meta.class;
1503
+ if (entityClass)
1504
+ installCompliantAccessors(entityClass.prototype, wired.bindings);
1505
+ const setClass = schema.setClass.bind(schema);
1506
+ schema.setClass = ((cls) => {
1507
+ installCompliantAccessors(cls.prototype, wired.bindings);
1508
+ return setClass(cls);
1509
+ });
1510
+ }
1511
+ return schema;
1512
+ }
1513
+
1514
+ // src/persistence/complianceEventSubscriber.ts
1515
+ var ENCRYPTED_LEVELS3 = /* @__PURE__ */ new Set([
1516
+ "pii",
1517
+ "phi",
1518
+ "pci"
1519
+ ]);
1520
+ function wrapEmWithNativeQueryBlocking(em) {
1521
+ const BLOCKED_METHODS = [
1522
+ "nativeInsert",
1523
+ "nativeUpdate",
1524
+ "nativeDelete"
1525
+ ];
936
1526
  return new Proxy(em, {
937
1527
  get(target, prop, receiver) {
938
1528
  if (typeof prop === "string" && BLOCKED_METHODS.includes(prop)) {
@@ -1061,14 +1651,23 @@ async function reencryptEncryptedColumns(options) {
1061
1651
  const fields = getEntityComplianceFields(meta.className);
1062
1652
  if (!fields) continue;
1063
1653
  const props = meta.properties;
1064
- const encryptedProps = [...fields.entries()].filter(([, level]) => ENCRYPTED_LEVELS4.has(level)).map(([prop]) => props[prop]).filter(
1654
+ const compliant = compliantColumnsOf(props);
1655
+ const compliantNames = new Set(compliant.map((c) => c.property));
1656
+ const encryptedProps = [...fields.entries()].filter(
1657
+ ([prop, level]) => ENCRYPTED_LEVELS4.has(level) && !compliantNames.has(prop)
1658
+ ).map(([prop]) => props[prop]).filter(
1065
1659
  (p3) => !!p3 && Array.isArray(p3.fieldNames) && p3.fieldNames.length === 1
1066
1660
  );
1067
- if (encryptedProps.length === 0) continue;
1661
+ if (encryptedProps.length === 0 && compliant.length === 0) continue;
1068
1662
  const pkProp = props[meta.primaryKeys[0]];
1069
1663
  if (!pkProp) continue;
1070
1664
  const pkColumn = pkProp.fieldNames[0];
1071
- const columns = encryptedProps.map((p3) => p3.fieldNames[0]);
1665
+ const columns = [
1666
+ ...encryptedProps.map((p3) => p3.fieldNames[0]),
1667
+ ...compliant.flatMap(
1668
+ (c) => c.indexColumn ? [c.envelopeColumn, c.indexColumn] : [c.envelopeColumn]
1669
+ )
1670
+ ];
1072
1671
  const report = {
1073
1672
  table: meta.tableName,
1074
1673
  columns,
@@ -1079,7 +1678,9 @@ async function reencryptEncryptedColumns(options) {
1079
1678
  unreadable: 0,
1080
1679
  unreadableIds: [],
1081
1680
  byKeyId: {},
1082
- missingKeyIds: {}
1681
+ missingKeyIds: {},
1682
+ sealed: 0,
1683
+ reindexed: 0
1083
1684
  };
1084
1685
  const exists = await execute(`select to_regclass(?) as t`, [
1085
1686
  quote(meta.tableName)
@@ -1124,6 +1725,34 @@ async function reencryptEncryptedColumns(options) {
1124
1725
  report.missingKeyIds[missing2] = (report.missingKeyIds[missing2] ?? 0) + 1;
1125
1726
  }
1126
1727
  }
1728
+ for (const column of compliant) {
1729
+ const outcome = planCompliantColumn(
1730
+ column,
1731
+ row,
1732
+ encryptor,
1733
+ tenantIds,
1734
+ fallbackTenantIds
1735
+ );
1736
+ if (outcome.kind === "unreadable") {
1737
+ report.unreadable += 1;
1738
+ rowUnreadable = true;
1739
+ const missing2 = stampedKeyId(String(row[column.envelopeColumn]));
1740
+ if (missing2)
1741
+ report.missingKeyIds[missing2] = (report.missingKeyIds[missing2] ?? 0) + 1;
1742
+ continue;
1743
+ }
1744
+ if (outcome.kind === "empty") continue;
1745
+ if (outcome.sealed) {
1746
+ report.sealed = (report.sealed ?? 0) + 1;
1747
+ report.rewritten += 1;
1748
+ if (outcome.fromKeyId)
1749
+ report.byKeyId[outcome.fromKeyId] = (report.byKeyId[outcome.fromKeyId] ?? 0) + 1;
1750
+ } else {
1751
+ report.current += 1;
1752
+ }
1753
+ if (outcome.reindexed) report.reindexed = (report.reindexed ?? 0) + 1;
1754
+ updates.push(...outcome.updates.map((u) => ({ ...u, jsonb: false })));
1755
+ }
1127
1756
  if (rowUnreadable) report.unreadableIds.push(String(row[pkColumn]));
1128
1757
  if (updates.length && !dryRun) {
1129
1758
  const sets = updates.map(
@@ -1138,7 +1767,7 @@ async function reencryptEncryptedColumns(options) {
1138
1767
  const byKey = Object.entries(report.byKeyId).map(([k, n]) => `${k}: ${n}`).join(", ");
1139
1768
  const missing = Object.entries(report.missingKeyIds).map(([k, n]) => `${k}: ${n}`).join(", ");
1140
1769
  log(
1141
- `[key-rotation] ${meta.tableName}: scanned ${report.scanned} rows, ${report.rewritten} values ${dryRun ? "would be " : ""}rewritten${byKey ? ` (from ${byKey})` : ""}, ${report.current} already current, ${report.plaintext} plaintext, ${report.unreadable} unreadable${missing ? ` (stamped with keys not in the ring: ${missing})` : ""}${report.unreadableIds.length ? ` (rows ${report.unreadableIds.slice(0, 10).join(", ")}${report.unreadableIds.length > 10 ? ", ..." : ""})` : ""}`
1770
+ `[key-rotation] ${meta.tableName}: scanned ${report.scanned} rows, ${report.rewritten} values ${dryRun ? "would be " : ""}rewritten${byKey ? ` (from ${byKey})` : ""}, ${report.current} already current, ${report.plaintext} plaintext, ${report.sealed ?? 0} compliant values sealed, ${report.reindexed ?? 0} blind indexes written, ${report.unreadable} unreadable${missing ? ` (stamped with keys not in the ring: ${missing})` : ""}${report.unreadableIds.length ? ` (rows ${report.unreadableIds.slice(0, 10).join(", ")}${report.unreadableIds.length > 10 ? ", ..." : ""})` : ""}`
1142
1771
  );
1143
1772
  reports.push(report);
1144
1773
  }
@@ -1169,6 +1798,93 @@ function rotationTotals(reports) {
1169
1798
  unreadable: sum((r) => r.unreadable)
1170
1799
  };
1171
1800
  }
1801
+ function compliantColumnsOf(props) {
1802
+ const columns = [];
1803
+ for (const [property, prop] of Object.entries(props)) {
1804
+ const type = prop?.customType;
1805
+ if (!prop || !isCompliantType(type)) continue;
1806
+ if (type instanceof CompliantIndexType) {
1807
+ const sibling = props[`${property}Sealed`];
1808
+ if (!sibling?.fieldNames?.[0]) continue;
1809
+ columns.push({
1810
+ property,
1811
+ spec: type.spec,
1812
+ envelopeColumn: sibling.fieldNames[0],
1813
+ indexColumn: prop.fieldNames[0]
1814
+ });
1815
+ } else {
1816
+ columns.push({
1817
+ property,
1818
+ spec: type.spec,
1819
+ envelopeColumn: prop.fieldNames[0]
1820
+ });
1821
+ }
1822
+ }
1823
+ return columns;
1824
+ }
1825
+ function planCompliantColumn(column, row, encryptor, tenantIds, fallbackTenantIds) {
1826
+ const stored = row[column.envelopeColumn];
1827
+ if (stored === null || stored === void 0) {
1828
+ if (column.indexColumn && row[column.indexColumn] != null) {
1829
+ return {
1830
+ kind: "ok",
1831
+ sealed: false,
1832
+ reindexed: true,
1833
+ updates: [{ column: column.indexColumn, next: null }]
1834
+ };
1835
+ }
1836
+ return { kind: "empty" };
1837
+ }
1838
+ const primary = tenantIds.length ? tenantIds : [""];
1839
+ const candidates = [
1840
+ ...primary,
1841
+ ...fallbackTenantIds.filter((t) => !primary.includes(t))
1842
+ ];
1843
+ for (const tenantId of candidates) {
1844
+ let serialized;
1845
+ let anon;
1846
+ let current = false;
1847
+ let fromKeyId;
1848
+ if (!isEncryptedValue(stored)) {
1849
+ serialized = String(stored);
1850
+ } else {
1851
+ const opened = tryOpen(encryptor, String(stored), tenantId);
1852
+ if (!opened) continue;
1853
+ serialized = opened.plaintext;
1854
+ anon = opened.anon;
1855
+ current = opened.version === "v4" && !opened.stale;
1856
+ fromKeyId = opened.keyId;
1857
+ }
1858
+ const updates = [];
1859
+ if (!current) {
1860
+ anon ??= computeAnon(
1861
+ column.spec.anon,
1862
+ deserializeFromEncryption(
1863
+ serialized,
1864
+ column.spec.elementRuntimeType,
1865
+ column.spec.isArray
1866
+ )
1867
+ );
1868
+ updates.push({
1869
+ column: column.envelopeColumn,
1870
+ next: encryptor.seal(serialized, tenantId, anon)
1871
+ });
1872
+ }
1873
+ let reindexed = false;
1874
+ if (column.indexColumn) {
1875
+ const index = encryptor.blindIndex(
1876
+ normalizeSerializedForIndex(serialized, column.spec),
1877
+ tenantId
1878
+ );
1879
+ if (row[column.indexColumn] !== index) {
1880
+ updates.push({ column: column.indexColumn, next: index });
1881
+ reindexed = true;
1882
+ }
1883
+ }
1884
+ return { kind: "ok", sealed: !current, reindexed, fromKeyId, updates };
1885
+ }
1886
+ return { kind: "unreadable" };
1887
+ }
1172
1888
 
1173
1889
  // src/persistence/tenantFilter.ts
1174
1890
  var TENANT_FILTER_NAME = "tenant";
@@ -1341,7 +2057,13 @@ function rebind(result, target, proxy) {
1341
2057
  }
1342
2058
  // Annotate the CommonJS export names for ESM import in node:
1343
2059
  0 && (module.exports = {
2060
+ Anon,
2061
+ BLIND_INDEX_PREFIX,
1344
2062
  ComplianceLevel,
2063
+ CompliantField,
2064
+ CompliantIndexType,
2065
+ CompliantQueryError,
2066
+ CompliantType,
1345
2067
  DecryptionError,
1346
2068
  ENCRYPTED_PREFIXES,
1347
2069
  ENCRYPTION_FORMAT_ENV,
@@ -1352,6 +2074,7 @@ function rebind(result, target, proxy) {
1352
2074
  FieldEncryptor,
1353
2075
  LEGACY_ENCRYPTION_KEYS_ENV,
1354
2076
  LEGACY_ENCRYPTION_KEY_ENV,
2077
+ LegacyCiphertextError,
1355
2078
  MissingEncryptionKeyError,
1356
2079
  RetentionAction,
1357
2080
  RetentionDuration,
@@ -1364,6 +2087,7 @@ function rebind(result, target, proxy) {
1364
2087
  collectFallbackTenantIds,
1365
2088
  countValuesByKeyId,
1366
2089
  createTenantFilterDef,
2090
+ deanon,
1367
2091
  defaultTenantIdsFor,
1368
2092
  defineComplianceEntity,
1369
2093
  encryptionKeyId,
@@ -1379,13 +2103,17 @@ function rebind(result, target, proxy) {
1379
2103
  getEntityRetention,
1380
2104
  getEntityUserIdField,
1381
2105
  getSuperAdminContext,
2106
+ isAnon,
2107
+ isCompliantField,
1382
2108
  isEncryptedCiphertext,
1383
2109
  isEncryptedValue,
2110
+ onComplianceAccess,
1384
2111
  parseDuration,
1385
2112
  parseEncryptionKeyList,
1386
2113
  reencryptEncryptedColumns,
1387
2114
  registerEncryptor,
1388
2115
  rotationTotals,
2116
+ sealedAnon,
1389
2117
  setEncryptionTenantId,
1390
2118
  setupRls,
1391
2119
  setupTenantFilter,