@forklaunch/core 1.5.19 → 1.6.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.
@@ -32,18 +32,29 @@ var persistence_exports = {};
32
32
  __export(persistence_exports, {
33
33
  ComplianceLevel: () => ComplianceLevel,
34
34
  DecryptionError: () => DecryptionError,
35
+ ENCRYPTED_PREFIXES: () => ENCRYPTED_PREFIXES2,
36
+ ENCRYPTION_FORMAT_ENV: () => ENCRYPTION_FORMAT_ENV,
37
+ ENCRYPTION_KEY_ENV: () => ENCRYPTION_KEY_ENV,
35
38
  EncryptedType: () => EncryptedType,
36
39
  EncryptionRequiredError: () => EncryptionRequiredError,
37
40
  FieldEncryptor: () => FieldEncryptor,
41
+ LEGACY_ENCRYPTION_KEYS_ENV: () => LEGACY_ENCRYPTION_KEYS_ENV,
42
+ LEGACY_ENCRYPTION_KEY_ENV: () => LEGACY_ENCRYPTION_KEY_ENV,
38
43
  MissingEncryptionKeyError: () => MissingEncryptionKeyError,
39
44
  RetentionAction: () => RetentionAction,
40
45
  RetentionDuration: () => RetentionDuration,
41
46
  RlsEventSubscriber: () => RlsEventSubscriber,
42
47
  TENANT_FILTER_NAME: () => TENANT_FILTER_NAME,
43
48
  asEncryptionSafe: () => asEncryptionSafe,
49
+ classifyEncryptedValue: () => classifyEncryptedValue,
50
+ collectFallbackTenantIds: () => collectFallbackTenantIds,
51
+ countValuesByKeyId: () => countValuesByKeyId,
44
52
  createTenantFilterDef: () => createTenantFilterDef,
53
+ defaultTenantIdsFor: () => defaultTenantIdsFor,
45
54
  defineComplianceEntity: () => defineComplianceEntity,
55
+ encryptionKeyId: () => encryptionKeyId,
46
56
  entityHasEncryptedFields: () => entityHasEncryptedFields,
57
+ entityMetadataList: () => entityMetadataList,
47
58
  fp: () => fp,
48
59
  getAllRetentionPolicies: () => getAllRetentionPolicies,
49
60
  getAllUserIdFields: () => getAllUserIdFields,
@@ -53,11 +64,18 @@ __export(persistence_exports, {
53
64
  getEntityRetention: () => getEntityRetention,
54
65
  getEntityUserIdField: () => getEntityUserIdField,
55
66
  getSuperAdminContext: () => getSuperAdminContext,
67
+ isEncryptedCiphertext: () => isEncryptedCiphertext,
68
+ isEncryptedValue: () => isEncryptedValue,
56
69
  parseDuration: () => parseDuration,
70
+ parseEncryptionKeyList: () => parseEncryptionKeyList,
71
+ reencryptEncryptedColumns: () => reencryptEncryptedColumns,
57
72
  registerEncryptor: () => registerEncryptor,
73
+ rotationTotals: () => rotationTotals,
58
74
  setEncryptionTenantId: () => setEncryptionTenantId,
59
75
  setupRls: () => setupRls,
60
76
  setupTenantFilter: () => setupTenantFilter,
77
+ stampedKeyId: () => stampedKeyId,
78
+ stampedPrefix: () => stampedPrefix,
61
79
  subtractDuration: () => subtractDuration,
62
80
  withEncryptionContext: () => withEncryptionContext,
63
81
  wrapEmWithForgivingDecryption: () => wrapEmWithForgivingDecryption,
@@ -234,7 +252,7 @@ var import_core2 = require("@mikro-orm/core");
234
252
  // src/persistence/encryptedType.ts
235
253
  var import_core = require("@mikro-orm/core");
236
254
  var import_node_async_hooks = require("async_hooks");
237
- var ENCRYPTED_PREFIXES = ["v1:", "v2:"];
255
+ var ENCRYPTED_PREFIXES = ["v1:", "v2:", "v3:"];
238
256
  function isEncrypted(value) {
239
257
  return ENCRYPTED_PREFIXES.some((p3) => value.startsWith(p3));
240
258
  }
@@ -638,26 +656,122 @@ var IV_BYTES = 12;
638
656
  var KEY_BYTES = 32;
639
657
  var HKDF_HASH = "sha256";
640
658
  var HKDF_SALT = Buffer.alloc(0);
641
- var FieldEncryptor = class {
642
- masterKey;
643
- constructor(masterKey) {
659
+ var KEY_ID_HEX_CHARS = 12;
660
+ var DERIVED_KEY_CACHE_LIMIT = 4096;
661
+ var ENCRYPTED_PREFIXES2 = ["v1:", "v2:", "v3:"];
662
+ function isEncryptedCiphertext(value) {
663
+ return typeof value === "string" && ENCRYPTED_PREFIXES2.some((p3) => value.startsWith(p3));
664
+ }
665
+ var ENCRYPTION_KEY_ENV = "ENCRYPTION_KEY";
666
+ var LEGACY_ENCRYPTION_KEYS_ENV = "LEGACY_ENCRYPTION_KEYS";
667
+ var LEGACY_ENCRYPTION_KEY_ENV = "LEGACY_ENCRYPTION_KEY";
668
+ var ENCRYPTION_FORMAT_ENV = "ENCRYPTION_FORMAT";
669
+ function parseEncryptionKeyList(...sources) {
670
+ const keys = [];
671
+ for (const source of sources) {
672
+ for (const part of (source ?? "").split(/[\s,;]+/)) {
673
+ const key = part.trim().replace(/^["']|["']$/g, "");
674
+ if (key && !keys.includes(key)) keys.push(key);
675
+ }
676
+ }
677
+ return keys;
678
+ }
679
+ function encryptionKeyId(masterKey) {
680
+ return import_crypto.default.createHash("sha256").update(masterKey).digest("hex").slice(0, KEY_ID_HEX_CHARS);
681
+ }
682
+ function stampedKeyId(ciphertext) {
683
+ if (!ciphertext.startsWith("v3:")) return null;
684
+ const parts = ciphertext.split(":");
685
+ return parts.length === 5 ? parts[1] : null;
686
+ }
687
+ function stampedPrefix(keyId) {
688
+ return `v3:${keyId}:`;
689
+ }
690
+ var FieldEncryptor = class _FieldEncryptor {
691
+ ring;
692
+ byId;
693
+ derived = /* @__PURE__ */ new Map();
694
+ format;
695
+ constructor(masterKey, options = {}) {
644
696
  if (!masterKey) {
645
697
  throw new MissingEncryptionKeyError();
646
698
  }
647
- this.masterKey = masterKey;
699
+ const ring = [{ id: encryptionKeyId(masterKey), masterKey }];
700
+ for (const key of options.previousKeys ?? []) {
701
+ if (!key || ring.some((k) => k.masterKey === key)) continue;
702
+ ring.push({ id: encryptionKeyId(key), masterKey: key });
703
+ }
704
+ this.ring = ring;
705
+ this.byId = new Map(ring.map((k) => [k.id, k]));
706
+ this.format = options.format ?? "v2";
707
+ }
708
+ /**
709
+ * Build the ring from the environment: `ENCRYPTION_KEY` plus
710
+ * `LEGACY_ENCRYPTION_KEYS` (comma separated) and the older
711
+ * `LEGACY_ENCRYPTION_KEY`; `ENCRYPTION_FORMAT` selects the envelope.
712
+ */
713
+ static fromEnv(env = process.env) {
714
+ const masterKey = env[ENCRYPTION_KEY_ENV];
715
+ if (!masterKey) {
716
+ throw new MissingEncryptionKeyError(`${ENCRYPTION_KEY_ENV} must be set`);
717
+ }
718
+ const format = env[ENCRYPTION_FORMAT_ENV]?.trim().toLowerCase();
719
+ if (format && format !== "v2" && format !== "v3") {
720
+ throw new Error(
721
+ `${ENCRYPTION_FORMAT_ENV} must be "v2" or "v3", got "${format}"`
722
+ );
723
+ }
724
+ return new _FieldEncryptor(masterKey, {
725
+ previousKeys: parseEncryptionKeyList(
726
+ env[LEGACY_ENCRYPTION_KEYS_ENV],
727
+ env[LEGACY_ENCRYPTION_KEY_ENV]
728
+ ),
729
+ format: format ?? "v2"
730
+ });
731
+ }
732
+ /** Fingerprints of the ring, current first. Safe to log. */
733
+ get keyIds() {
734
+ return {
735
+ current: this.ring[0].id,
736
+ previous: this.ring.slice(1).map((k) => k.id)
737
+ };
738
+ }
739
+ /** A copy of this encryptor with a different set of previous keys. */
740
+ withPreviousKeys(previousKeys) {
741
+ return new _FieldEncryptor(this.ring[0].masterKey, {
742
+ previousKeys,
743
+ format: this.format
744
+ });
745
+ }
746
+ /** A copy of this encryptor writing a different envelope. */
747
+ withFormat(format) {
748
+ return new _FieldEncryptor(this.ring[0].masterKey, {
749
+ previousKeys: this.ring.slice(1).map((k) => k.masterKey),
750
+ format
751
+ });
648
752
  }
649
753
  /**
650
- * Derive a per-tenant 32-byte key using HKDF-SHA256.
651
- * The master key is used as input key material and the tenantId as info context.
754
+ * Derive a per-tenant 32-byte key using HKDF-SHA256 from the CURRENT
755
+ * master key. The master key is the input key material and the tenantId
756
+ * the info context.
652
757
  */
653
758
  deriveKey(tenantId) {
654
- return Buffer.from(
655
- import_crypto.default.hkdfSync(HKDF_HASH, this.masterKey, HKDF_SALT, tenantId, KEY_BYTES)
759
+ return this.deriveKeyFor(this.ring[0], tenantId);
760
+ }
761
+ deriveKeyFor(key, tenantId) {
762
+ const cacheKey = `${key.id} ${tenantId}`;
763
+ const cached = this.derived.get(cacheKey);
764
+ if (cached) return cached;
765
+ const derived = Buffer.from(
766
+ import_crypto.default.hkdfSync(HKDF_HASH, key.masterKey, HKDF_SALT, tenantId, KEY_BYTES)
656
767
  );
768
+ if (this.derived.size >= DERIVED_KEY_CACHE_LIMIT) this.derived.clear();
769
+ this.derived.set(cacheKey, derived);
770
+ return derived;
657
771
  }
658
772
  /**
659
773
  * Derive a deterministic IV from the key and plaintext using HMAC-SHA256,
660
- * truncated to IV_BYTES. Same plaintext + same key → same IV → same
774
+ * truncated to IV_BYTES. Same plaintext + same key -> same IV -> same
661
775
  * ciphertext. This enables WHERE clause matching on encrypted columns
662
776
  * while maintaining AES-256-GCM authenticated encryption.
663
777
  *
@@ -678,39 +792,95 @@ var FieldEncryptor = class {
678
792
  cipher.final()
679
793
  ]);
680
794
  const authTag = cipher.getAuthTag();
681
- return [
682
- "v2",
795
+ const body = [
683
796
  iv.toString("base64"),
684
797
  authTag.toString("base64"),
685
798
  encrypted.toString("base64")
686
- ].join(":");
799
+ ];
800
+ return this.format === "v3" ? ["v3", this.ring[0].id, ...body].join(":") : ["v2", ...body].join(":");
687
801
  }
688
802
  decrypt(ciphertext, tenantId) {
689
803
  if (ciphertext === null || ciphertext === void 0) {
690
804
  return null;
691
805
  }
806
+ return this.open(ciphertext, tenantId ?? "").plaintext;
807
+ }
808
+ /**
809
+ * Decrypt and report which key opened the value and whether a rewrite
810
+ * would change it. Throws {@link DecryptionError} when no key in the
811
+ * ring does.
812
+ */
813
+ open(ciphertext, tenantId = "") {
692
814
  const parts = ciphertext.split(":");
693
- if (parts.length !== 4 || parts[0] !== "v1" && parts[0] !== "v2") {
815
+ const version = parts[0];
816
+ let candidates;
817
+ let body;
818
+ if (version === "v3" && parts.length === 5) {
819
+ const key = this.byId.get(parts[1]);
820
+ if (!key) {
821
+ throw new DecryptionError(
822
+ `Decryption failed: value is stamped with key ${parts[1]}, which is not in the ring (current ${this.ring[0].id}; previous ${this.ring.slice(1).map((k) => k.id).join(", ") || "none"}). Add it to ${LEGACY_ENCRYPTION_KEYS_ENV}.`
823
+ );
824
+ }
825
+ candidates = [key];
826
+ body = parts.slice(2);
827
+ } else if ((version === "v1" || version === "v2") && parts.length === 4) {
828
+ candidates = this.ring;
829
+ body = parts.slice(1);
830
+ } else {
694
831
  throw new DecryptionError(
695
832
  `Unknown ciphertext version or malformed format`
696
833
  );
697
834
  }
698
- const [, ivB64, authTagB64, encryptedB64] = parts;
835
+ const [ivB64, authTagB64, encryptedB64] = body;
699
836
  const iv = Buffer.from(ivB64, "base64");
700
837
  const authTag = Buffer.from(authTagB64, "base64");
701
838
  const encrypted = Buffer.from(encryptedB64, "base64");
702
- const key = this.deriveKey(tenantId ?? "");
703
- try {
704
- const decipher = import_crypto.default.createDecipheriv(ALGORITHM, key, iv);
705
- decipher.setAuthTag(authTag);
706
- const decrypted = Buffer.concat([
707
- decipher.update(encrypted),
708
- decipher.final()
709
- ]);
710
- return decrypted.toString("utf8");
711
- } catch {
712
- throw new DecryptionError();
839
+ for (const key of candidates) {
840
+ try {
841
+ const decipher = import_crypto.default.createDecipheriv(
842
+ ALGORITHM,
843
+ this.deriveKeyFor(key, tenantId),
844
+ iv
845
+ );
846
+ decipher.setAuthTag(authTag);
847
+ const decrypted = Buffer.concat([
848
+ decipher.update(encrypted),
849
+ decipher.final()
850
+ ]);
851
+ const current = key === this.ring[0];
852
+ return {
853
+ plaintext: decrypted.toString("utf8"),
854
+ keyId: key.id,
855
+ current,
856
+ version,
857
+ stale: !current || this.format === "v3" && version !== "v3"
858
+ };
859
+ } catch {
860
+ }
713
861
  }
862
+ throw new DecryptionError(
863
+ candidates.length > 1 ? `Decryption failed: none of the ${candidates.length} keys in the ring opens this value (ciphertext corrupted, wrong tenant, or the key it was written with is not in ${LEGACY_ENCRYPTION_KEYS_ENV})` : `Decryption failed: key ${candidates[0].id} does not open this value (ciphertext corrupted or wrong tenant)`
864
+ );
865
+ }
866
+ /**
867
+ * True when a rewrite would change the stored bytes: the value is under
868
+ * a previous key, or unstamped while this encryptor writes `v3`. Throws
869
+ * when no key opens it.
870
+ */
871
+ needsRotation(ciphertext, tenantId = "") {
872
+ return this.open(ciphertext, tenantId).stale;
873
+ }
874
+ /**
875
+ * Rewrite a value under the current key in the current format. Returns
876
+ * the input unchanged when nothing would change, so callers can compare
877
+ * to detect writes.
878
+ */
879
+ rotate(ciphertext, tenantId = "") {
880
+ if (ciphertext === null || ciphertext === void 0) return null;
881
+ const opened = this.open(ciphertext, tenantId);
882
+ if (!opened.stale) return ciphertext;
883
+ return this.encrypt(opened.plaintext, tenantId);
714
884
  }
715
885
  };
716
886
 
@@ -768,6 +938,201 @@ function resolveEntityName2(entityNameOrEntity) {
768
938
  return void 0;
769
939
  }
770
940
 
941
+ // src/persistence/keyRotation.ts
942
+ var ENCRYPTED_LEVELS4 = /* @__PURE__ */ new Set(["pii", "phi", "pci"]);
943
+ var isEncryptedValue = isEncryptedCiphertext;
944
+ var tryOpen = (encryptor, value, tenantId) => {
945
+ try {
946
+ return encryptor.open(value, tenantId);
947
+ } catch {
948
+ return null;
949
+ }
950
+ };
951
+ function classifyEncryptedValue(value, encryptor, tenantIds, fallbackTenantIds = []) {
952
+ if (!isEncryptedValue(value)) return { kind: "plaintext" };
953
+ const primary = tenantIds.length ? tenantIds : [""];
954
+ const candidates = [
955
+ ...primary,
956
+ ...fallbackTenantIds.filter((t) => !primary.includes(t))
957
+ ];
958
+ for (const tenantId of candidates) {
959
+ const opened = tryOpen(encryptor, value, tenantId);
960
+ if (!opened) continue;
961
+ if (!opened.stale) {
962
+ return { kind: "current", tenantId, keyId: opened.keyId };
963
+ }
964
+ const next = encryptor.encrypt(opened.plaintext, tenantId);
965
+ return next ? { kind: "rewritten", tenantId, keyId: opened.keyId, next } : { kind: "unreadable" };
966
+ }
967
+ return { kind: "unreadable" };
968
+ }
969
+ function defaultTenantIdsFor(_entityName, row) {
970
+ const candidates = [];
971
+ for (const key of ["organization_id", "organizationId"]) {
972
+ const v = row[key];
973
+ if (typeof v === "string" && v) candidates.push(v);
974
+ }
975
+ candidates.push("");
976
+ return [...new Set(candidates)];
977
+ }
978
+ var quote = (id) => `"${id.replace(/"/g, '""')}"`;
979
+ async function collectFallbackTenantIds(execute) {
980
+ const ids = /* @__PURE__ */ new Set();
981
+ const tables = await execute(
982
+ `select table_name from information_schema.columns where table_schema = current_schema() and column_name = 'organization_id'`
983
+ );
984
+ for (const { table_name } of tables) {
985
+ const rows = await execute(
986
+ `select distinct "organization_id" as id from ${quote(table_name)} where "organization_id" is not null`
987
+ );
988
+ for (const { id } of rows) if (typeof id === "string" && id) ids.add(id);
989
+ }
990
+ const org = await execute(`select to_regclass('"organization"') as t`);
991
+ if (org[0]?.t) {
992
+ const rows = await execute(`select "id" from "organization"`);
993
+ for (const { id } of rows) if (typeof id === "string" && id) ids.add(id);
994
+ }
995
+ return [...ids];
996
+ }
997
+ function entityMetadataList(em) {
998
+ const all = em.getMetadata().getAll();
999
+ if (all instanceof Map) return [...all.values()];
1000
+ if (Array.isArray(all)) return all;
1001
+ if (all && typeof all === "object")
1002
+ return Object.values(all);
1003
+ return [];
1004
+ }
1005
+ async function reencryptEncryptedColumns(options) {
1006
+ const { em, encryptor, dryRun = false, log = () => void 0 } = options;
1007
+ const tenantIdsFor = options.tenantIdsFor ?? defaultTenantIdsFor;
1008
+ const fallbackTenantIds = options.fallbackTenantIds ?? [];
1009
+ const connection = em.getConnection();
1010
+ const execute = options.execute ?? ((sql, params) => connection.execute(sql, params));
1011
+ const reports = [];
1012
+ const ids = encryptor.keyIds;
1013
+ log(
1014
+ `[key-rotation] current key ${ids.current}; previous: ${ids.previous.join(", ") || "none"}; ${fallbackTenantIds.length} fallback tenant(s)${dryRun ? "; DRY RUN" : ""}`
1015
+ );
1016
+ const metas = entityMetadataList(em);
1017
+ if (metas.length === 0) {
1018
+ throw new Error(
1019
+ "[key-rotation] MikroORM reported no entities; refusing to report a sweep over nothing"
1020
+ );
1021
+ }
1022
+ for (const meta of metas) {
1023
+ if (meta.abstract || meta.embeddable || !meta.tableName) continue;
1024
+ const fields = getEntityComplianceFields(meta.className);
1025
+ if (!fields) continue;
1026
+ const props = meta.properties;
1027
+ const encryptedProps = [...fields.entries()].filter(([, level]) => ENCRYPTED_LEVELS4.has(level)).map(([prop]) => props[prop]).filter(
1028
+ (p3) => !!p3 && Array.isArray(p3.fieldNames) && p3.fieldNames.length === 1
1029
+ );
1030
+ if (encryptedProps.length === 0) continue;
1031
+ const pkProp = props[meta.primaryKeys[0]];
1032
+ if (!pkProp) continue;
1033
+ const pkColumn = pkProp.fieldNames[0];
1034
+ const columns = encryptedProps.map((p3) => p3.fieldNames[0]);
1035
+ const report = {
1036
+ table: meta.tableName,
1037
+ columns,
1038
+ scanned: 0,
1039
+ plaintext: 0,
1040
+ current: 0,
1041
+ rewritten: 0,
1042
+ unreadable: 0,
1043
+ unreadableIds: [],
1044
+ byKeyId: {},
1045
+ missingKeyIds: {}
1046
+ };
1047
+ const exists = await execute(`select to_regclass(?) as t`, [
1048
+ quote(meta.tableName)
1049
+ ]);
1050
+ if (!exists[0]?.t) {
1051
+ log(
1052
+ `[key-rotation] ${meta.tableName}: table does not exist yet, skipped`
1053
+ );
1054
+ continue;
1055
+ }
1056
+ const rows = await execute(
1057
+ `select * from ${quote(meta.tableName)}`
1058
+ );
1059
+ for (const row of rows) {
1060
+ report.scanned += 1;
1061
+ const tenantIds = tenantIdsFor(meta.className, row);
1062
+ const updates = [];
1063
+ let rowUnreadable = false;
1064
+ for (const prop of encryptedProps) {
1065
+ const column = prop.fieldNames[0];
1066
+ const outcome = classifyEncryptedValue(
1067
+ row[column],
1068
+ encryptor,
1069
+ tenantIds,
1070
+ fallbackTenantIds
1071
+ );
1072
+ if (outcome.kind === "plaintext") report.plaintext += 1;
1073
+ else if (outcome.kind === "current") report.current += 1;
1074
+ else if (outcome.kind === "rewritten") {
1075
+ report.rewritten += 1;
1076
+ report.byKeyId[outcome.keyId] = (report.byKeyId[outcome.keyId] ?? 0) + 1;
1077
+ updates.push({
1078
+ column,
1079
+ next: outcome.next,
1080
+ jsonb: (prop.columnTypes?.[0] ?? "").toLowerCase().startsWith("json")
1081
+ });
1082
+ } else {
1083
+ report.unreadable += 1;
1084
+ rowUnreadable = true;
1085
+ const missing2 = stampedKeyId(String(row[column]));
1086
+ if (missing2)
1087
+ report.missingKeyIds[missing2] = (report.missingKeyIds[missing2] ?? 0) + 1;
1088
+ }
1089
+ }
1090
+ if (rowUnreadable) report.unreadableIds.push(String(row[pkColumn]));
1091
+ if (updates.length && !dryRun) {
1092
+ const sets = updates.map(
1093
+ (u) => `${quote(u.column)} = ${u.jsonb ? "to_jsonb(?::text)" : "?"}`
1094
+ );
1095
+ await execute(
1096
+ `update ${quote(meta.tableName)} set ${sets.join(", ")} where ${quote(pkColumn)} = ?`,
1097
+ [...updates.map((u) => u.next), row[pkColumn]]
1098
+ );
1099
+ }
1100
+ }
1101
+ const byKey = Object.entries(report.byKeyId).map(([k, n]) => `${k}: ${n}`).join(", ");
1102
+ const missing = Object.entries(report.missingKeyIds).map(([k, n]) => `${k}: ${n}`).join(", ");
1103
+ log(
1104
+ `[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 ? ", ..." : ""})` : ""}`
1105
+ );
1106
+ reports.push(report);
1107
+ }
1108
+ return reports;
1109
+ }
1110
+ async function countValuesByKeyId(execute, table, column) {
1111
+ const rows = await execute(
1112
+ `select case when ${quote(column)} like 'v3:%' then split_part(${quote(column)}, ':', 2) when ${quote(column)} like 'v1:%' or ${quote(column)} like 'v2:%' then '' else null end as key_id, count(*)::int as n from ${quote(table)} group by 1`
1113
+ );
1114
+ const byKeyId = {};
1115
+ let unstamped = 0;
1116
+ let plaintext = 0;
1117
+ for (const { key_id, n } of rows) {
1118
+ if (key_id === null) plaintext += Number(n);
1119
+ else if (key_id === "") unstamped += Number(n);
1120
+ else byKeyId[key_id] = (byKeyId[key_id] ?? 0) + Number(n);
1121
+ }
1122
+ return { byKeyId, unstamped, plaintext };
1123
+ }
1124
+ function rotationTotals(reports) {
1125
+ const sum = (pick) => reports.reduce((n, r) => n + pick(r), 0);
1126
+ return {
1127
+ tables: reports.length,
1128
+ scanned: sum((r) => r.scanned),
1129
+ rewritten: sum((r) => r.rewritten),
1130
+ current: sum((r) => r.current),
1131
+ plaintext: sum((r) => r.plaintext),
1132
+ unreadable: sum((r) => r.unreadable)
1133
+ };
1134
+ }
1135
+
771
1136
  // src/persistence/tenantFilter.ts
772
1137
  var TENANT_FILTER_NAME = "tenant";
773
1138
  function createTenantFilterDef(column = "organizationId", relationName) {
@@ -936,18 +1301,29 @@ function wrapEmWithTenantContext(em, tenantId) {
936
1301
  0 && (module.exports = {
937
1302
  ComplianceLevel,
938
1303
  DecryptionError,
1304
+ ENCRYPTED_PREFIXES,
1305
+ ENCRYPTION_FORMAT_ENV,
1306
+ ENCRYPTION_KEY_ENV,
939
1307
  EncryptedType,
940
1308
  EncryptionRequiredError,
941
1309
  FieldEncryptor,
1310
+ LEGACY_ENCRYPTION_KEYS_ENV,
1311
+ LEGACY_ENCRYPTION_KEY_ENV,
942
1312
  MissingEncryptionKeyError,
943
1313
  RetentionAction,
944
1314
  RetentionDuration,
945
1315
  RlsEventSubscriber,
946
1316
  TENANT_FILTER_NAME,
947
1317
  asEncryptionSafe,
1318
+ classifyEncryptedValue,
1319
+ collectFallbackTenantIds,
1320
+ countValuesByKeyId,
948
1321
  createTenantFilterDef,
1322
+ defaultTenantIdsFor,
949
1323
  defineComplianceEntity,
1324
+ encryptionKeyId,
950
1325
  entityHasEncryptedFields,
1326
+ entityMetadataList,
951
1327
  fp,
952
1328
  getAllRetentionPolicies,
953
1329
  getAllUserIdFields,
@@ -957,11 +1333,18 @@ function wrapEmWithTenantContext(em, tenantId) {
957
1333
  getEntityRetention,
958
1334
  getEntityUserIdField,
959
1335
  getSuperAdminContext,
1336
+ isEncryptedCiphertext,
1337
+ isEncryptedValue,
960
1338
  parseDuration,
1339
+ parseEncryptionKeyList,
1340
+ reencryptEncryptedColumns,
961
1341
  registerEncryptor,
1342
+ rotationTotals,
962
1343
  setEncryptionTenantId,
963
1344
  setupRls,
964
1345
  setupTenantFilter,
1346
+ stampedKeyId,
1347
+ stampedPrefix,
965
1348
  subtractDuration,
966
1349
  withEncryptionContext,
967
1350
  wrapEmWithForgivingDecryption,