@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
@@ -53,6 +53,7 @@ __export(persistence_exports, {
53
53
  RetentionDuration: () => RetentionDuration,
54
54
  RlsEventSubscriber: () => RlsEventSubscriber,
55
55
  TENANT_FILTER_NAME: () => TENANT_FILTER_NAME,
56
+ TenantMismatchError: () => TenantMismatchError,
56
57
  UnboundTenantError: () => UnboundTenantError,
57
58
  asEncryptionSafe: () => asEncryptionSafe,
58
59
  assertBindableTenantId: () => assertBindableTenantId,
@@ -81,6 +82,7 @@ __export(persistence_exports, {
81
82
  isEncryptedCiphertext: () => isEncryptedCiphertext,
82
83
  isEncryptedValue: () => isEncryptedValue,
83
84
  onComplianceAccess: () => onComplianceAccess,
85
+ onComplianceChange: () => onComplianceChange,
84
86
  parseDuration: () => parseDuration,
85
87
  parseEncryptionKeyList: () => parseEncryptionKeyList,
86
88
  reencryptEncryptedColumns: () => reencryptEncryptedColumns,
@@ -827,6 +829,22 @@ var LegacyCiphertextError = class extends Error {
827
829
  var CompliantQueryError = class extends Error {
828
830
  name = "CompliantQueryError";
829
831
  };
832
+ var TenantMismatchError = class extends Error {
833
+ constructor(path, operation, ownerTenantId, boundTenantId) {
834
+ super(
835
+ `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.`
836
+ );
837
+ this.path = path;
838
+ this.operation = operation;
839
+ this.ownerTenantId = ownerTenantId;
840
+ this.boundTenantId = boundTenantId;
841
+ }
842
+ path;
843
+ operation;
844
+ ownerTenantId;
845
+ boundTenantId;
846
+ name = "TenantMismatchError";
847
+ };
830
848
  var MINT = /* @__PURE__ */ Symbol.for("forklaunch.compliance.mint");
831
849
  var ANON_BRAND = /* @__PURE__ */ Symbol.for("forklaunch.compliance.anon");
832
850
  var Anon = class {
@@ -907,10 +925,11 @@ var CompliantField = class _CompliantField {
907
925
  Object.freeze(this);
908
926
  }
909
927
  /**
910
- * @internal Wrap a plaintext value for `spec`. Applications assign plain
911
- * values to the entity property instead.
928
+ * @internal Wrap a plaintext value for `spec`, owned by `tenant` (the one
929
+ * bound where it was assigned, if any). Applications assign plain values to
930
+ * the entity property instead.
912
931
  */
913
- static fromPlain(value, spec) {
932
+ static fromPlain(value, spec, tenant) {
914
933
  if (spec.enumValues) {
915
934
  const values = spec.isArray && Array.isArray(value) ? value : [value];
916
935
  for (const v of values) {
@@ -923,16 +942,22 @@ var CompliantField = class _CompliantField {
923
942
  }
924
943
  return new _CompliantField({
925
944
  spec,
945
+ tenant,
926
946
  hasPlain: true,
927
947
  plain: value,
928
948
  anon: computeAnon(spec.anon, value)
929
949
  });
930
950
  }
931
- /** @internal Wrap a stored value (any envelope, or legacy plaintext). */
932
- static fromStored(stored, spec, index) {
951
+ /**
952
+ * @internal Wrap a stored value (any envelope, or legacy plaintext) loaded
953
+ * under `tenant`: the tenant bound during hydration, which is the loading
954
+ * entity manager's.
955
+ */
956
+ static fromStored(stored, spec, index, tenant) {
933
957
  if (!isEncryptedCiphertext(stored)) {
934
958
  return new _CompliantField({
935
959
  spec,
960
+ tenant,
936
961
  hasPlain: true,
937
962
  plain: deserializeFromEncryption(
938
963
  stored,
@@ -944,6 +969,7 @@ var CompliantField = class _CompliantField {
944
969
  }
945
970
  return new _CompliantField({
946
971
  spec,
972
+ tenant,
947
973
  hasPlain: false,
948
974
  sealed: stored,
949
975
  anon: sealedAnon(stored) ?? void 0,
@@ -976,11 +1002,24 @@ var CompliantField = class _CompliantField {
976
1002
  return readPlaintext(this, true);
977
1003
  }
978
1004
  };
1005
+ function tenantOf(state, operation) {
1006
+ const bound = getBoundTenantId();
1007
+ if (state.tenant === void 0) return requireBoundTenantId(operation);
1008
+ if (bound !== void 0 && bound !== state.tenant) {
1009
+ throw new TenantMismatchError(
1010
+ state.spec.path,
1011
+ operation,
1012
+ state.tenant,
1013
+ bound
1014
+ );
1015
+ }
1016
+ return state.tenant;
1017
+ }
979
1018
  function readPlaintext(field, report) {
980
1019
  const state = stateOf(field);
981
1020
  let tenantId = "";
982
1021
  if (!state.hasPlain) {
983
- tenantId = requireBoundTenantId("read");
1022
+ tenantId = tenantOf(state, "read");
984
1023
  if (state.sealed === void 0) {
985
1024
  throw new Error(
986
1025
  `${state.spec.path} was not loaded: its ciphertext column was left out of the selection`
@@ -999,6 +1038,8 @@ function readPlaintext(field, report) {
999
1038
  state.spec.isArray
1000
1039
  );
1001
1040
  state.hasPlain = true;
1041
+ } else if (state.tenant !== void 0) {
1042
+ tenantId = tenantOf(state, "read");
1002
1043
  }
1003
1044
  if (report) {
1004
1045
  tenantId ||= getBoundTenantId() ?? "";
@@ -1026,10 +1067,11 @@ function sealedValueOf(field) {
1026
1067
  return state.sealed;
1027
1068
  }
1028
1069
  if (!state.hasPlain) return state.sealed;
1029
- const tenantId = requireBoundTenantId("write");
1030
- if (state.sealed !== void 0 && state.sealedTenant === tenantId) {
1070
+ if (state.sealed !== void 0 && state.sealedTenant === state.tenant) {
1031
1071
  return state.sealed;
1032
1072
  }
1073
+ const tenantId = tenantOf(state, "write");
1074
+ state.tenant = tenantId;
1033
1075
  if (state.anon === void 0) {
1034
1076
  state.anon = computeAnon(state.spec.anon, state.plain);
1035
1077
  }
@@ -1048,9 +1090,12 @@ function normalizedForIndex(value, spec) {
1048
1090
  return normalizeSerializedForIndex(serializeForEncryption(value), spec);
1049
1091
  }
1050
1092
  function blindIndexOfValue(value, spec) {
1093
+ return blindIndexUnder(value, spec, requireBoundTenantId("query"));
1094
+ }
1095
+ function blindIndexUnder(value, spec, tenantId) {
1051
1096
  return requireEncryptor(spec.path).blindIndex(
1052
1097
  normalizedForIndex(value, spec),
1053
- requireBoundTenantId("query")
1098
+ tenantId
1054
1099
  );
1055
1100
  }
1056
1101
  function blindIndexOf(field) {
@@ -1059,7 +1104,7 @@ function blindIndexOf(field) {
1059
1104
  if (!state.hasPlain && state.sealed === void 0) return state.index ?? null;
1060
1105
  try {
1061
1106
  const plain = state.hasPlain ? state.plain : readPlaintext(field, false);
1062
- state.index = blindIndexOfValue(plain, state.spec);
1107
+ state.index = blindIndexUnder(plain, state.spec, tenantOf(state, "index"));
1063
1108
  } catch {
1064
1109
  return state.index ?? null;
1065
1110
  }
@@ -1072,6 +1117,7 @@ function isPendingIndex(value) {
1072
1117
  function pendingIndexValue(value) {
1073
1118
  return value[PENDING];
1074
1119
  }
1120
+ var COMPLIANT_RUNTIME_TYPE = "CompliantField";
1075
1121
  var CompliantTypeBase = class extends import_core2.Type {
1076
1122
  constructor(spec) {
1077
1123
  super();
@@ -1082,7 +1128,7 @@ var CompliantTypeBase = class extends import_core2.Type {
1082
1128
  return "text";
1083
1129
  }
1084
1130
  get runtimeType() {
1085
- return "string";
1131
+ return COMPLIANT_RUNTIME_TYPE;
1086
1132
  }
1087
1133
  ensureComparable() {
1088
1134
  return false;
@@ -1093,7 +1139,7 @@ var CompliantTypeBase = class extends import_core2.Type {
1093
1139
  };
1094
1140
  var CompliantType = class extends CompliantTypeBase {
1095
1141
  convertToDatabaseValue(value, _platform, context) {
1096
- if (context?.fromQuery) {
1142
+ if (context?.fromQuery && context.mode !== "query-data") {
1097
1143
  throw new CompliantQueryError(
1098
1144
  `${this.spec.path} cannot be queried: declare it with .compliance('${this.spec.level}', { queryable: true }) to allow equality lookups`
1099
1145
  );
@@ -1101,18 +1147,30 @@ var CompliantType = class extends CompliantTypeBase {
1101
1147
  if (value === null || value === void 0) return null;
1102
1148
  if (isCompliantField(value)) return sealedValueOf(value) ?? null;
1103
1149
  if (isEncryptedCiphertext(value)) return value;
1104
- return sealedValueOf(CompliantField.fromPlain(value, this.spec)) ?? null;
1150
+ return sealedValueOf(
1151
+ CompliantField.fromPlain(value, this.spec, getBoundTenantId())
1152
+ ) ?? null;
1105
1153
  }
1106
1154
  convertToJSValue(value) {
1107
1155
  if (value === null || value === void 0) return value;
1108
1156
  if (isCompliantField(value)) return value;
1109
- return CompliantField.fromStored(String(value), this.spec);
1157
+ return CompliantField.fromStored(
1158
+ String(value),
1159
+ this.spec,
1160
+ void 0,
1161
+ getBoundTenantId()
1162
+ );
1110
1163
  }
1111
1164
  };
1112
1165
  var EQUALITY_OPERATORS = /* @__PURE__ */ new Set(["$eq", "$ne", "$in", "$nin"]);
1113
1166
  var CompliantIndexType = class extends CompliantTypeBase {
1114
1167
  convertToDatabaseValue(value, _platform, context) {
1115
1168
  if (value === null || value === void 0) return null;
1169
+ if (context?.mode === "query-data") {
1170
+ throw new CompliantQueryError(
1171
+ `${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.`
1172
+ );
1173
+ }
1116
1174
  if (context?.fromQuery) {
1117
1175
  const key = context.key;
1118
1176
  if (key !== void 0 && !EQUALITY_OPERATORS.has(String(key)) && !/^\d+$/.test(String(key))) {
@@ -1135,6 +1193,18 @@ function isCompliantType(type) {
1135
1193
  return type instanceof CompliantType || type instanceof CompliantIndexType;
1136
1194
  }
1137
1195
  var SLOTS = globalThis[/* @__PURE__ */ Symbol.for("forklaunch.compliance.slots")] ??= /* @__PURE__ */ new WeakMap();
1196
+ var LOADED_TENANTS = globalThis[/* @__PURE__ */ Symbol.for("forklaunch.compliance.loadedTenants")] ??= /* @__PURE__ */ new WeakMap();
1197
+ function loadedTenantOf(entity, binding) {
1198
+ return LOADED_TENANTS.get(entity)?.[binding.property];
1199
+ }
1200
+ function recordLoadedTenant(entity, binding) {
1201
+ let tenants = LOADED_TENANTS.get(entity);
1202
+ if (!tenants) {
1203
+ tenants = {};
1204
+ LOADED_TENANTS.set(entity, tenants);
1205
+ }
1206
+ tenants[binding.property] = getBoundTenantId();
1207
+ }
1138
1208
  function slotsOf(entity) {
1139
1209
  let slots = SLOTS.get(entity);
1140
1210
  if (!slots) {
@@ -1150,12 +1220,22 @@ function readProperty(entity, binding) {
1150
1220
  const stored = slots[binding.sibling];
1151
1221
  if (isPendingIndex(value)) {
1152
1222
  const index = pendingIndexValue(value);
1153
- const field = typeof stored === "string" ? CompliantField.fromStored(stored, binding.spec, index) : CompliantField.fromIndexOnly(index, binding.spec);
1223
+ const field = typeof stored === "string" ? CompliantField.fromStored(
1224
+ stored,
1225
+ binding.spec,
1226
+ index,
1227
+ loadedTenantOf(entity, binding)
1228
+ ) : CompliantField.fromIndexOnly(index, binding.spec);
1154
1229
  slots[binding.property] = field;
1155
1230
  return field;
1156
1231
  }
1157
1232
  if (value == null && typeof stored === "string") {
1158
- const field = CompliantField.fromStored(stored, binding.spec, null);
1233
+ const field = CompliantField.fromStored(
1234
+ stored,
1235
+ binding.spec,
1236
+ null,
1237
+ loadedTenantOf(entity, binding)
1238
+ );
1159
1239
  slots[binding.property] = field;
1160
1240
  return field;
1161
1241
  }
@@ -1177,10 +1257,18 @@ function writeProperty(entity, binding, value) {
1177
1257
  return;
1178
1258
  }
1179
1259
  if (isCompliantField(value)) {
1180
- slots[binding.property] = sameRules(stateOf(value).spec, binding.spec) ? value : CompliantField.fromPlain(readPlaintext(value, false), binding.spec);
1260
+ slots[binding.property] = sameRules(stateOf(value).spec, binding.spec) ? value : CompliantField.fromPlain(
1261
+ readPlaintext(value, false),
1262
+ binding.spec,
1263
+ stateOf(value).tenant ?? getBoundTenantId()
1264
+ );
1181
1265
  return;
1182
1266
  }
1183
- slots[binding.property] = CompliantField.fromPlain(value, binding.spec);
1267
+ slots[binding.property] = CompliantField.fromPlain(
1268
+ value,
1269
+ binding.spec,
1270
+ getBoundTenantId()
1271
+ );
1184
1272
  }
1185
1273
  function installCompliantAccessors(prototype, bindings) {
1186
1274
  installInspect(prototype, bindings);
@@ -1207,6 +1295,7 @@ function installCompliantAccessors(prototype, bindings) {
1207
1295
  return sealedValueOf(field) ?? stored;
1208
1296
  },
1209
1297
  set(value) {
1298
+ recordLoadedTenant(this, binding);
1210
1299
  slotsOf(this)[sibling] = value;
1211
1300
  }
1212
1301
  });
@@ -1315,7 +1404,7 @@ function wrapClassified(builder, level, complianceOptions) {
1315
1404
  };
1316
1405
  options.type = spec.queryable ? new CompliantIndexType(spec) : new CompliantType(spec);
1317
1406
  options.columnType = "text";
1318
- options.runtimeType = "string";
1407
+ options.runtimeType = COMPLIANT_RUNTIME_TYPE;
1319
1408
  options.accessor = true;
1320
1409
  }
1321
1410
  }
@@ -1419,6 +1508,47 @@ var fp = new Proxy(import_core3.p, {
1419
1508
 
1420
1509
  // src/persistence/defineComplianceEntity.ts
1421
1510
  var import_core4 = require("@mikro-orm/core");
1511
+
1512
+ // src/persistence/complianceChanges.ts
1513
+ var LISTENERS2 = globalThis[/* @__PURE__ */ Symbol.for("forklaunch.compliance.changeListeners")] ??= /* @__PURE__ */ new Set();
1514
+ function onComplianceChange(listener) {
1515
+ LISTENERS2.add(listener);
1516
+ return () => LISTENERS2.delete(listener);
1517
+ }
1518
+ function emit(event) {
1519
+ for (const listener of LISTENERS2) {
1520
+ try {
1521
+ listener(event);
1522
+ } catch (error) {
1523
+ console.error("Compliance change listener failed:", error);
1524
+ }
1525
+ }
1526
+ }
1527
+ function installComplianceChangeHooks(schema, entityName, levels) {
1528
+ const sensitive = new Set(
1529
+ [...levels].filter(([, level]) => level !== "none").map(([name]) => name)
1530
+ );
1531
+ if (!sensitive.size) return;
1532
+ const report = (action) => (args) => {
1533
+ if (!LISTENERS2.size) return;
1534
+ const entity = args.entity;
1535
+ const keys = args.meta?.primaryKeys ?? ["id"];
1536
+ const fields = action === "update" ? Object.keys(args.changeSet?.payload ?? {}) : action === "create" ? [...levels.keys()].filter((name) => entity[name] !== void 0) : [];
1537
+ emit({
1538
+ entity: entityName,
1539
+ id: keys.map((key) => String(entity[key] ?? "")).join(":"),
1540
+ action,
1541
+ fields,
1542
+ sensitiveFields: action === "delete" ? [...sensitive] : fields.filter((name) => sensitive.has(name)),
1543
+ tenantId: getBoundTenantId() ?? null
1544
+ });
1545
+ };
1546
+ schema.addHook("afterCreate", report("create"));
1547
+ schema.addHook("afterUpdate", report("update"));
1548
+ schema.addHook("afterDelete", report("delete"));
1549
+ }
1550
+
1551
+ // src/persistence/defineComplianceEntity.ts
1422
1552
  var naming = new import_core4.UnderscoreNamingStrategy();
1423
1553
  function wireCompliantProperties(entityName, properties) {
1424
1554
  const wired = {};
@@ -1498,6 +1628,11 @@ function defineComplianceEntity(meta) {
1498
1628
  ...meta,
1499
1629
  properties: wired.properties
1500
1630
  });
1631
+ installComplianceChangeHooks(
1632
+ schema,
1633
+ entityName,
1634
+ complianceFields
1635
+ );
1501
1636
  if (wired.bindings.length > 0) {
1502
1637
  const entityClass = schema.meta.class;
1503
1638
  if (entityClass)
@@ -2080,6 +2215,7 @@ function rebind(result, target, proxy) {
2080
2215
  RetentionDuration,
2081
2216
  RlsEventSubscriber,
2082
2217
  TENANT_FILTER_NAME,
2218
+ TenantMismatchError,
2083
2219
  UnboundTenantError,
2084
2220
  asEncryptionSafe,
2085
2221
  assertBindableTenantId,
@@ -2108,6 +2244,7 @@ function rebind(result, target, proxy) {
2108
2244
  isEncryptedCiphertext,
2109
2245
  isEncryptedValue,
2110
2246
  onComplianceAccess,
2247
+ onComplianceChange,
2111
2248
  parseDuration,
2112
2249
  parseEncryptionKeyList,
2113
2250
  reencryptEncryptedColumns,