@forklaunch/core 2.1.0 → 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 +29 -13
@@ -159,9 +159,11 @@ function asEncryptionSafe(em) {
159
159
  return em;
160
160
  }
161
161
 
162
- // src/persistence/compliancePropertyBuilder.ts
163
- import { safeParse, safeStringify } from "@forklaunch/common";
164
- import { p, Type as Type2 } from "@mikro-orm/core";
162
+ // src/persistence/compliantField.ts
163
+ import crypto2 from "crypto";
164
+ import {
165
+ Type as Type2
166
+ } from "@mikro-orm/core";
165
167
 
166
168
  // src/persistence/encryptedType.ts
167
169
  import { Type } from "@mikro-orm/core";
@@ -175,6 +177,9 @@ var _tenantContext = new AsyncLocalStorage();
175
177
  function registerEncryptor(encryptor) {
176
178
  _encryptor = encryptor;
177
179
  }
180
+ function getRegisteredEncryptor() {
181
+ return _encryptor;
182
+ }
178
183
  var EmptyTenantError = class extends Error {
179
184
  constructor(api) {
180
185
  super(
@@ -330,255 +335,55 @@ var EncryptedType = class extends Type {
330
335
  // Serialization helpers
331
336
  // ---------------------------------------------------------------------------
332
337
  serialize(value) {
333
- if (typeof value === "string") return value;
334
- if (value instanceof Date) return value.toISOString();
335
- if (typeof value === "bigint") return value.toString();
336
- if (Buffer.isBuffer(value) || value instanceof Uint8Array)
337
- return Buffer.from(value).toString("base64");
338
- return JSON.stringify(value);
338
+ return serializeForEncryption(value);
339
339
  }
340
340
  deserialize(value) {
341
- if (this._isArray) {
342
- try {
343
- const arr = JSON.parse(value);
344
- if (!Array.isArray(arr)) return arr;
345
- return arr.map(
346
- (el) => hydrateValue(el, this._elementRuntimeType)
347
- );
348
- } catch {
349
- return value;
350
- }
351
- }
352
- switch (this._elementRuntimeType) {
353
- case "string":
354
- return value;
355
- case "any":
356
- try {
357
- return JSON.parse(value);
358
- } catch {
359
- return value;
360
- }
361
- // Types serialized via toString/toISOString/base64 — hydrate from string directly
362
- case "bigint":
363
- case "Date":
364
- case "Buffer":
365
- return hydrateValue(value, this._elementRuntimeType);
366
- // Types where JSON.parse recovers the native JS value (number, boolean)
367
- default:
368
- return hydrateValue(this.tryJsonParse(value), this._elementRuntimeType);
369
- }
370
- }
371
- tryJsonParse(value) {
372
- try {
373
- return JSON.parse(value);
374
- } catch {
375
- return value;
376
- }
341
+ return deserializeFromEncryption(
342
+ value,
343
+ this._elementRuntimeType,
344
+ this._isArray
345
+ );
377
346
  }
378
347
  };
379
-
380
- // src/persistence/compliancePropertyBuilder.ts
381
- function isBuilder(value) {
382
- return value != null && typeof value === "object" && "~options" in value;
383
- }
384
- function wrapUnclassified(builder) {
385
- return new Proxy(builder, {
386
- get(target, prop) {
387
- if (prop === "compliance") {
388
- return (level) => wrapClassified(target, level);
389
- }
390
- if (prop === "~options") return Reflect.get(target, prop, target);
391
- if (prop === COMPLIANCE_KEY) return void 0;
392
- const value = Reflect.get(target, prop, target);
393
- if (typeof value === "function") {
394
- return (...args) => {
395
- const result = value.apply(
396
- target,
397
- args
398
- );
399
- return isBuilder(result) ? wrapUnclassified(result) : result;
400
- };
401
- }
402
- return value;
403
- }
404
- });
348
+ function serializeForEncryption(value) {
349
+ if (typeof value === "string") return value;
350
+ if (value instanceof Date) return value.toISOString();
351
+ if (typeof value === "bigint") return value.toString();
352
+ if (Buffer.isBuffer(value) || value instanceof Uint8Array)
353
+ return Buffer.from(value).toString("base64");
354
+ return JSON.stringify(value);
405
355
  }
406
- var ENCRYPTED_LEVELS2 = /* @__PURE__ */ new Set([
407
- "pii",
408
- "phi",
409
- "pci"
410
- ]);
411
- function resolveEncryptedTypeArgs(options) {
412
- const type = options.type;
413
- const isArray = options.array === true;
414
- const resolved = resolveTypeInstance(type);
415
- if (resolved) {
416
- const rt = resolved.runtimeType;
417
- if (rt.endsWith("[]")) {
418
- return { elementRuntimeType: rt.slice(0, -2) || "string", isArray: true };
419
- }
420
- return { elementRuntimeType: rt, isArray };
356
+ function tryJsonParse(value) {
357
+ try {
358
+ return JSON.parse(value);
359
+ } catch {
360
+ return value;
421
361
  }
422
- return { elementRuntimeType: "string", isArray };
423
362
  }
424
- function wrapClassified(builder, level) {
425
- if (ENCRYPTED_LEVELS2.has(level)) {
426
- const options = builder["~options"];
427
- if (options) {
428
- const { elementRuntimeType, isArray } = resolveEncryptedTypeArgs(options);
429
- let enumValues;
430
- if (options.items) {
431
- enumValues = extractEnumValues(options.items);
432
- delete options.items;
433
- delete options.nativeEnumName;
434
- delete options.enum;
435
- }
436
- options.type = new EncryptedType(elementRuntimeType, isArray, enumValues);
437
- options.columnType = "text";
438
- options.runtimeType = isArray ? "object" : elementRuntimeType;
439
- }
440
- }
441
- return new Proxy(builder, {
442
- get(target, prop) {
443
- if (prop === COMPLIANCE_KEY) return level;
444
- if (prop === "~options") return Reflect.get(target, prop, target);
445
- const value = Reflect.get(target, prop, target);
446
- if (typeof value === "function") {
447
- return (...args) => {
448
- const result = value.apply(
449
- target,
450
- args
451
- );
452
- return isBuilder(result) ? wrapClassified(result, level) : result;
453
- };
454
- }
363
+ function deserializeFromEncryption(value, elementRuntimeType, isArray) {
364
+ if (isArray) {
365
+ try {
366
+ const arr = JSON.parse(value);
367
+ if (!Array.isArray(arr)) return arr;
368
+ return arr.map((el) => hydrateValue(el, elementRuntimeType));
369
+ } catch {
455
370
  return value;
456
371
  }
457
- });
458
- }
459
- function wrapRelation(builder) {
460
- return wrapClassified(builder, "none");
461
- }
462
- var RELATION_METHODS = /* @__PURE__ */ new Set([
463
- "manyToOne",
464
- "oneToMany",
465
- "manyToMany",
466
- "oneToOne",
467
- "embedded"
468
- ]);
469
- function isRelationMethod(prop) {
470
- return typeof prop === "string" && RELATION_METHODS.has(prop);
471
- }
472
- function extractEnumValues(items) {
473
- if (typeof items === "function") {
474
- return extractEnumValues(items());
475
- }
476
- if (Array.isArray(items)) return items;
477
- if (items != null && typeof items === "object") {
478
- const obj = items;
479
- const allValues = Object.values(obj);
480
- return allValues.filter((v) => typeof v !== "string" || !(v in obj));
481
- }
482
- return [];
483
- }
484
- var EnumTextType = class extends Type2 {
485
- convertToDatabaseValue(value) {
486
- return safeStringify(value);
487
- }
488
- convertToJSValue(value) {
489
- return safeParse(value);
490
- }
491
- getColumnType() {
492
- return "text";
493
- }
494
- get runtimeType() {
495
- return "string";
496
- }
497
- };
498
- function inferEnumType(items) {
499
- const values = extractEnumValues(items);
500
- if (values.length === 0) return "text";
501
- const allStrings = values.every((v) => typeof v === "string");
502
- if (allStrings) return "text";
503
- const allNumbers = values.every((v) => typeof v === "number");
504
- if (allNumbers) return "integer";
505
- return new EnumTextType();
506
- }
507
- var fp = new Proxy(p, {
508
- get(target, prop) {
509
- const value = Reflect.get(target, prop, target);
510
- if (typeof value !== "function") return value;
511
- if (isRelationMethod(prop)) {
512
- return (...args) => {
513
- const result = value.apply(
514
- target,
515
- args
516
- );
517
- return isBuilder(result) ? wrapRelation(result) : result;
518
- };
519
- }
520
- return (...args) => {
521
- const result = value.apply(
522
- target,
523
- args
524
- );
525
- if (isBuilder(result)) {
526
- if (prop === "enum") {
527
- const options = result["~options"];
528
- if (options && !options.type) {
529
- options.type = inferEnumType(options.items);
530
- }
531
- }
532
- return wrapUnclassified(result);
533
- }
534
- return result;
535
- };
536
- }
537
- });
538
-
539
- // src/persistence/defineComplianceEntity.ts
540
- import {
541
- defineEntity,
542
- p as p2
543
- } from "@mikro-orm/core";
544
- function readComplianceLevel(builder) {
545
- if (builder == null || typeof builder !== "object") return void 0;
546
- return builder[COMPLIANCE_KEY];
547
- }
548
- function defineComplianceEntity(meta) {
549
- const entityName = "name" in meta ? meta.name : "Unknown";
550
- const complianceFields = /* @__PURE__ */ new Map();
551
- const rawProperties = meta.properties;
552
- const resolvedProperties = typeof rawProperties === "function" ? rawProperties(p2) : rawProperties;
553
- for (const [fieldName, rawProp] of Object.entries(resolvedProperties)) {
554
- if (typeof rawProp === "function") {
555
- complianceFields.set(fieldName, "none");
556
- continue;
557
- }
558
- const level = readComplianceLevel(rawProp);
559
- if (level == null) {
560
- throw new Error(
561
- `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.`
562
- );
563
- }
564
- complianceFields.set(fieldName, level);
565
- }
566
- registerEntityCompliance(entityName, complianceFields);
567
- if (meta.retention) {
568
- parseDuration(meta.retention.duration);
569
- if (!resolvedProperties["createdAt"]) {
570
- throw new Error(
571
- `Entity '${entityName}' has a retention policy but no 'createdAt' property. Retention requires createdAt to compute expiration.`
572
- );
573
- }
574
- registerEntityRetention(entityName, meta.retention);
575
372
  }
576
- if (meta.userIdField) {
577
- registerEntityUserIdField(entityName, meta.userIdField);
373
+ switch (elementRuntimeType) {
374
+ case "string":
375
+ return value;
376
+ case "any":
377
+ return tryJsonParse(value);
378
+ // Types serialized via toString/toISOString/base64 — hydrate from string directly
379
+ case "bigint":
380
+ case "Date":
381
+ case "Buffer":
382
+ return hydrateValue(value, elementRuntimeType);
383
+ // Types where JSON.parse recovers the native JS value (number, boolean)
384
+ default:
385
+ return hydrateValue(tryJsonParse(value), elementRuntimeType);
578
386
  }
579
- return defineEntity(
580
- meta
581
- );
582
387
  }
583
388
 
584
389
  // src/persistence/fieldEncryptor.ts
@@ -606,9 +411,17 @@ var IV_BYTES = 12;
606
411
  var KEY_BYTES = 32;
607
412
  var HKDF_HASH = "sha256";
608
413
  var HKDF_SALT = Buffer.alloc(0);
414
+ var BLIND_INDEX_INFO = "forklaunch:blind-index\0";
609
415
  var KEY_ID_HEX_CHARS = 12;
610
416
  var DERIVED_KEY_CACHE_LIMIT = 4096;
611
- var ENCRYPTED_PREFIXES2 = ["v1:", "v2:", "v3:"];
417
+ var ENCRYPTED_PREFIXES2 = ["v1:", "v2:", "v3:", "v4:"];
418
+ var BLIND_INDEX_PREFIX = "bi1:";
419
+ function sealedAnon(ciphertext) {
420
+ if (!ciphertext.startsWith("v4:")) return null;
421
+ const parts = ciphertext.split(":");
422
+ if (parts.length !== 6) return null;
423
+ return Buffer.from(parts[2], "base64url").toString("utf8");
424
+ }
612
425
  function isEncryptedCiphertext(value) {
613
426
  return typeof value === "string" && ENCRYPTED_PREFIXES2.some((p3) => value.startsWith(p3));
614
427
  }
@@ -630,9 +443,12 @@ function encryptionKeyId(masterKey) {
630
443
  return crypto.createHash("sha256").update(masterKey).digest("hex").slice(0, KEY_ID_HEX_CHARS);
631
444
  }
632
445
  function stampedKeyId(ciphertext) {
633
- if (!ciphertext.startsWith("v3:")) return null;
634
446
  const parts = ciphertext.split(":");
635
- return parts.length === 5 ? parts[1] : null;
447
+ if (ciphertext.startsWith("v3:")) return parts.length === 5 ? parts[1] : null;
448
+ if (ciphertext.startsWith("v4:")) return parts.length === 6 ? parts[1] : null;
449
+ if (ciphertext.startsWith(BLIND_INDEX_PREFIX))
450
+ return parts.length === 3 ? parts[1] : null;
451
+ return null;
636
452
  }
637
453
  function stampedPrefix(keyId) {
638
454
  return `v3:${keyId}:`;
@@ -765,7 +581,8 @@ var FieldEncryptor = class _FieldEncryptor {
765
581
  const version = parts[0];
766
582
  let candidates;
767
583
  let body;
768
- if (version === "v3" && parts.length === 5) {
584
+ let anon;
585
+ if (version === "v3" && parts.length === 5 || version === "v4" && parts.length === 6) {
769
586
  const key = this.byId.get(parts[1]);
770
587
  if (!key) {
771
588
  throw new DecryptionError(
@@ -773,7 +590,12 @@ var FieldEncryptor = class _FieldEncryptor {
773
590
  );
774
591
  }
775
592
  candidates = [key];
776
- body = parts.slice(2);
593
+ if (version === "v4") {
594
+ anon = Buffer.from(parts[2], "base64url").toString("utf8");
595
+ body = parts.slice(3);
596
+ } else {
597
+ body = parts.slice(2);
598
+ }
777
599
  } else if ((version === "v1" || version === "v2") && parts.length === 4) {
778
600
  candidates = this.ring;
779
601
  body = parts.slice(1);
@@ -804,7 +626,8 @@ var FieldEncryptor = class _FieldEncryptor {
804
626
  keyId: key.id,
805
627
  current,
806
628
  version,
807
- stale: !current || this.format === "v3" && version !== "v3"
629
+ anon,
630
+ stale: version === "v4" ? !current : !current || this.format === "v3" && version !== "v3"
808
631
  };
809
632
  } catch {
810
633
  }
@@ -830,15 +653,773 @@ var FieldEncryptor = class _FieldEncryptor {
830
653
  if (ciphertext === null || ciphertext === void 0) return null;
831
654
  const opened = this.open(ciphertext, tenantId);
832
655
  if (!opened.stale) return ciphertext;
656
+ if (opened.version === "v4") {
657
+ return this.seal(opened.plaintext, tenantId, opened.anon ?? "");
658
+ }
833
659
  return this.encrypt(opened.plaintext, tenantId);
834
660
  }
661
+ /**
662
+ * Encrypt for a compliant field: a fresh random IV every time (equal
663
+ * plaintexts produce unrelated ciphertexts), stamped with the current key,
664
+ * and carrying `anon` — the field's de-identified value — in clear so it
665
+ * can be read without decrypting.
666
+ *
667
+ * Equality lookups do not use the ciphertext; they use {@link blindIndex}.
668
+ *
669
+ * @returns `v4:{keyId}:{anon}:{iv}:{tag}:{data}`
670
+ */
671
+ seal(plaintext, tenantId, anon) {
672
+ const key = this.deriveKey(tenantId);
673
+ const iv = crypto.randomBytes(IV_BYTES);
674
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv);
675
+ const encrypted = Buffer.concat([
676
+ cipher.update(plaintext, "utf8"),
677
+ cipher.final()
678
+ ]);
679
+ return [
680
+ "v4",
681
+ this.ring[0].id,
682
+ Buffer.from(anon, "utf8").toString("base64url"),
683
+ iv.toString("base64"),
684
+ cipher.getAuthTag().toString("base64"),
685
+ encrypted.toString("base64")
686
+ ].join(":");
687
+ }
688
+ /**
689
+ * Keyed HMAC of a plaintext for equality lookups, under a key derived
690
+ * from the current master key and the tenant (separate from the
691
+ * encryption key). Deterministic per tenant and key, one-way, and never
692
+ * decryptable.
693
+ *
694
+ * After a master key rotation, rows still indexed under the previous key
695
+ * do not match until `reencryptEncryptedColumns` re-indexes them; see
696
+ * {@link blindIndexes} for a lookup that spans the ring.
697
+ *
698
+ * @returns `bi1:{keyId}:{hmac base64url}`
699
+ */
700
+ blindIndex(plaintext, tenantId) {
701
+ return this.blindIndexFor(this.ring[0], plaintext, tenantId);
702
+ }
703
+ /** {@link blindIndex} under every key in the ring, current first. */
704
+ blindIndexes(plaintext, tenantId) {
705
+ return this.ring.map((k) => this.blindIndexFor(k, plaintext, tenantId));
706
+ }
707
+ /** True when a blind index was computed under a previous key. */
708
+ isStaleBlindIndex(index) {
709
+ return stampedKeyId(index) !== this.ring[0].id;
710
+ }
711
+ blindIndexFor(key, plaintext, tenantId) {
712
+ const indexKey = this.deriveKeyFor(key, BLIND_INDEX_INFO + tenantId);
713
+ const mac = crypto.createHmac("sha256", indexKey).update(plaintext, "utf8").digest("base64url");
714
+ return `${BLIND_INDEX_PREFIX}${key.id}:${mac}`;
715
+ }
835
716
  };
836
717
 
837
- // src/persistence/complianceEventSubscriber.ts
838
- var ENCRYPTED_LEVELS3 = /* @__PURE__ */ new Set([
839
- "pii",
840
- "phi",
841
- "pci"
718
+ // src/persistence/compliantField.ts
719
+ var LISTENERS = globalThis[/* @__PURE__ */ Symbol.for("forklaunch.compliance.listeners")] ??= /* @__PURE__ */ new Set();
720
+ function onComplianceAccess(listener) {
721
+ LISTENERS.add(listener);
722
+ return () => LISTENERS.delete(listener);
723
+ }
724
+ var LegacyCiphertextError = class extends Error {
725
+ name = "LegacyCiphertextError";
726
+ };
727
+ var CompliantQueryError = class extends Error {
728
+ name = "CompliantQueryError";
729
+ };
730
+ var MINT = /* @__PURE__ */ Symbol.for("forklaunch.compliance.mint");
731
+ var ANON_BRAND = /* @__PURE__ */ Symbol.for("forklaunch.compliance.anon");
732
+ var Anon = class {
733
+ value;
734
+ /** @internal */
735
+ constructor(value, key) {
736
+ if (key !== MINT) {
737
+ throw new Error("Anon values can only be produced by a compliant field");
738
+ }
739
+ Object.defineProperty(this, ANON_BRAND, { value: true });
740
+ Object.defineProperty(this, "value", { value, enumerable: true });
741
+ Object.freeze(this);
742
+ }
743
+ };
744
+ function isAnon(value) {
745
+ return value != null && typeof value === "object" && value[ANON_BRAND] === true;
746
+ }
747
+ var RESTRICTED_ZIP3 = /* @__PURE__ */ new Set([
748
+ "036",
749
+ "059",
750
+ "063",
751
+ "102",
752
+ "203",
753
+ "556",
754
+ "692",
755
+ "790",
756
+ "821",
757
+ "823",
758
+ "830",
759
+ "831",
760
+ "878",
761
+ "879",
762
+ "884",
763
+ "890",
764
+ "893"
765
+ ]);
766
+ var REDACTED = "[redacted]";
767
+ function computeAnon(strategy, value) {
768
+ switch (strategy) {
769
+ case "token":
770
+ return "tok_" + crypto2.randomBytes(9).toString("base64url");
771
+ case "year": {
772
+ if (value instanceof Date && !isNaN(value.getTime()))
773
+ return String(value.getUTCFullYear());
774
+ const match = /\d{4}/.exec(String(value));
775
+ return match ? match[0] : REDACTED;
776
+ }
777
+ case "zip3": {
778
+ const digits = String(value).replace(/\D/g, "").slice(0, 3);
779
+ if (digits.length < 3 || RESTRICTED_ZIP3.has(digits)) return "000";
780
+ return digits;
781
+ }
782
+ case "last4": {
783
+ const digits = String(value).replace(/\D/g, "");
784
+ return digits.length >= 4 ? `\u2022\u2022\u2022\u2022 ${digits.slice(-4)}` : REDACTED;
785
+ }
786
+ case "redact":
787
+ return REDACTED;
788
+ }
789
+ }
790
+ function defaultAnonStrategy(elementRuntimeType, isArray, hasEnum) {
791
+ if (isArray || hasEnum) return "redact";
792
+ if (elementRuntimeType === "string") return "token";
793
+ if (elementRuntimeType === "Date") return "year";
794
+ return "redact";
795
+ }
796
+ var STATE = globalThis[/* @__PURE__ */ Symbol.for("forklaunch.compliance.state")] ??= /* @__PURE__ */ new WeakMap();
797
+ var FIELD_BRAND = /* @__PURE__ */ Symbol.for("forklaunch.compliance.field");
798
+ function stateOf(field) {
799
+ const state = STATE.get(field);
800
+ if (!state) throw new Error("Not a compliant field");
801
+ return state;
802
+ }
803
+ var CompliantField = class _CompliantField {
804
+ constructor(state) {
805
+ Object.defineProperty(this, FIELD_BRAND, { value: true });
806
+ STATE.set(this, state);
807
+ Object.freeze(this);
808
+ }
809
+ /**
810
+ * @internal Wrap a plaintext value for `spec`. Applications assign plain
811
+ * values to the entity property instead.
812
+ */
813
+ static fromPlain(value, spec) {
814
+ if (spec.enumValues) {
815
+ const values = spec.isArray && Array.isArray(value) ? value : [value];
816
+ for (const v of values) {
817
+ if (!spec.enumValues.includes(v)) {
818
+ throw new Error(
819
+ `Invalid enum value${spec.path ? ` for ${spec.path}` : ""}: ${String(v)}. Allowed values: ${spec.enumValues.join(", ")}`
820
+ );
821
+ }
822
+ }
823
+ }
824
+ return new _CompliantField({
825
+ spec,
826
+ hasPlain: true,
827
+ plain: value,
828
+ anon: computeAnon(spec.anon, value)
829
+ });
830
+ }
831
+ /** @internal Wrap a stored value (any envelope, or legacy plaintext). */
832
+ static fromStored(stored, spec, index) {
833
+ if (!isEncryptedCiphertext(stored)) {
834
+ return new _CompliantField({
835
+ spec,
836
+ hasPlain: true,
837
+ plain: deserializeFromEncryption(
838
+ stored,
839
+ spec.elementRuntimeType,
840
+ spec.isArray
841
+ ),
842
+ index
843
+ });
844
+ }
845
+ return new _CompliantField({
846
+ spec,
847
+ hasPlain: false,
848
+ sealed: stored,
849
+ anon: sealedAnon(stored) ?? void 0,
850
+ index
851
+ });
852
+ }
853
+ /** @internal A queryable field whose ciphertext column was not selected. */
854
+ static fromIndexOnly(index, spec) {
855
+ return new _CompliantField({ spec, hasPlain: false, index });
856
+ }
857
+ /** The field's classification. */
858
+ get level() {
859
+ return stateOf(this).spec.level;
860
+ }
861
+ /** The de-identified value. Never decrypts for values in a `v4` envelope. */
862
+ get anon() {
863
+ const state = stateOf(this);
864
+ if (state.anon === void 0) {
865
+ if (state.spec.anon === "token") {
866
+ throw new LegacyCiphertextError(
867
+ `${state.spec.path} is stored in a pre-v4 envelope and has no token yet; run reencryptEncryptedColumns() once to upgrade it`
868
+ );
869
+ }
870
+ state.anon = computeAnon(state.spec.anon, readPlaintext(this, false));
871
+ }
872
+ return new Anon(state.anon, MINT);
873
+ }
874
+ /** The plaintext. Every read is reported to access listeners. */
875
+ get deanon() {
876
+ return readPlaintext(this, true);
877
+ }
878
+ };
879
+ function readPlaintext(field, report) {
880
+ const state = stateOf(field);
881
+ let tenantId = "";
882
+ if (!state.hasPlain) {
883
+ tenantId = requireBoundTenantId("read");
884
+ if (state.sealed === void 0) {
885
+ throw new Error(
886
+ `${state.spec.path} was not loaded: its ciphertext column was left out of the selection`
887
+ );
888
+ }
889
+ const encryptor = getRegisteredEncryptor();
890
+ if (!encryptor) {
891
+ throw new EncryptionRequiredError(
892
+ `Cannot read ${state.spec.path}: no encryptor registered. Call registerEncryptor() at bootstrap.`
893
+ );
894
+ }
895
+ const opened = encryptor.open(state.sealed, tenantId);
896
+ state.plain = deserializeFromEncryption(
897
+ opened.plaintext,
898
+ state.spec.elementRuntimeType,
899
+ state.spec.isArray
900
+ );
901
+ state.hasPlain = true;
902
+ }
903
+ if (report) {
904
+ tenantId ||= getBoundTenantId() ?? "";
905
+ for (const listener of LISTENERS) {
906
+ listener({ field: state.spec.path, level: state.spec.level, tenantId });
907
+ }
908
+ }
909
+ return state.plain;
910
+ }
911
+ function isCompliantField(value) {
912
+ return value != null && typeof value === "object" && value[FIELD_BRAND] === true;
913
+ }
914
+ function requireEncryptor(path) {
915
+ const encryptor = getRegisteredEncryptor();
916
+ if (!encryptor) {
917
+ throw new EncryptionRequiredError(
918
+ `Cannot write ${path}: no encryptor registered. Call registerEncryptor() at bootstrap; compliant fields are never stored in plaintext.`
919
+ );
920
+ }
921
+ return encryptor;
922
+ }
923
+ function sealedValueOf(field) {
924
+ const state = stateOf(field);
925
+ if (state.sealed !== void 0 && state.sealedTenant === void 0) {
926
+ return state.sealed;
927
+ }
928
+ if (!state.hasPlain) return state.sealed;
929
+ const tenantId = requireBoundTenantId("write");
930
+ if (state.sealed !== void 0 && state.sealedTenant === tenantId) {
931
+ return state.sealed;
932
+ }
933
+ if (state.anon === void 0) {
934
+ state.anon = computeAnon(state.spec.anon, state.plain);
935
+ }
936
+ state.sealed = requireEncryptor(state.spec.path).seal(
937
+ serializeForEncryption(state.plain),
938
+ tenantId,
939
+ state.anon
940
+ );
941
+ state.sealedTenant = tenantId;
942
+ return state.sealed;
943
+ }
944
+ function normalizeSerializedForIndex(serialized, spec) {
945
+ return spec.normalize === "lowercase" ? serialized.trim().toLowerCase() : serialized;
946
+ }
947
+ function normalizedForIndex(value, spec) {
948
+ return normalizeSerializedForIndex(serializeForEncryption(value), spec);
949
+ }
950
+ function blindIndexOfValue(value, spec) {
951
+ return requireEncryptor(spec.path).blindIndex(
952
+ normalizedForIndex(value, spec),
953
+ requireBoundTenantId("query")
954
+ );
955
+ }
956
+ function blindIndexOf(field) {
957
+ const state = stateOf(field);
958
+ if (state.index) return state.index;
959
+ if (!state.hasPlain && state.sealed === void 0) return state.index ?? null;
960
+ try {
961
+ const plain = state.hasPlain ? state.plain : readPlaintext(field, false);
962
+ state.index = blindIndexOfValue(plain, state.spec);
963
+ } catch {
964
+ return state.index ?? null;
965
+ }
966
+ return state.index;
967
+ }
968
+ var PENDING = /* @__PURE__ */ Symbol.for("forklaunch.compliance.pending");
969
+ function isPendingIndex(value) {
970
+ return value != null && typeof value === "object" && PENDING in value;
971
+ }
972
+ function pendingIndexValue(value) {
973
+ return value[PENDING];
974
+ }
975
+ var CompliantTypeBase = class extends Type2 {
976
+ constructor(spec) {
977
+ super();
978
+ this.spec = spec;
979
+ }
980
+ spec;
981
+ getColumnType() {
982
+ return "text";
983
+ }
984
+ get runtimeType() {
985
+ return "string";
986
+ }
987
+ ensureComparable() {
988
+ return false;
989
+ }
990
+ compareAsType() {
991
+ return "string";
992
+ }
993
+ };
994
+ var CompliantType = class extends CompliantTypeBase {
995
+ convertToDatabaseValue(value, _platform, context) {
996
+ if (context?.fromQuery) {
997
+ throw new CompliantQueryError(
998
+ `${this.spec.path} cannot be queried: declare it with .compliance('${this.spec.level}', { queryable: true }) to allow equality lookups`
999
+ );
1000
+ }
1001
+ if (value === null || value === void 0) return null;
1002
+ if (isCompliantField(value)) return sealedValueOf(value) ?? null;
1003
+ if (isEncryptedCiphertext(value)) return value;
1004
+ return sealedValueOf(CompliantField.fromPlain(value, this.spec)) ?? null;
1005
+ }
1006
+ convertToJSValue(value) {
1007
+ if (value === null || value === void 0) return value;
1008
+ if (isCompliantField(value)) return value;
1009
+ return CompliantField.fromStored(String(value), this.spec);
1010
+ }
1011
+ };
1012
+ var EQUALITY_OPERATORS = /* @__PURE__ */ new Set(["$eq", "$ne", "$in", "$nin"]);
1013
+ var CompliantIndexType = class extends CompliantTypeBase {
1014
+ convertToDatabaseValue(value, _platform, context) {
1015
+ if (value === null || value === void 0) return null;
1016
+ if (context?.fromQuery) {
1017
+ const key = context.key;
1018
+ if (key !== void 0 && !EQUALITY_OPERATORS.has(String(key)) && !/^\d+$/.test(String(key))) {
1019
+ throw new CompliantQueryError(
1020
+ `${this.spec.path} supports equality lookups only ($eq, $ne, $in, $nin); ${String(key)} would need the plaintext`
1021
+ );
1022
+ }
1023
+ }
1024
+ if (isCompliantField(value)) return blindIndexOf(value);
1025
+ if (isPendingIndex(value)) return pendingIndexValue(value);
1026
+ if (typeof value === "string" && value.startsWith("bi1:")) return value;
1027
+ return blindIndexOfValue(value, this.spec);
1028
+ }
1029
+ convertToJSValue(value) {
1030
+ if (isCompliantField(value) || isPendingIndex(value)) return value;
1031
+ return { [PENDING]: value ?? null };
1032
+ }
1033
+ };
1034
+ function isCompliantType(type) {
1035
+ return type instanceof CompliantType || type instanceof CompliantIndexType;
1036
+ }
1037
+ var SLOTS = globalThis[/* @__PURE__ */ Symbol.for("forklaunch.compliance.slots")] ??= /* @__PURE__ */ new WeakMap();
1038
+ function slotsOf(entity) {
1039
+ let slots = SLOTS.get(entity);
1040
+ if (!slots) {
1041
+ slots = {};
1042
+ SLOTS.set(entity, slots);
1043
+ }
1044
+ return slots;
1045
+ }
1046
+ function readProperty(entity, binding) {
1047
+ const slots = slotsOf(entity);
1048
+ const value = slots[binding.property];
1049
+ if (binding.sibling) {
1050
+ const stored = slots[binding.sibling];
1051
+ if (isPendingIndex(value)) {
1052
+ const index = pendingIndexValue(value);
1053
+ const field = typeof stored === "string" ? CompliantField.fromStored(stored, binding.spec, index) : CompliantField.fromIndexOnly(index, binding.spec);
1054
+ slots[binding.property] = field;
1055
+ return field;
1056
+ }
1057
+ if (value == null && typeof stored === "string") {
1058
+ const field = CompliantField.fromStored(stored, binding.spec, null);
1059
+ slots[binding.property] = field;
1060
+ return field;
1061
+ }
1062
+ }
1063
+ return value;
1064
+ }
1065
+ function sameRules(a, b) {
1066
+ return a === b || a.path === b.path && a.anon === b.anon && a.normalize === b.normalize && a.queryable === b.queryable;
1067
+ }
1068
+ function writeProperty(entity, binding, value) {
1069
+ const slots = slotsOf(entity);
1070
+ if (isPendingIndex(value)) {
1071
+ slots[binding.property] = value;
1072
+ return;
1073
+ }
1074
+ if (value === null || value === void 0) {
1075
+ slots[binding.property] = value;
1076
+ if (binding.sibling) slots[binding.sibling] = value;
1077
+ return;
1078
+ }
1079
+ if (isCompliantField(value)) {
1080
+ slots[binding.property] = sameRules(stateOf(value).spec, binding.spec) ? value : CompliantField.fromPlain(readPlaintext(value, false), binding.spec);
1081
+ return;
1082
+ }
1083
+ slots[binding.property] = CompliantField.fromPlain(value, binding.spec);
1084
+ }
1085
+ function installCompliantAccessors(prototype, bindings) {
1086
+ installInspect(prototype, bindings);
1087
+ for (const binding of bindings) {
1088
+ Object.defineProperty(prototype, binding.property, {
1089
+ configurable: true,
1090
+ enumerable: true,
1091
+ get() {
1092
+ return readProperty(this, binding);
1093
+ },
1094
+ set(value) {
1095
+ writeProperty(this, binding, value);
1096
+ }
1097
+ });
1098
+ if (binding.sibling) {
1099
+ const sibling = binding.sibling;
1100
+ Object.defineProperty(prototype, sibling, {
1101
+ configurable: true,
1102
+ enumerable: false,
1103
+ get() {
1104
+ const field = readProperty(this, binding);
1105
+ const stored = slotsOf(this)[sibling];
1106
+ if (field === null || field === void 0) return stored ?? field;
1107
+ return sealedValueOf(field) ?? stored;
1108
+ },
1109
+ set(value) {
1110
+ slotsOf(this)[sibling] = value;
1111
+ }
1112
+ });
1113
+ }
1114
+ }
1115
+ }
1116
+ var INSPECT = /* @__PURE__ */ Symbol.for("nodejs.util.inspect.custom");
1117
+ function installInspect(prototype, bindings) {
1118
+ const hidden = new Set(
1119
+ bindings.map((b) => b.sibling).filter((s) => !!s)
1120
+ );
1121
+ Object.defineProperty(prototype, INSPECT, {
1122
+ configurable: true,
1123
+ writable: true,
1124
+ value(depth, options, inspect) {
1125
+ const shown = {};
1126
+ for (const key of Object.keys(this)) {
1127
+ if (!hidden.has(key)) shown[key] = this[key];
1128
+ }
1129
+ for (const binding of bindings) {
1130
+ const value = this[binding.property];
1131
+ if (value !== void 0) shown[binding.property] = value;
1132
+ }
1133
+ const name = this.constructor?.name ?? "Entity";
1134
+ return `${name} ${inspect(shown, { ...options, depth })}`;
1135
+ }
1136
+ });
1137
+ }
1138
+ function deanon(entity) {
1139
+ const copy = {};
1140
+ for (const key in entity) {
1141
+ const value = entity[key];
1142
+ copy[key] = isCompliantField(value) ? value.deanon : value;
1143
+ }
1144
+ return copy;
1145
+ }
1146
+
1147
+ // src/persistence/compliancePropertyBuilder.ts
1148
+ import { safeParse, safeStringify } from "@forklaunch/common";
1149
+ import { p, Type as Type3 } from "@mikro-orm/core";
1150
+ function isBuilder(value) {
1151
+ return value != null && typeof value === "object" && "~options" in value;
1152
+ }
1153
+ function wrapUnclassified(builder) {
1154
+ return new Proxy(builder, {
1155
+ get(target, prop) {
1156
+ if (prop === "compliance") {
1157
+ return (level, options) => wrapClassified(target, level, options);
1158
+ }
1159
+ if (prop === "~options") return Reflect.get(target, prop, target);
1160
+ if (prop === COMPLIANCE_KEY) return void 0;
1161
+ const value = Reflect.get(target, prop, target);
1162
+ if (typeof value === "function") {
1163
+ return (...args) => {
1164
+ const result = value.apply(
1165
+ target,
1166
+ args
1167
+ );
1168
+ return isBuilder(result) ? wrapUnclassified(result) : result;
1169
+ };
1170
+ }
1171
+ return value;
1172
+ }
1173
+ });
1174
+ }
1175
+ var ENCRYPTED_LEVELS2 = /* @__PURE__ */ new Set([
1176
+ "pii",
1177
+ "phi",
1178
+ "pci"
1179
+ ]);
1180
+ function resolveEncryptedTypeArgs(options) {
1181
+ const type = options.type;
1182
+ const isArray = options.array === true;
1183
+ const resolved = resolveTypeInstance(type);
1184
+ if (resolved) {
1185
+ const rt = resolved.runtimeType;
1186
+ if (rt.endsWith("[]")) {
1187
+ return { elementRuntimeType: rt.slice(0, -2) || "string", isArray: true };
1188
+ }
1189
+ return { elementRuntimeType: rt, isArray };
1190
+ }
1191
+ return { elementRuntimeType: "string", isArray };
1192
+ }
1193
+ function wrapClassified(builder, level, complianceOptions) {
1194
+ if (ENCRYPTED_LEVELS2.has(level)) {
1195
+ const options = builder["~options"];
1196
+ if (options) {
1197
+ const { elementRuntimeType, isArray } = resolveEncryptedTypeArgs(options);
1198
+ let enumValues;
1199
+ if (options.items) {
1200
+ enumValues = extractEnumValues(options.items);
1201
+ delete options.items;
1202
+ delete options.nativeEnumName;
1203
+ delete options.enum;
1204
+ }
1205
+ const spec = {
1206
+ level,
1207
+ anon: complianceOptions?.anon ?? defaultAnonStrategy(elementRuntimeType, isArray, !!enumValues),
1208
+ queryable: complianceOptions?.queryable === true,
1209
+ normalize: complianceOptions?.normalize ?? "exact",
1210
+ elementRuntimeType,
1211
+ isArray,
1212
+ enumValues,
1213
+ path: ""
1214
+ // set by defineComplianceEntity once the property name is known
1215
+ };
1216
+ options.type = spec.queryable ? new CompliantIndexType(spec) : new CompliantType(spec);
1217
+ options.columnType = "text";
1218
+ options.runtimeType = "string";
1219
+ options.accessor = true;
1220
+ }
1221
+ }
1222
+ return new Proxy(builder, {
1223
+ get(target, prop) {
1224
+ if (prop === COMPLIANCE_KEY) return level;
1225
+ if (prop === "~options") return Reflect.get(target, prop, target);
1226
+ const value = Reflect.get(target, prop, target);
1227
+ if (typeof value === "function") {
1228
+ return (...args) => {
1229
+ const result = value.apply(
1230
+ target,
1231
+ args
1232
+ );
1233
+ return isBuilder(result) ? wrapClassified(result, level) : result;
1234
+ };
1235
+ }
1236
+ return value;
1237
+ }
1238
+ });
1239
+ }
1240
+ function wrapRelation(builder) {
1241
+ return wrapClassified(builder, "none");
1242
+ }
1243
+ var RELATION_METHODS = /* @__PURE__ */ new Set([
1244
+ "manyToOne",
1245
+ "oneToMany",
1246
+ "manyToMany",
1247
+ "oneToOne",
1248
+ "embedded"
1249
+ ]);
1250
+ function isRelationMethod(prop) {
1251
+ return typeof prop === "string" && RELATION_METHODS.has(prop);
1252
+ }
1253
+ function extractEnumValues(items) {
1254
+ if (typeof items === "function") {
1255
+ return extractEnumValues(items());
1256
+ }
1257
+ if (Array.isArray(items)) return items;
1258
+ if (items != null && typeof items === "object") {
1259
+ const obj = items;
1260
+ const allValues = Object.values(obj);
1261
+ return allValues.filter((v) => typeof v !== "string" || !(v in obj));
1262
+ }
1263
+ return [];
1264
+ }
1265
+ var EnumTextType = class extends Type3 {
1266
+ convertToDatabaseValue(value) {
1267
+ return safeStringify(value);
1268
+ }
1269
+ convertToJSValue(value) {
1270
+ return safeParse(value);
1271
+ }
1272
+ getColumnType() {
1273
+ return "text";
1274
+ }
1275
+ get runtimeType() {
1276
+ return "string";
1277
+ }
1278
+ };
1279
+ function inferEnumType(items) {
1280
+ const values = extractEnumValues(items);
1281
+ if (values.length === 0) return "text";
1282
+ const allStrings = values.every((v) => typeof v === "string");
1283
+ if (allStrings) return "text";
1284
+ const allNumbers = values.every((v) => typeof v === "number");
1285
+ if (allNumbers) return "integer";
1286
+ return new EnumTextType();
1287
+ }
1288
+ var fp = new Proxy(p, {
1289
+ get(target, prop) {
1290
+ const value = Reflect.get(target, prop, target);
1291
+ if (typeof value !== "function") return value;
1292
+ if (isRelationMethod(prop)) {
1293
+ return (...args) => {
1294
+ const result = value.apply(
1295
+ target,
1296
+ args
1297
+ );
1298
+ return isBuilder(result) ? wrapRelation(result) : result;
1299
+ };
1300
+ }
1301
+ return (...args) => {
1302
+ const result = value.apply(
1303
+ target,
1304
+ args
1305
+ );
1306
+ if (isBuilder(result)) {
1307
+ if (prop === "enum") {
1308
+ const options = result["~options"];
1309
+ if (options && !options.type) {
1310
+ options.type = inferEnumType(options.items);
1311
+ }
1312
+ }
1313
+ return wrapUnclassified(result);
1314
+ }
1315
+ return result;
1316
+ };
1317
+ }
1318
+ });
1319
+
1320
+ // src/persistence/defineComplianceEntity.ts
1321
+ import {
1322
+ defineEntity,
1323
+ p as p2,
1324
+ UnderscoreNamingStrategy
1325
+ } from "@mikro-orm/core";
1326
+ var naming = new UnderscoreNamingStrategy();
1327
+ function wireCompliantProperties(entityName, properties) {
1328
+ const wired = {};
1329
+ const bindings = [];
1330
+ for (const [name, prop] of Object.entries(properties)) {
1331
+ wired[name] = prop;
1332
+ if (typeof prop === "function" || prop == null) continue;
1333
+ const options = prop["~options"] ?? prop;
1334
+ const type = options.type;
1335
+ if (!isCompliantType(type)) continue;
1336
+ type.spec.path = `${entityName}.${name}`;
1337
+ if (!(type instanceof CompliantIndexType)) {
1338
+ bindings.push({ property: name, spec: type.spec });
1339
+ continue;
1340
+ }
1341
+ const sibling = `${name}Sealed`;
1342
+ if (sibling in properties) {
1343
+ throw new Error(
1344
+ `Entity '${entityName}': '${sibling}' is reserved for the encrypted value of queryable field '${name}'`
1345
+ );
1346
+ }
1347
+ const explicit = options.fieldName ?? options.fieldNames?.[0];
1348
+ const column = typeof explicit === "string" ? explicit : naming.propertyToColumnName(name);
1349
+ const required = options.nullable !== true;
1350
+ options.fieldName = `${column}_idx`;
1351
+ delete options.fieldNames;
1352
+ options.nullable = true;
1353
+ wired[sibling] = {
1354
+ type: "text",
1355
+ columnType: "text",
1356
+ fieldName: column,
1357
+ nullable: !required,
1358
+ hidden: true,
1359
+ accessor: true
1360
+ };
1361
+ bindings.push({ property: name, spec: type.spec, sibling });
1362
+ }
1363
+ return { properties: wired, bindings };
1364
+ }
1365
+ function readComplianceLevel(builder) {
1366
+ if (builder == null || typeof builder !== "object") return void 0;
1367
+ return builder[COMPLIANCE_KEY];
1368
+ }
1369
+ function defineComplianceEntity(meta) {
1370
+ const entityName = "name" in meta ? meta.name : "Unknown";
1371
+ const complianceFields = /* @__PURE__ */ new Map();
1372
+ const rawProperties = meta.properties;
1373
+ const resolvedProperties = typeof rawProperties === "function" ? rawProperties(p2) : rawProperties;
1374
+ for (const [fieldName, rawProp] of Object.entries(resolvedProperties)) {
1375
+ if (typeof rawProp === "function") {
1376
+ complianceFields.set(fieldName, "none");
1377
+ continue;
1378
+ }
1379
+ const level = readComplianceLevel(rawProp);
1380
+ if (level == null) {
1381
+ throw new Error(
1382
+ `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.`
1383
+ );
1384
+ }
1385
+ complianceFields.set(fieldName, level);
1386
+ }
1387
+ registerEntityCompliance(entityName, complianceFields);
1388
+ if (meta.retention) {
1389
+ parseDuration(meta.retention.duration);
1390
+ if (!resolvedProperties["createdAt"]) {
1391
+ throw new Error(
1392
+ `Entity '${entityName}' has a retention policy but no 'createdAt' property. Retention requires createdAt to compute expiration.`
1393
+ );
1394
+ }
1395
+ registerEntityRetention(entityName, meta.retention);
1396
+ }
1397
+ if (meta.userIdField) {
1398
+ registerEntityUserIdField(entityName, meta.userIdField);
1399
+ }
1400
+ const wired = wireCompliantProperties(entityName, resolvedProperties);
1401
+ const schema = defineEntity({
1402
+ ...meta,
1403
+ properties: wired.properties
1404
+ });
1405
+ if (wired.bindings.length > 0) {
1406
+ const entityClass = schema.meta.class;
1407
+ if (entityClass)
1408
+ installCompliantAccessors(entityClass.prototype, wired.bindings);
1409
+ const setClass = schema.setClass.bind(schema);
1410
+ schema.setClass = ((cls) => {
1411
+ installCompliantAccessors(cls.prototype, wired.bindings);
1412
+ return setClass(cls);
1413
+ });
1414
+ }
1415
+ return schema;
1416
+ }
1417
+
1418
+ // src/persistence/complianceEventSubscriber.ts
1419
+ var ENCRYPTED_LEVELS3 = /* @__PURE__ */ new Set([
1420
+ "pii",
1421
+ "phi",
1422
+ "pci"
842
1423
  ]);
843
1424
  function wrapEmWithNativeQueryBlocking(em) {
844
1425
  const BLOCKED_METHODS = [
@@ -974,14 +1555,23 @@ async function reencryptEncryptedColumns(options) {
974
1555
  const fields = getEntityComplianceFields(meta.className);
975
1556
  if (!fields) continue;
976
1557
  const props = meta.properties;
977
- const encryptedProps = [...fields.entries()].filter(([, level]) => ENCRYPTED_LEVELS4.has(level)).map(([prop]) => props[prop]).filter(
1558
+ const compliant = compliantColumnsOf(props);
1559
+ const compliantNames = new Set(compliant.map((c) => c.property));
1560
+ const encryptedProps = [...fields.entries()].filter(
1561
+ ([prop, level]) => ENCRYPTED_LEVELS4.has(level) && !compliantNames.has(prop)
1562
+ ).map(([prop]) => props[prop]).filter(
978
1563
  (p3) => !!p3 && Array.isArray(p3.fieldNames) && p3.fieldNames.length === 1
979
1564
  );
980
- if (encryptedProps.length === 0) continue;
1565
+ if (encryptedProps.length === 0 && compliant.length === 0) continue;
981
1566
  const pkProp = props[meta.primaryKeys[0]];
982
1567
  if (!pkProp) continue;
983
1568
  const pkColumn = pkProp.fieldNames[0];
984
- const columns = encryptedProps.map((p3) => p3.fieldNames[0]);
1569
+ const columns = [
1570
+ ...encryptedProps.map((p3) => p3.fieldNames[0]),
1571
+ ...compliant.flatMap(
1572
+ (c) => c.indexColumn ? [c.envelopeColumn, c.indexColumn] : [c.envelopeColumn]
1573
+ )
1574
+ ];
985
1575
  const report = {
986
1576
  table: meta.tableName,
987
1577
  columns,
@@ -992,7 +1582,9 @@ async function reencryptEncryptedColumns(options) {
992
1582
  unreadable: 0,
993
1583
  unreadableIds: [],
994
1584
  byKeyId: {},
995
- missingKeyIds: {}
1585
+ missingKeyIds: {},
1586
+ sealed: 0,
1587
+ reindexed: 0
996
1588
  };
997
1589
  const exists = await execute(`select to_regclass(?) as t`, [
998
1590
  quote(meta.tableName)
@@ -1037,6 +1629,34 @@ async function reencryptEncryptedColumns(options) {
1037
1629
  report.missingKeyIds[missing2] = (report.missingKeyIds[missing2] ?? 0) + 1;
1038
1630
  }
1039
1631
  }
1632
+ for (const column of compliant) {
1633
+ const outcome = planCompliantColumn(
1634
+ column,
1635
+ row,
1636
+ encryptor,
1637
+ tenantIds,
1638
+ fallbackTenantIds
1639
+ );
1640
+ if (outcome.kind === "unreadable") {
1641
+ report.unreadable += 1;
1642
+ rowUnreadable = true;
1643
+ const missing2 = stampedKeyId(String(row[column.envelopeColumn]));
1644
+ if (missing2)
1645
+ report.missingKeyIds[missing2] = (report.missingKeyIds[missing2] ?? 0) + 1;
1646
+ continue;
1647
+ }
1648
+ if (outcome.kind === "empty") continue;
1649
+ if (outcome.sealed) {
1650
+ report.sealed = (report.sealed ?? 0) + 1;
1651
+ report.rewritten += 1;
1652
+ if (outcome.fromKeyId)
1653
+ report.byKeyId[outcome.fromKeyId] = (report.byKeyId[outcome.fromKeyId] ?? 0) + 1;
1654
+ } else {
1655
+ report.current += 1;
1656
+ }
1657
+ if (outcome.reindexed) report.reindexed = (report.reindexed ?? 0) + 1;
1658
+ updates.push(...outcome.updates.map((u) => ({ ...u, jsonb: false })));
1659
+ }
1040
1660
  if (rowUnreadable) report.unreadableIds.push(String(row[pkColumn]));
1041
1661
  if (updates.length && !dryRun) {
1042
1662
  const sets = updates.map(
@@ -1051,7 +1671,7 @@ async function reencryptEncryptedColumns(options) {
1051
1671
  const byKey = Object.entries(report.byKeyId).map(([k, n]) => `${k}: ${n}`).join(", ");
1052
1672
  const missing = Object.entries(report.missingKeyIds).map(([k, n]) => `${k}: ${n}`).join(", ");
1053
1673
  log(
1054
- `[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 ? ", ..." : ""})` : ""}`
1674
+ `[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 ? ", ..." : ""})` : ""}`
1055
1675
  );
1056
1676
  reports.push(report);
1057
1677
  }
@@ -1082,6 +1702,93 @@ function rotationTotals(reports) {
1082
1702
  unreadable: sum((r) => r.unreadable)
1083
1703
  };
1084
1704
  }
1705
+ function compliantColumnsOf(props) {
1706
+ const columns = [];
1707
+ for (const [property, prop] of Object.entries(props)) {
1708
+ const type = prop?.customType;
1709
+ if (!prop || !isCompliantType(type)) continue;
1710
+ if (type instanceof CompliantIndexType) {
1711
+ const sibling = props[`${property}Sealed`];
1712
+ if (!sibling?.fieldNames?.[0]) continue;
1713
+ columns.push({
1714
+ property,
1715
+ spec: type.spec,
1716
+ envelopeColumn: sibling.fieldNames[0],
1717
+ indexColumn: prop.fieldNames[0]
1718
+ });
1719
+ } else {
1720
+ columns.push({
1721
+ property,
1722
+ spec: type.spec,
1723
+ envelopeColumn: prop.fieldNames[0]
1724
+ });
1725
+ }
1726
+ }
1727
+ return columns;
1728
+ }
1729
+ function planCompliantColumn(column, row, encryptor, tenantIds, fallbackTenantIds) {
1730
+ const stored = row[column.envelopeColumn];
1731
+ if (stored === null || stored === void 0) {
1732
+ if (column.indexColumn && row[column.indexColumn] != null) {
1733
+ return {
1734
+ kind: "ok",
1735
+ sealed: false,
1736
+ reindexed: true,
1737
+ updates: [{ column: column.indexColumn, next: null }]
1738
+ };
1739
+ }
1740
+ return { kind: "empty" };
1741
+ }
1742
+ const primary = tenantIds.length ? tenantIds : [""];
1743
+ const candidates = [
1744
+ ...primary,
1745
+ ...fallbackTenantIds.filter((t) => !primary.includes(t))
1746
+ ];
1747
+ for (const tenantId of candidates) {
1748
+ let serialized;
1749
+ let anon;
1750
+ let current = false;
1751
+ let fromKeyId;
1752
+ if (!isEncryptedValue(stored)) {
1753
+ serialized = String(stored);
1754
+ } else {
1755
+ const opened = tryOpen(encryptor, String(stored), tenantId);
1756
+ if (!opened) continue;
1757
+ serialized = opened.plaintext;
1758
+ anon = opened.anon;
1759
+ current = opened.version === "v4" && !opened.stale;
1760
+ fromKeyId = opened.keyId;
1761
+ }
1762
+ const updates = [];
1763
+ if (!current) {
1764
+ anon ??= computeAnon(
1765
+ column.spec.anon,
1766
+ deserializeFromEncryption(
1767
+ serialized,
1768
+ column.spec.elementRuntimeType,
1769
+ column.spec.isArray
1770
+ )
1771
+ );
1772
+ updates.push({
1773
+ column: column.envelopeColumn,
1774
+ next: encryptor.seal(serialized, tenantId, anon)
1775
+ });
1776
+ }
1777
+ let reindexed = false;
1778
+ if (column.indexColumn) {
1779
+ const index = encryptor.blindIndex(
1780
+ normalizeSerializedForIndex(serialized, column.spec),
1781
+ tenantId
1782
+ );
1783
+ if (row[column.indexColumn] !== index) {
1784
+ updates.push({ column: column.indexColumn, next: index });
1785
+ reindexed = true;
1786
+ }
1787
+ }
1788
+ return { kind: "ok", sealed: !current, reindexed, fromKeyId, updates };
1789
+ }
1790
+ return { kind: "unreadable" };
1791
+ }
1085
1792
 
1086
1793
  // src/persistence/tenantFilter.ts
1087
1794
  var TENANT_FILTER_NAME = "tenant";
@@ -1253,7 +1960,13 @@ function rebind(result, target, proxy) {
1253
1960
  return result === target ? proxy : result;
1254
1961
  }
1255
1962
  export {
1963
+ Anon,
1964
+ BLIND_INDEX_PREFIX,
1256
1965
  ComplianceLevel,
1966
+ CompliantField,
1967
+ CompliantIndexType,
1968
+ CompliantQueryError,
1969
+ CompliantType,
1257
1970
  DecryptionError,
1258
1971
  ENCRYPTED_PREFIXES2 as ENCRYPTED_PREFIXES,
1259
1972
  ENCRYPTION_FORMAT_ENV,
@@ -1264,6 +1977,7 @@ export {
1264
1977
  FieldEncryptor,
1265
1978
  LEGACY_ENCRYPTION_KEYS_ENV,
1266
1979
  LEGACY_ENCRYPTION_KEY_ENV,
1980
+ LegacyCiphertextError,
1267
1981
  MissingEncryptionKeyError,
1268
1982
  RetentionAction,
1269
1983
  RetentionDuration,
@@ -1276,6 +1990,7 @@ export {
1276
1990
  collectFallbackTenantIds,
1277
1991
  countValuesByKeyId,
1278
1992
  createTenantFilterDef,
1993
+ deanon,
1279
1994
  defaultTenantIdsFor,
1280
1995
  defineComplianceEntity,
1281
1996
  encryptionKeyId,
@@ -1291,13 +2006,17 @@ export {
1291
2006
  getEntityRetention,
1292
2007
  getEntityUserIdField,
1293
2008
  getSuperAdminContext,
2009
+ isAnon,
2010
+ isCompliantField,
1294
2011
  isEncryptedCiphertext,
1295
2012
  isEncryptedValue,
2013
+ onComplianceAccess,
1296
2014
  parseDuration,
1297
2015
  parseEncryptionKeyList,
1298
2016
  reencryptEncryptedColumns,
1299
2017
  registerEncryptor,
1300
2018
  rotationTotals,
2019
+ sealedAnon,
1301
2020
  setEncryptionTenantId,
1302
2021
  setupRls,
1303
2022
  setupTenantFilter,