@forklaunch/core 3.1.0 → 3.2.1

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 (36) hide show
  1. package/bin/gateway-mock/routes/email.mjs +91 -4
  2. package/lib/http/emailClient.d.ts +48 -1
  3. package/lib/http/emailClient.d.ts.map +1 -1
  4. package/lib/http/index.d.ts +1 -0
  5. package/lib/http/index.d.ts.map +1 -1
  6. package/lib/http/index.js +307 -96
  7. package/lib/http/index.js.map +1 -1
  8. package/lib/http/index.mjs +266 -58
  9. package/lib/http/index.mjs.map +1 -1
  10. package/lib/http/instanceGateway.d.ts +10 -6
  11. package/lib/http/instanceGateway.d.ts.map +1 -1
  12. package/lib/http/middleware/request/enrichDetails.middleware.d.ts.map +1 -1
  13. package/lib/http/modelGatewayClient.d.ts +13 -12
  14. package/lib/http/modelGatewayClient.d.ts.map +1 -1
  15. package/lib/http/smsClient.d.ts +2 -2
  16. package/lib/http/smsClient.d.ts.map +1 -1
  17. package/lib/http/telemetry/auditLogger.d.ts +5 -1
  18. package/lib/http/telemetry/auditLogger.d.ts.map +1 -1
  19. package/lib/http/telemetry/requestAudit.d.ts +30 -0
  20. package/lib/http/telemetry/requestAudit.d.ts.map +1 -0
  21. package/lib/http/voiceClient.d.ts +4 -4
  22. package/lib/http/voiceClient.d.ts.map +1 -1
  23. package/lib/http/whatsappClient.d.ts +5 -5
  24. package/lib/persistence/complianceChanges.d.ts +30 -0
  25. package/lib/persistence/complianceChanges.d.ts.map +1 -0
  26. package/lib/persistence/compliancePropertyBuilder.d.ts.map +1 -1
  27. package/lib/persistence/compliantField.d.ts +41 -8
  28. package/lib/persistence/compliantField.d.ts.map +1 -1
  29. package/lib/persistence/defineComplianceEntity.d.ts.map +1 -1
  30. package/lib/persistence/index.d.ts +2 -1
  31. package/lib/persistence/index.d.ts.map +1 -1
  32. package/lib/persistence/index.js +156 -19
  33. package/lib/persistence/index.js.map +1 -1
  34. package/lib/persistence/index.mjs +154 -19
  35. package/lib/persistence/index.mjs.map +1 -1
  36. package/package.json +10 -10
@@ -727,6 +727,22 @@ var LegacyCiphertextError = class extends Error {
727
727
  var CompliantQueryError = class extends Error {
728
728
  name = "CompliantQueryError";
729
729
  };
730
+ var TenantMismatchError = class extends Error {
731
+ constructor(path, operation, ownerTenantId, boundTenantId) {
732
+ super(
733
+ `Cannot ${operation} ${path || "a compliant field"} under tenant '${boundTenantId}': it belongs to tenant '${ownerTenantId}', the tenant it was loaded or assigned under. Read it outside any encryption context, or inside the owning tenant's.`
734
+ );
735
+ this.path = path;
736
+ this.operation = operation;
737
+ this.ownerTenantId = ownerTenantId;
738
+ this.boundTenantId = boundTenantId;
739
+ }
740
+ path;
741
+ operation;
742
+ ownerTenantId;
743
+ boundTenantId;
744
+ name = "TenantMismatchError";
745
+ };
730
746
  var MINT = /* @__PURE__ */ Symbol.for("forklaunch.compliance.mint");
731
747
  var ANON_BRAND = /* @__PURE__ */ Symbol.for("forklaunch.compliance.anon");
732
748
  var Anon = class {
@@ -807,10 +823,11 @@ var CompliantField = class _CompliantField {
807
823
  Object.freeze(this);
808
824
  }
809
825
  /**
810
- * @internal Wrap a plaintext value for `spec`. Applications assign plain
811
- * values to the entity property instead.
826
+ * @internal Wrap a plaintext value for `spec`, owned by `tenant` (the one
827
+ * bound where it was assigned, if any). Applications assign plain values to
828
+ * the entity property instead.
812
829
  */
813
- static fromPlain(value, spec) {
830
+ static fromPlain(value, spec, tenant) {
814
831
  if (spec.enumValues) {
815
832
  const values = spec.isArray && Array.isArray(value) ? value : [value];
816
833
  for (const v of values) {
@@ -823,16 +840,22 @@ var CompliantField = class _CompliantField {
823
840
  }
824
841
  return new _CompliantField({
825
842
  spec,
843
+ tenant,
826
844
  hasPlain: true,
827
845
  plain: value,
828
846
  anon: computeAnon(spec.anon, value)
829
847
  });
830
848
  }
831
- /** @internal Wrap a stored value (any envelope, or legacy plaintext). */
832
- static fromStored(stored, spec, index) {
849
+ /**
850
+ * @internal Wrap a stored value (any envelope, or legacy plaintext) loaded
851
+ * under `tenant`: the tenant bound during hydration, which is the loading
852
+ * entity manager's.
853
+ */
854
+ static fromStored(stored, spec, index, tenant) {
833
855
  if (!isEncryptedCiphertext(stored)) {
834
856
  return new _CompliantField({
835
857
  spec,
858
+ tenant,
836
859
  hasPlain: true,
837
860
  plain: deserializeFromEncryption(
838
861
  stored,
@@ -844,6 +867,7 @@ var CompliantField = class _CompliantField {
844
867
  }
845
868
  return new _CompliantField({
846
869
  spec,
870
+ tenant,
847
871
  hasPlain: false,
848
872
  sealed: stored,
849
873
  anon: sealedAnon(stored) ?? void 0,
@@ -876,11 +900,24 @@ var CompliantField = class _CompliantField {
876
900
  return readPlaintext(this, true);
877
901
  }
878
902
  };
903
+ function tenantOf(state, operation) {
904
+ const bound = getBoundTenantId();
905
+ if (state.tenant === void 0) return requireBoundTenantId(operation);
906
+ if (bound !== void 0 && bound !== state.tenant) {
907
+ throw new TenantMismatchError(
908
+ state.spec.path,
909
+ operation,
910
+ state.tenant,
911
+ bound
912
+ );
913
+ }
914
+ return state.tenant;
915
+ }
879
916
  function readPlaintext(field, report) {
880
917
  const state = stateOf(field);
881
918
  let tenantId = "";
882
919
  if (!state.hasPlain) {
883
- tenantId = requireBoundTenantId("read");
920
+ tenantId = tenantOf(state, "read");
884
921
  if (state.sealed === void 0) {
885
922
  throw new Error(
886
923
  `${state.spec.path} was not loaded: its ciphertext column was left out of the selection`
@@ -899,6 +936,8 @@ function readPlaintext(field, report) {
899
936
  state.spec.isArray
900
937
  );
901
938
  state.hasPlain = true;
939
+ } else if (state.tenant !== void 0) {
940
+ tenantId = tenantOf(state, "read");
902
941
  }
903
942
  if (report) {
904
943
  tenantId ||= getBoundTenantId() ?? "";
@@ -926,10 +965,11 @@ function sealedValueOf(field) {
926
965
  return state.sealed;
927
966
  }
928
967
  if (!state.hasPlain) return state.sealed;
929
- const tenantId = requireBoundTenantId("write");
930
- if (state.sealed !== void 0 && state.sealedTenant === tenantId) {
968
+ if (state.sealed !== void 0 && state.sealedTenant === state.tenant) {
931
969
  return state.sealed;
932
970
  }
971
+ const tenantId = tenantOf(state, "write");
972
+ state.tenant = tenantId;
933
973
  if (state.anon === void 0) {
934
974
  state.anon = computeAnon(state.spec.anon, state.plain);
935
975
  }
@@ -948,9 +988,12 @@ function normalizedForIndex(value, spec) {
948
988
  return normalizeSerializedForIndex(serializeForEncryption(value), spec);
949
989
  }
950
990
  function blindIndexOfValue(value, spec) {
991
+ return blindIndexUnder(value, spec, requireBoundTenantId("query"));
992
+ }
993
+ function blindIndexUnder(value, spec, tenantId) {
951
994
  return requireEncryptor(spec.path).blindIndex(
952
995
  normalizedForIndex(value, spec),
953
- requireBoundTenantId("query")
996
+ tenantId
954
997
  );
955
998
  }
956
999
  function blindIndexOf(field) {
@@ -959,7 +1002,7 @@ function blindIndexOf(field) {
959
1002
  if (!state.hasPlain && state.sealed === void 0) return state.index ?? null;
960
1003
  try {
961
1004
  const plain = state.hasPlain ? state.plain : readPlaintext(field, false);
962
- state.index = blindIndexOfValue(plain, state.spec);
1005
+ state.index = blindIndexUnder(plain, state.spec, tenantOf(state, "index"));
963
1006
  } catch {
964
1007
  return state.index ?? null;
965
1008
  }
@@ -972,6 +1015,7 @@ function isPendingIndex(value) {
972
1015
  function pendingIndexValue(value) {
973
1016
  return value[PENDING];
974
1017
  }
1018
+ var COMPLIANT_RUNTIME_TYPE = "CompliantField";
975
1019
  var CompliantTypeBase = class extends Type2 {
976
1020
  constructor(spec) {
977
1021
  super();
@@ -982,7 +1026,7 @@ var CompliantTypeBase = class extends Type2 {
982
1026
  return "text";
983
1027
  }
984
1028
  get runtimeType() {
985
- return "string";
1029
+ return COMPLIANT_RUNTIME_TYPE;
986
1030
  }
987
1031
  ensureComparable() {
988
1032
  return false;
@@ -993,7 +1037,7 @@ var CompliantTypeBase = class extends Type2 {
993
1037
  };
994
1038
  var CompliantType = class extends CompliantTypeBase {
995
1039
  convertToDatabaseValue(value, _platform, context) {
996
- if (context?.fromQuery) {
1040
+ if (context?.fromQuery && context.mode !== "query-data") {
997
1041
  throw new CompliantQueryError(
998
1042
  `${this.spec.path} cannot be queried: declare it with .compliance('${this.spec.level}', { queryable: true }) to allow equality lookups`
999
1043
  );
@@ -1001,18 +1045,30 @@ var CompliantType = class extends CompliantTypeBase {
1001
1045
  if (value === null || value === void 0) return null;
1002
1046
  if (isCompliantField(value)) return sealedValueOf(value) ?? null;
1003
1047
  if (isEncryptedCiphertext(value)) return value;
1004
- return sealedValueOf(CompliantField.fromPlain(value, this.spec)) ?? null;
1048
+ return sealedValueOf(
1049
+ CompliantField.fromPlain(value, this.spec, getBoundTenantId())
1050
+ ) ?? null;
1005
1051
  }
1006
1052
  convertToJSValue(value) {
1007
1053
  if (value === null || value === void 0) return value;
1008
1054
  if (isCompliantField(value)) return value;
1009
- return CompliantField.fromStored(String(value), this.spec);
1055
+ return CompliantField.fromStored(
1056
+ String(value),
1057
+ this.spec,
1058
+ void 0,
1059
+ getBoundTenantId()
1060
+ );
1010
1061
  }
1011
1062
  };
1012
1063
  var EQUALITY_OPERATORS = /* @__PURE__ */ new Set(["$eq", "$ne", "$in", "$nin"]);
1013
1064
  var CompliantIndexType = class extends CompliantTypeBase {
1014
1065
  convertToDatabaseValue(value, _platform, context) {
1015
1066
  if (value === null || value === void 0) return null;
1067
+ if (context?.mode === "query-data") {
1068
+ throw new CompliantQueryError(
1069
+ `${this.spec.path} is queryable, so a bulk update (nativeUpdate, qb.update) cannot set it: its sealed value lives in a second column the update does not write. Load the entity, assign the value and flush.`
1070
+ );
1071
+ }
1016
1072
  if (context?.fromQuery) {
1017
1073
  const key = context.key;
1018
1074
  if (key !== void 0 && !EQUALITY_OPERATORS.has(String(key)) && !/^\d+$/.test(String(key))) {
@@ -1035,6 +1091,18 @@ function isCompliantType(type) {
1035
1091
  return type instanceof CompliantType || type instanceof CompliantIndexType;
1036
1092
  }
1037
1093
  var SLOTS = globalThis[/* @__PURE__ */ Symbol.for("forklaunch.compliance.slots")] ??= /* @__PURE__ */ new WeakMap();
1094
+ var LOADED_TENANTS = globalThis[/* @__PURE__ */ Symbol.for("forklaunch.compliance.loadedTenants")] ??= /* @__PURE__ */ new WeakMap();
1095
+ function loadedTenantOf(entity, binding) {
1096
+ return LOADED_TENANTS.get(entity)?.[binding.property];
1097
+ }
1098
+ function recordLoadedTenant(entity, binding) {
1099
+ let tenants = LOADED_TENANTS.get(entity);
1100
+ if (!tenants) {
1101
+ tenants = {};
1102
+ LOADED_TENANTS.set(entity, tenants);
1103
+ }
1104
+ tenants[binding.property] = getBoundTenantId();
1105
+ }
1038
1106
  function slotsOf(entity) {
1039
1107
  let slots = SLOTS.get(entity);
1040
1108
  if (!slots) {
@@ -1050,12 +1118,22 @@ function readProperty(entity, binding) {
1050
1118
  const stored = slots[binding.sibling];
1051
1119
  if (isPendingIndex(value)) {
1052
1120
  const index = pendingIndexValue(value);
1053
- const field = typeof stored === "string" ? CompliantField.fromStored(stored, binding.spec, index) : CompliantField.fromIndexOnly(index, binding.spec);
1121
+ const field = typeof stored === "string" ? CompliantField.fromStored(
1122
+ stored,
1123
+ binding.spec,
1124
+ index,
1125
+ loadedTenantOf(entity, binding)
1126
+ ) : CompliantField.fromIndexOnly(index, binding.spec);
1054
1127
  slots[binding.property] = field;
1055
1128
  return field;
1056
1129
  }
1057
1130
  if (value == null && typeof stored === "string") {
1058
- const field = CompliantField.fromStored(stored, binding.spec, null);
1131
+ const field = CompliantField.fromStored(
1132
+ stored,
1133
+ binding.spec,
1134
+ null,
1135
+ loadedTenantOf(entity, binding)
1136
+ );
1059
1137
  slots[binding.property] = field;
1060
1138
  return field;
1061
1139
  }
@@ -1077,10 +1155,18 @@ function writeProperty(entity, binding, value) {
1077
1155
  return;
1078
1156
  }
1079
1157
  if (isCompliantField(value)) {
1080
- slots[binding.property] = sameRules(stateOf(value).spec, binding.spec) ? value : CompliantField.fromPlain(readPlaintext(value, false), binding.spec);
1158
+ slots[binding.property] = sameRules(stateOf(value).spec, binding.spec) ? value : CompliantField.fromPlain(
1159
+ readPlaintext(value, false),
1160
+ binding.spec,
1161
+ stateOf(value).tenant ?? getBoundTenantId()
1162
+ );
1081
1163
  return;
1082
1164
  }
1083
- slots[binding.property] = CompliantField.fromPlain(value, binding.spec);
1165
+ slots[binding.property] = CompliantField.fromPlain(
1166
+ value,
1167
+ binding.spec,
1168
+ getBoundTenantId()
1169
+ );
1084
1170
  }
1085
1171
  function installCompliantAccessors(prototype, bindings) {
1086
1172
  installInspect(prototype, bindings);
@@ -1107,6 +1193,7 @@ function installCompliantAccessors(prototype, bindings) {
1107
1193
  return sealedValueOf(field) ?? stored;
1108
1194
  },
1109
1195
  set(value) {
1196
+ recordLoadedTenant(this, binding);
1110
1197
  slotsOf(this)[sibling] = value;
1111
1198
  }
1112
1199
  });
@@ -1215,7 +1302,7 @@ function wrapClassified(builder, level, complianceOptions) {
1215
1302
  };
1216
1303
  options.type = spec.queryable ? new CompliantIndexType(spec) : new CompliantType(spec);
1217
1304
  options.columnType = "text";
1218
- options.runtimeType = "string";
1305
+ options.runtimeType = COMPLIANT_RUNTIME_TYPE;
1219
1306
  options.accessor = true;
1220
1307
  }
1221
1308
  }
@@ -1323,6 +1410,47 @@ import {
1323
1410
  p as p2,
1324
1411
  UnderscoreNamingStrategy
1325
1412
  } from "@mikro-orm/core";
1413
+
1414
+ // src/persistence/complianceChanges.ts
1415
+ var LISTENERS2 = globalThis[/* @__PURE__ */ Symbol.for("forklaunch.compliance.changeListeners")] ??= /* @__PURE__ */ new Set();
1416
+ function onComplianceChange(listener) {
1417
+ LISTENERS2.add(listener);
1418
+ return () => LISTENERS2.delete(listener);
1419
+ }
1420
+ function emit(event) {
1421
+ for (const listener of LISTENERS2) {
1422
+ try {
1423
+ listener(event);
1424
+ } catch (error) {
1425
+ console.error("Compliance change listener failed:", error);
1426
+ }
1427
+ }
1428
+ }
1429
+ function installComplianceChangeHooks(schema, entityName, levels) {
1430
+ const sensitive = new Set(
1431
+ [...levels].filter(([, level]) => level !== "none").map(([name]) => name)
1432
+ );
1433
+ if (!sensitive.size) return;
1434
+ const report = (action) => (args) => {
1435
+ if (!LISTENERS2.size) return;
1436
+ const entity = args.entity;
1437
+ const keys = args.meta?.primaryKeys ?? ["id"];
1438
+ const fields = action === "update" ? Object.keys(args.changeSet?.payload ?? {}) : action === "create" ? [...levels.keys()].filter((name) => entity[name] !== void 0) : [];
1439
+ emit({
1440
+ entity: entityName,
1441
+ id: keys.map((key) => String(entity[key] ?? "")).join(":"),
1442
+ action,
1443
+ fields,
1444
+ sensitiveFields: action === "delete" ? [...sensitive] : fields.filter((name) => sensitive.has(name)),
1445
+ tenantId: getBoundTenantId() ?? null
1446
+ });
1447
+ };
1448
+ schema.addHook("afterCreate", report("create"));
1449
+ schema.addHook("afterUpdate", report("update"));
1450
+ schema.addHook("afterDelete", report("delete"));
1451
+ }
1452
+
1453
+ // src/persistence/defineComplianceEntity.ts
1326
1454
  var naming = new UnderscoreNamingStrategy();
1327
1455
  function wireCompliantProperties(entityName, properties) {
1328
1456
  const wired = {};
@@ -1402,6 +1530,11 @@ function defineComplianceEntity(meta) {
1402
1530
  ...meta,
1403
1531
  properties: wired.properties
1404
1532
  });
1533
+ installComplianceChangeHooks(
1534
+ schema,
1535
+ entityName,
1536
+ complianceFields
1537
+ );
1405
1538
  if (wired.bindings.length > 0) {
1406
1539
  const entityClass = schema.meta.class;
1407
1540
  if (entityClass)
@@ -1983,6 +2116,7 @@ export {
1983
2116
  RetentionDuration,
1984
2117
  RlsEventSubscriber,
1985
2118
  TENANT_FILTER_NAME,
2119
+ TenantMismatchError,
1986
2120
  UnboundTenantError,
1987
2121
  asEncryptionSafe,
1988
2122
  assertBindableTenantId,
@@ -2011,6 +2145,7 @@ export {
2011
2145
  isEncryptedCiphertext,
2012
2146
  isEncryptedValue,
2013
2147
  onComplianceAccess,
2148
+ onComplianceChange,
2014
2149
  parseDuration,
2015
2150
  parseEncryptionKeyList,
2016
2151
  reencryptEncryptedColumns,