@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
package/lib/http/index.js CHANGED
@@ -63,6 +63,9 @@ __export(http_exports, {
63
63
  VoiceClient: () => VoiceClient,
64
64
  VoiceRequestError: () => VoiceRequestError,
65
65
  WhatsAppRequestError: () => WhatsAppRequestError,
66
+ auditComplianceAccess: () => auditComplianceAccess,
67
+ auditEnabled: () => auditEnabled,
68
+ auditEventType: () => auditEventType,
66
69
  createContext: () => createContext,
67
70
  createEmailClient: () => createEmailClient,
68
71
  createHmacToken: () => createHmacToken,
@@ -115,6 +118,7 @@ __export(http_exports, {
115
118
  post: () => post,
116
119
  put: () => put,
117
120
  recordMetric: () => recordMetric,
121
+ recordRequestAudit: () => recordRequestAudit,
118
122
  resetProcessHandlersForTesting: () => resetProcessHandlersForTesting,
119
123
  signPlatformEvent: () => signPlatformEvent,
120
124
  smsSegments: () => smsSegments,
@@ -123,6 +127,7 @@ __export(http_exports, {
123
127
  typedAuthHandler: () => typedAuthHandler,
124
128
  typedHandler: () => typedHandler,
125
129
  validateEmailSendRequest: () => validateEmailSendRequest,
130
+ validateListUnsubscribe: () => validateListUnsubscribe,
126
131
  validateSmsSend: () => validateSmsSend,
127
132
  verifyPlatformEvent: () => verifyPlatformEvent,
128
133
  voiceRequestProblem: () => voiceRequestProblem
@@ -151,7 +156,7 @@ function cors(corsOptions) {
151
156
  }
152
157
 
153
158
  // src/http/router/expressLikeRouter.ts
154
- var import_common11 = require("@forklaunch/common");
159
+ var import_common12 = require("@forklaunch/common");
155
160
 
156
161
  // src/http/guards/hasPermissionChecks.ts
157
162
  function hasPermissionChecks(maybePermissionedAuth) {
@@ -721,7 +726,7 @@ function discriminateResponseBodies(schemaValidator, responses) {
721
726
  }
722
727
 
723
728
  // src/http/router/routerSharedLogic.ts
724
- var import_common10 = require("@forklaunch/common");
729
+ var import_common11 = require("@forklaunch/common");
725
730
 
726
731
  // src/http/guards/hasVersionedSchema.ts
727
732
  function hasVersionedSchema(contractDetails) {
@@ -800,8 +805,8 @@ async function getCachedJwks(jwksPublicKeyUrl) {
800
805
  return cachedJwks.value;
801
806
  } else {
802
807
  const jwksResponse = await fetch(jwksPublicKeyUrl);
803
- const text = await jwksResponse.text();
804
- if (!text) {
808
+ const text2 = await jwksResponse.text();
809
+ if (!text2) {
805
810
  if (cachedJwks.value) {
806
811
  return cachedJwks.value;
807
812
  }
@@ -809,7 +814,7 @@ async function getCachedJwks(jwksPublicKeyUrl) {
809
814
  `JWKS endpoint returned empty response (status ${jwksResponse.status})`
810
815
  );
811
816
  }
812
- const jwks = JSON.parse(text).keys;
817
+ const jwks = JSON.parse(text2).keys;
813
818
  cachedJwks.value = jwks;
814
819
  cachedJwks.lastUpdated = /* @__PURE__ */ new Date();
815
820
  cachedJwks.ttl = parseInt(
@@ -1271,7 +1276,164 @@ async function parseRequestAuth(req, res, next) {
1271
1276
  }
1272
1277
 
1273
1278
  // src/http/middleware/request/enrichDetails.middleware.ts
1279
+ var import_common9 = require("@forklaunch/common");
1280
+
1281
+ // src/http/telemetry/requestAudit.ts
1274
1282
  var import_common8 = require("@forklaunch/common");
1283
+
1284
+ // src/persistence/encryptedType.ts
1285
+ var import_core = require("@mikro-orm/core");
1286
+ var import_node_async_hooks = require("async_hooks");
1287
+ var _tenantContext = new import_node_async_hooks.AsyncLocalStorage();
1288
+
1289
+ // src/persistence/complianceChanges.ts
1290
+ var LISTENERS = globalThis[/* @__PURE__ */ Symbol.for("forklaunch.compliance.changeListeners")] ??= /* @__PURE__ */ new Set();
1291
+ function onComplianceChange(listener) {
1292
+ LISTENERS.add(listener);
1293
+ return () => LISTENERS.delete(listener);
1294
+ }
1295
+
1296
+ // src/persistence/compliantField.ts
1297
+ var import_core2 = require("@mikro-orm/core");
1298
+
1299
+ // src/persistence/fieldEncryptor.ts
1300
+ var HKDF_SALT = Buffer.alloc(0);
1301
+
1302
+ // src/persistence/compliantField.ts
1303
+ var LISTENERS2 = globalThis[/* @__PURE__ */ Symbol.for("forklaunch.compliance.listeners")] ??= /* @__PURE__ */ new Set();
1304
+ function onComplianceAccess(listener) {
1305
+ LISTENERS2.add(listener);
1306
+ return () => LISTENERS2.delete(listener);
1307
+ }
1308
+ var STATE = globalThis[/* @__PURE__ */ Symbol.for("forklaunch.compliance.state")] ??= /* @__PURE__ */ new WeakMap();
1309
+ var SLOTS = globalThis[/* @__PURE__ */ Symbol.for("forklaunch.compliance.slots")] ??= /* @__PURE__ */ new WeakMap();
1310
+ var LOADED_TENANTS = globalThis[/* @__PURE__ */ Symbol.for("forklaunch.compliance.loadedTenants")] ??= /* @__PURE__ */ new WeakMap();
1311
+
1312
+ // src/http/telemetry/auditLogger.ts
1313
+ var import_crypto3 = require("crypto");
1314
+ var AuditLogger = class {
1315
+ _otel;
1316
+ constructor(otel) {
1317
+ this._otel = otel;
1318
+ }
1319
+ append(entry) {
1320
+ try {
1321
+ this._otel.info(
1322
+ {
1323
+ "log.type": "audit",
1324
+ "audit.timestamp": entry.timestamp,
1325
+ "audit.userId": entry.userId ?? "",
1326
+ "audit.tenantId": entry.tenantId ?? "",
1327
+ "audit.route": entry.route,
1328
+ "audit.method": entry.method,
1329
+ "audit.bodyHash": entry.bodyHash,
1330
+ "audit.status": entry.status,
1331
+ "audit.duration": entry.duration,
1332
+ "audit.redactedFields": entry.redactedFields.join(","),
1333
+ "audit.eventType": entry.eventType,
1334
+ _meta: true
1335
+ },
1336
+ `audit:${entry.eventType} ${entry.method} ${entry.route} ${entry.status}`
1337
+ );
1338
+ } catch (error) {
1339
+ console.error("Failed to emit audit log via OTEL:", error);
1340
+ }
1341
+ }
1342
+ static hashBody(body) {
1343
+ if (body === void 0 || body === null || body === "") {
1344
+ return "";
1345
+ }
1346
+ return (0, import_crypto3.createHash)("sha256").update(body).digest("hex");
1347
+ }
1348
+ };
1349
+
1350
+ // src/http/telemetry/requestAudit.ts
1351
+ function auditEnabled() {
1352
+ return (0, import_common8.getEnvVar)("FORKLAUNCH_AUDIT") !== "off";
1353
+ }
1354
+ var loggers = /* @__PURE__ */ new WeakMap();
1355
+ function loggerFor(otel) {
1356
+ let logger2 = loggers.get(otel);
1357
+ if (!logger2) {
1358
+ logger2 = new AuditLogger(otel);
1359
+ loggers.set(otel, logger2);
1360
+ }
1361
+ return logger2;
1362
+ }
1363
+ function auditEventType(status) {
1364
+ if (status === 401) return "auth_failure";
1365
+ if (status === 403) return "rbac_deny";
1366
+ if (status === 429) return "rate_limit";
1367
+ return "http";
1368
+ }
1369
+ var text = (value) => typeof value === "string" && value ? value : null;
1370
+ function bodyText(body) {
1371
+ if (body == null || body === "") return void 0;
1372
+ if (typeof body === "string" || Buffer.isBuffer(body)) return String(body);
1373
+ try {
1374
+ return JSON.stringify(body);
1375
+ } catch {
1376
+ return void 0;
1377
+ }
1378
+ }
1379
+ function recordRequestAudit(otel, req, status, durationMs) {
1380
+ if (!otel || !auditEnabled()) return;
1381
+ const session = req.session && typeof req.session === "object" ? req.session : void 0;
1382
+ loggerFor(otel).append({
1383
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1384
+ userId: text(session?.sub) ?? text(session?.userId),
1385
+ tenantId: text(session?.activeOrganizationId) ?? text(session?.organizationId),
1386
+ route: req.originalPath ?? req.path ?? "unknown",
1387
+ method: req.method,
1388
+ bodyHash: AuditLogger.hashBody(bodyText(req.body)),
1389
+ status,
1390
+ duration: Math.max(0, Math.round(durationMs)),
1391
+ redactedFields: [],
1392
+ eventType: auditEventType(status)
1393
+ });
1394
+ }
1395
+ var accessAudit;
1396
+ function auditComplianceAccess(otel) {
1397
+ if (!accessAudit && auditEnabled()) {
1398
+ const logger2 = loggerFor(otel);
1399
+ const remove = onComplianceAccess(
1400
+ (event) => logger2.append({
1401
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1402
+ userId: null,
1403
+ tenantId: event.tenantId || null,
1404
+ route: event.field,
1405
+ method: "READ",
1406
+ bodyHash: "",
1407
+ status: 0,
1408
+ duration: 0,
1409
+ redactedFields: [event.field],
1410
+ eventType: "data_access"
1411
+ })
1412
+ );
1413
+ const removeChanges = onComplianceChange(
1414
+ (event) => logger2.append({
1415
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1416
+ userId: null,
1417
+ tenantId: event.tenantId,
1418
+ route: `${event.entity}#${event.id}`,
1419
+ method: event.action.toUpperCase(),
1420
+ bodyHash: "",
1421
+ status: 0,
1422
+ duration: 0,
1423
+ redactedFields: event.sensitiveFields,
1424
+ eventType: "data_change"
1425
+ })
1426
+ );
1427
+ accessAudit = () => {
1428
+ remove();
1429
+ removeChanges();
1430
+ accessAudit = void 0;
1431
+ };
1432
+ }
1433
+ return accessAudit ?? (() => void 0);
1434
+ }
1435
+
1436
+ // src/http/middleware/request/enrichDetails.middleware.ts
1275
1437
  function enrichDetails(path, contractDetails, requestSchema, responseSchemas, openTelemetryCollector, globalOptions) {
1276
1438
  return (req, res, next) => {
1277
1439
  req.originalPath = path;
@@ -1282,12 +1444,20 @@ function enrichDetails(path, contractDetails, requestSchema, responseSchemas, op
1282
1444
  req._globalOptions = globalOptions ?? (() => void 0);
1283
1445
  req.context?.span?.setAttribute(ATTR_API_NAME, req.contractDetails?.name);
1284
1446
  const startTime = process.hrtime();
1447
+ if (openTelemetryCollector) auditComplianceAccess(openTelemetryCollector);
1285
1448
  res.on("finish", () => {
1449
+ const [auditSeconds, auditNanoseconds] = process.hrtime(startTime);
1450
+ recordRequestAudit(
1451
+ openTelemetryCollector,
1452
+ req,
1453
+ Number(res.statusCode),
1454
+ auditSeconds * 1e3 + auditNanoseconds / 1e6
1455
+ );
1286
1456
  const [seconds, nanoseconds] = process.hrtime(startTime);
1287
1457
  const durationMs = seconds + nanoseconds / 1e9;
1288
1458
  httpServerDurationHistogram.record(durationMs, {
1289
- [import_semantic_conventions.ATTR_SERVICE_NAME]: (0, import_common8.getEnvVar)("OTEL_SERVICE_NAME") || "unknown",
1290
- [ATTR_APPLICATION_ID]: (0, import_common8.getEnvVar)("OTEL_APPLICATION_ID"),
1459
+ [import_semantic_conventions.ATTR_SERVICE_NAME]: (0, import_common9.getEnvVar)("OTEL_SERVICE_NAME") || "unknown",
1460
+ [ATTR_APPLICATION_ID]: (0, import_common9.getEnvVar)("OTEL_APPLICATION_ID"),
1291
1461
  [ATTR_API_NAME]: req.contractDetails?.name || "unknown",
1292
1462
  [import_semantic_conventions.ATTR_HTTP_REQUEST_METHOD]: req.method,
1293
1463
  [import_semantic_conventions.ATTR_HTTP_ROUTE]: req.originalPath || "unknown",
@@ -1299,7 +1469,7 @@ function enrichDetails(path, contractDetails, requestSchema, responseSchemas, op
1299
1469
  }
1300
1470
 
1301
1471
  // src/http/middleware/request/parse.middleware.ts
1302
- var import_common9 = require("@forklaunch/common");
1472
+ var import_common10 = require("@forklaunch/common");
1303
1473
  var import_validator2 = require("@forklaunch/validator");
1304
1474
 
1305
1475
  // src/http/guards/hasSend.ts
@@ -1328,7 +1498,7 @@ function parse(req, res, next) {
1328
1498
  let parsedRequest;
1329
1499
  let collectedParseErrors;
1330
1500
  if (req.contractDetails.versions) {
1331
- if ((0, import_common9.isRecord)(req.requestSchema)) {
1501
+ if ((0, import_common10.isRecord)(req.requestSchema)) {
1332
1502
  let runningParseErrors = "";
1333
1503
  matchedVersions = [];
1334
1504
  Object.entries(req.requestSchema).forEach(([version, schema]) => {
@@ -1559,13 +1729,13 @@ function compileRouteSchemas(contractDetails, schemaValidator) {
1559
1729
  versionedContractDetails,
1560
1730
  contractDetails.params
1561
1731
  );
1562
- if ((0, import_common10.isRecord)(requestSchema)) {
1732
+ if ((0, import_common11.isRecord)(requestSchema)) {
1563
1733
  requestSchema = {
1564
1734
  ...requestSchema,
1565
1735
  [version]: versionedRequestSchema
1566
1736
  };
1567
1737
  }
1568
- if ((0, import_common10.isRecord)(responseSchemas)) {
1738
+ if ((0, import_common11.isRecord)(responseSchemas)) {
1569
1739
  responseSchemas = {
1570
1740
  ...responseSchemas,
1571
1741
  [version]: versionedResponseSchemas
@@ -1919,8 +2089,8 @@ var ForklaunchExpressLikeRouter = class _ForklaunchExpressLikeRouter {
1919
2089
  ...middlewares,
1920
2090
  this.#parseAndRunControllerHandler(controllerHandler)
1921
2091
  );
1922
- (0, import_common11.toRecord)(this._fetchMap)[(0, import_common11.sanitizePathSlashes)(`${this.basePath}${path}`)] = {
1923
- ...this._fetchMap[(0, import_common11.sanitizePathSlashes)(`${this.basePath}${path}`)] ?? {},
2092
+ (0, import_common12.toRecord)(this._fetchMap)[(0, import_common12.sanitizePathSlashes)(`${this.basePath}${path}`)] = {
2093
+ ...this._fetchMap[(0, import_common12.sanitizePathSlashes)(`${this.basePath}${path}`)] ?? {},
1924
2094
  [method.toUpperCase()]: contractDetails.versions ? Object.fromEntries(
1925
2095
  Object.keys(contractDetails.versions).map((version) => [
1926
2096
  version,
@@ -1943,7 +2113,7 @@ var ForklaunchExpressLikeRouter = class _ForklaunchExpressLikeRouter {
1943
2113
  };
1944
2114
  const contractDetailsName = contractDetails.name;
1945
2115
  if (contractDetailsName) {
1946
- (0, import_common11.toRecord)(this.sdk)[(0, import_common11.toPrettyCamelCase)(contractDetailsName)] = contractDetails.versions ? Object.fromEntries(
2116
+ (0, import_common12.toRecord)(this.sdk)[(0, import_common12.toPrettyCamelCase)(contractDetailsName)] = contractDetails.versions ? Object.fromEntries(
1947
2117
  Object.keys(contractDetails.versions).map((version) => [
1948
2118
  version,
1949
2119
  (req) => this.#localParamRequest(
@@ -2047,10 +2217,10 @@ var ForklaunchExpressLikeRouter = class _ForklaunchExpressLikeRouter {
2047
2217
  }
2048
2218
  addRouterToSdk(router) {
2049
2219
  Object.entries(router._fetchMap).map(
2050
- ([key, value]) => (0, import_common11.toRecord)(this._fetchMap)[(0, import_common11.sanitizePathSlashes)(`${this.basePath}${key}`)] = value
2220
+ ([key, value]) => (0, import_common12.toRecord)(this._fetchMap)[(0, import_common12.sanitizePathSlashes)(`${this.basePath}${key}`)] = value
2051
2221
  );
2052
- const existingSdk = this.sdk[router.sdkName ?? (0, import_common11.toPrettyCamelCase)(router.basePath)];
2053
- (0, import_common11.toRecord)(this.sdk)[router.sdkName ?? (0, import_common11.toPrettyCamelCase)(router.basePath)] = {
2222
+ const existingSdk = this.sdk[router.sdkName ?? (0, import_common12.toPrettyCamelCase)(router.basePath)];
2223
+ (0, import_common12.toRecord)(this.sdk)[router.sdkName ?? (0, import_common12.toPrettyCamelCase)(router.basePath)] = {
2054
2224
  ...typeof existingSdk === "object" ? existingSdk : {},
2055
2225
  ...router.sdk
2056
2226
  };
@@ -2382,7 +2552,7 @@ var ForklaunchExpressLikeRouter = class _ForklaunchExpressLikeRouter {
2382
2552
  unpackSdks(sdks, path, routerUniquenessCache) {
2383
2553
  Object.entries(sdks).forEach(([key, maybeHandler]) => {
2384
2554
  if (isSdkHandler(maybeHandler)) {
2385
- const cacheKey = (0, import_common11.hashString)((0, import_common11.safeStringify)(maybeHandler));
2555
+ const cacheKey = (0, import_common12.hashString)((0, import_common12.safeStringify)(maybeHandler));
2386
2556
  if (routerUniquenessCache.has(cacheKey)) {
2387
2557
  throw new Error(`SDK handler ${key} is already registered`);
2388
2558
  }
@@ -2489,7 +2659,7 @@ function isPortBound(port, host = "localhost") {
2489
2659
  }
2490
2660
 
2491
2661
  // src/http/generateHmacAuthHeaders.ts
2492
- var import_crypto3 = require("crypto");
2662
+ var import_crypto4 = require("crypto");
2493
2663
  function generateHmacAuthHeaders({
2494
2664
  secretKey,
2495
2665
  method,
@@ -2498,7 +2668,7 @@ function generateHmacAuthHeaders({
2498
2668
  keyId = "default"
2499
2669
  }) {
2500
2670
  const timestamp = /* @__PURE__ */ new Date();
2501
- const nonce = (0, import_crypto3.randomUUID)();
2671
+ const nonce = (0, import_crypto4.randomUUID)();
2502
2672
  const signature = createHmacToken({
2503
2673
  secretKey,
2504
2674
  method,
@@ -2513,7 +2683,7 @@ function generateHmacAuthHeaders({
2513
2683
  }
2514
2684
 
2515
2685
  // src/http/instanceGateway.ts
2516
- var import_crypto4 = require("crypto");
2686
+ var import_crypto5 = require("crypto");
2517
2687
  var INSTANCE_GATEWAY_MOUNT = "/instance-gateway";
2518
2688
  var PLATFORM_EVENT_KEY_ID = "platform";
2519
2689
  var PLATFORM_EVENTS_PATH = "/platform-events";
@@ -2534,7 +2704,7 @@ function instanceGatewaySettings(options2 = {}, feature = "The instance gateway"
2534
2704
  const hmacKey = options2.hmacKey ?? process.env.INSTANCE_HMAC_KEY;
2535
2705
  if (!gatewayUrl || !instanceId || !hmacKey) {
2536
2706
  throw new Error(
2537
- `${feature} is only available to instances hosted in managed mode: PLATFORM_GATEWAY_URL, INSTANCE_ID and INSTANCE_HMAC_KEY must be set`
2707
+ `${feature} goes through the ForkLaunch managed gateway: PLATFORM_GATEWAY_URL, INSTANCE_ID and INSTANCE_HMAC_KEY must be set. The platform sets them for a managed instance, and at deploy for a component that declares the capability (\`forklaunch infra add <service> <capability>\`), which also points them at the local gateway mock`
2538
2708
  );
2539
2709
  }
2540
2710
  return { gatewayUrl, instanceId, hmacKey };
@@ -2634,7 +2804,7 @@ function verifyPlatformEvent(request, options2 = {}) {
2634
2804
  })
2635
2805
  );
2636
2806
  const received = Buffer.from(signature);
2637
- if (expected.length !== received.length || !(0, import_crypto4.timingSafeEqual)(expected, received)) {
2807
+ if (expected.length !== received.length || !(0, import_crypto5.timingSafeEqual)(expected, received)) {
2638
2808
  throw new PlatformEventVerificationError("Invalid signature");
2639
2809
  }
2640
2810
  for (const [seen, at] of seenEventNonces) {
@@ -2769,17 +2939,17 @@ async function readPreviewCatalog(response) {
2769
2939
  if (!response.body) return invalidCatalog();
2770
2940
  const reader = response.body.getReader();
2771
2941
  const decoder = new TextDecoder();
2772
- let text = "", bytes = 0;
2942
+ let text2 = "", bytes = 0;
2773
2943
  try {
2774
2944
  for (; ; ) {
2775
2945
  const { done, value } = await reader.read();
2776
2946
  if (done) break;
2777
2947
  bytes += value.byteLength;
2778
2948
  if (bytes > 8192) return invalidCatalog();
2779
- text += decoder.decode(value, { stream: true });
2949
+ text2 += decoder.decode(value, { stream: true });
2780
2950
  }
2781
- text += decoder.decode();
2782
- return previewCatalog(JSON.parse(text));
2951
+ text2 += decoder.decode();
2952
+ return previewCatalog(JSON.parse(text2));
2783
2953
  } catch {
2784
2954
  return invalidCatalog();
2785
2955
  } finally {
@@ -2835,10 +3005,16 @@ var EMAIL_LIMITS = {
2835
3005
  maxSubjectLength: 998,
2836
3006
  /** text + html, UTF-8 bytes. */
2837
3007
  maxBodyBytes: 512 * 1024,
2838
- maxTags: 10
3008
+ maxTags: 10,
3009
+ /** `listUnsubscribe.url`; keeps the header within SES's limit. */
3010
+ maxUnsubscribeUrlLength: 512
2839
3011
  };
2840
3012
  var ADDRESS = /^[^\s@<>()[\]\\,;:"]+@[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)+$/;
2841
3013
  var TAG_PART = /^[A-Za-z0-9_-]{1,256}$/;
3014
+ var HEADER_SAFE = /^[\x21-\x7e]+$/;
3015
+ var URL_DELIMITERS = /[<>,"\\]/;
3016
+ var MAILBOX = /^[A-Za-z0-9._+-]+@[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?)+$/;
3017
+ var UNSUBSCRIBE_FIELDS = /* @__PURE__ */ new Set(["url", "mailto", "oneClick"]);
2842
3018
  function isEmailAddress(address) {
2843
3019
  return typeof address === "string" && address.length <= EMAIL_LIMITS.maxAddressLength && ADDRESS.test(address);
2844
3020
  }
@@ -2863,6 +3039,72 @@ function addresses(value, field) {
2863
3039
  }
2864
3040
  return list;
2865
3041
  }
3042
+ function fail(problem) {
3043
+ refuse(`listUnsubscribe: ${problem}`);
3044
+ }
3045
+ function unsubscribeUrlProblem(url) {
3046
+ if (typeof url !== "string" || url === "") return "url must be a string";
3047
+ if (url.length > EMAIL_LIMITS.maxUnsubscribeUrlLength) {
3048
+ return `url is longer than ${EMAIL_LIMITS.maxUnsubscribeUrlLength} characters`;
3049
+ }
3050
+ if (!HEADER_SAFE.test(url)) {
3051
+ return "url must be printable ASCII with no spaces or line breaks; percent-encode anything else";
3052
+ }
3053
+ if (URL_DELIMITERS.test(url)) {
3054
+ return 'url must not contain < > , " or \\; percent-encode them';
3055
+ }
3056
+ let parsed;
3057
+ try {
3058
+ parsed = new URL(url);
3059
+ } catch {
3060
+ return "url is not a URL";
3061
+ }
3062
+ if (parsed.protocol !== "https:") return "url must be https";
3063
+ if (parsed.username || parsed.password) {
3064
+ return "url must not carry a user name or password";
3065
+ }
3066
+ return null;
3067
+ }
3068
+ function validateListUnsubscribe(value) {
3069
+ if (value === void 0 || value === null) return void 0;
3070
+ if (typeof value !== "object" || Array.isArray(value)) {
3071
+ fail("must be an object");
3072
+ }
3073
+ const unknownFields = Object.keys(value).filter(
3074
+ (key) => !UNSUBSCRIBE_FIELDS.has(key)
3075
+ );
3076
+ if (unknownFields.length) {
3077
+ fail(`unknown field ${unknownFields.map((k) => `'${k}'`).join(", ")}`);
3078
+ }
3079
+ const url = "url" in value ? value.url : void 0;
3080
+ const mailto = "mailto" in value ? value.mailto : void 0;
3081
+ const oneClick = "oneClick" in value ? value.oneClick : void 0;
3082
+ if (url === void 0 && mailto === void 0) {
3083
+ fail("needs a url or a mailto");
3084
+ }
3085
+ if (url !== void 0) {
3086
+ const problem = unsubscribeUrlProblem(url);
3087
+ if (problem) fail(problem);
3088
+ }
3089
+ if (mailto !== void 0 && (typeof mailto !== "string" || mailto.length > EMAIL_LIMITS.maxAddressLength || !MAILBOX.test(mailto))) {
3090
+ fail(
3091
+ `mailto: '${String(mailto).slice(0, 80)}' is not a plain mailbox (letters, digits, . _ + - before the @)`
3092
+ );
3093
+ }
3094
+ if (oneClick !== void 0 && typeof oneClick !== "boolean") {
3095
+ fail("oneClick must be true or false");
3096
+ }
3097
+ if (typeof url === "string") {
3098
+ return {
3099
+ url,
3100
+ ...typeof mailto === "string" ? { mailto } : {},
3101
+ ...oneClick === true ? { oneClick: true } : {}
3102
+ };
3103
+ }
3104
+ if (oneClick === true) fail("oneClick needs a url (RFC 8058 posts to it)");
3105
+ if (typeof mailto === "string") return { mailto };
3106
+ return fail("needs a url or a mailto");
3107
+ }
2866
3108
  function validateEmailSendRequest(request) {
2867
3109
  const to = addresses(request.to, "to") ?? refuse("to is required");
2868
3110
  const replyTo = addresses(request.replyTo, "replyTo");
@@ -2894,13 +3136,15 @@ function validateEmailSendRequest(request) {
2894
3136
  refuse(`tag '${name}' is reserved`);
2895
3137
  }
2896
3138
  }
3139
+ const listUnsubscribe = validateListUnsubscribe(request.listUnsubscribe);
2897
3140
  return {
2898
3141
  to,
2899
3142
  subject,
2900
3143
  ...request.text !== void 0 ? { text: request.text } : {},
2901
3144
  ...request.html !== void 0 ? { html: request.html } : {},
2902
3145
  ...replyTo ? { replyTo } : {},
2903
- ...names.length ? { tags } : {}
3146
+ ...names.length ? { tags } : {},
3147
+ ...listUnsubscribe ? { listUnsubscribe } : {}
2904
3148
  };
2905
3149
  }
2906
3150
  function createEmailClient(options2 = {}) {
@@ -3012,7 +3256,7 @@ function validateSmsSend(request) {
3012
3256
  );
3013
3257
  }
3014
3258
  }
3015
- var NOT_MANAGED = "SMS is platform-held: this service must run as a managed instance (PLATFORM_GATEWAY_URL, INSTANCE_ID and INSTANCE_HMAC_KEY set). Locally, run the gateway mock (npx -p @forklaunch/core forklaunch-gateway-mock) and point those variables at it; do not add a Twilio or AWS SDK.";
3259
+ var NOT_MANAGED = "SMS goes through the ForkLaunch managed gateway: PLATFORM_GATEWAY_URL, INSTANCE_ID and INSTANCE_HMAC_KEY must be set (the platform sets them for a managed instance, and at deploy for a component that declares sms). Locally, run the gateway mock (npx -p @forklaunch/core forklaunch-gateway-mock) and point those variables at it; do not add a Twilio or AWS SDK.";
3016
3260
  var SmsClient = class {
3017
3261
  transport;
3018
3262
  constructor(options2 = {}) {
@@ -3181,7 +3425,7 @@ function voiceRequestProblem(request) {
3181
3425
  }
3182
3426
  var VoiceClient = class {
3183
3427
  transport;
3184
- /** Throws when the process is not a managed instance (see instanceGatewaySettings). */
3428
+ /** Throws without the managed gateway settings (see instanceGatewaySettings). */
3185
3429
  constructor(options2 = {}) {
3186
3430
  this.transport = createInstanceGatewayTransport(
3187
3431
  options2,
@@ -3209,8 +3453,8 @@ var VoiceClient = class {
3209
3453
  async send(method, route, body) {
3210
3454
  try {
3211
3455
  const response = await this.transport.request(method, route, body);
3212
- const text = await response.text();
3213
- return text ? JSON.parse(text) : void 0;
3456
+ const text2 = await response.text();
3457
+ return text2 ? JSON.parse(text2) : void 0;
3214
3458
  } catch (error) {
3215
3459
  if (error instanceof InstanceGatewayRequestError) {
3216
3460
  throw new VoiceRequestError(
@@ -3344,7 +3588,7 @@ function createStripeClient(options2 = {}) {
3344
3588
  if (!explicitGateway && !isManagedInstance()) {
3345
3589
  if (!options2.apiKey) {
3346
3590
  throw new Error(
3347
- "createStripeClient: not a managed instance (PLATFORM_GATEWAY_URL, INSTANCE_ID and INSTANCE_HMAC_KEY are unset) and no apiKey was given \u2014 pass { apiKey } for local development, or run the gateway mock (forklaunch-gateway-mock) with the managed env"
3591
+ "createStripeClient: no managed gateway (PLATFORM_GATEWAY_URL, INSTANCE_ID and INSTANCE_HMAC_KEY are unset) and no apiKey was given \u2014 pass { apiKey } for local development, or run the gateway mock (forklaunch-gateway-mock) with the managed env"
3348
3592
  );
3349
3593
  }
3350
3594
  return new StripeClass(options2.apiKey, options2.config);
@@ -4443,7 +4687,7 @@ var getCodeForStatus = (status) => {
4443
4687
  var httpStatusCodes_default = HTTPStatuses;
4444
4688
 
4445
4689
  // src/http/mcpGenerator/mcpGenerator.ts
4446
- var import_common12 = require("@forklaunch/common");
4690
+ var import_common13 = require("@forklaunch/common");
4447
4691
  var import_zod = require("@forklaunch/validator/zod");
4448
4692
  var import_fastmcp = require("fastmcp");
4449
4693
 
@@ -4578,7 +4822,7 @@ function generateMcpServer(schemaValidator, protocol, host, port, version, appli
4578
4822
  if (discriminatedBody) {
4579
4823
  switch (discriminatedBody.parserType) {
4580
4824
  case "json": {
4581
- parsedBody = (0, import_common12.safeStringify)(body);
4825
+ parsedBody = (0, import_common13.safeStringify)(body);
4582
4826
  break;
4583
4827
  }
4584
4828
  case "text": {
@@ -4586,12 +4830,12 @@ function generateMcpServer(schemaValidator, protocol, host, port, version, appli
4586
4830
  break;
4587
4831
  }
4588
4832
  case "file": {
4589
- parsedBody = Buffer.from((0, import_common12.safeStringify)(body));
4833
+ parsedBody = Buffer.from((0, import_common13.safeStringify)(body));
4590
4834
  break;
4591
4835
  }
4592
4836
  case "multipart": {
4593
4837
  const formData = new FormData();
4594
- if ((0, import_common12.isRecord)(body)) {
4838
+ if ((0, import_common13.isRecord)(body)) {
4595
4839
  for (const key in body) {
4596
4840
  if (typeof body[key] === "string") {
4597
4841
  if (schemaValidator.isInstanceOf(
@@ -4616,11 +4860,11 @@ function generateMcpServer(schemaValidator, protocol, host, port, version, appli
4616
4860
  break;
4617
4861
  }
4618
4862
  case "urlEncoded": {
4619
- if ((0, import_common12.isRecord)(body)) {
4863
+ if ((0, import_common13.isRecord)(body)) {
4620
4864
  parsedBody = new URLSearchParams(
4621
4865
  Object.entries(body).map(([key, value]) => [
4622
4866
  key,
4623
- (0, import_common12.safeStringify)(value)
4867
+ (0, import_common13.safeStringify)(value)
4624
4868
  ])
4625
4869
  );
4626
4870
  } else {
@@ -4629,8 +4873,8 @@ function generateMcpServer(schemaValidator, protocol, host, port, version, appli
4629
4873
  break;
4630
4874
  }
4631
4875
  default: {
4632
- (0, import_common12.isNever)(discriminatedBody.parserType);
4633
- parsedBody = (0, import_common12.safeStringify)(body);
4876
+ (0, import_common13.isNever)(discriminatedBody.parserType);
4877
+ parsedBody = (0, import_common13.safeStringify)(body);
4634
4878
  break;
4635
4879
  }
4636
4880
  }
@@ -4639,7 +4883,7 @@ function generateMcpServer(schemaValidator, protocol, host, port, version, appli
4639
4883
  const queryString = new URLSearchParams(
4640
4884
  Object.entries(query).map(([key, value]) => [
4641
4885
  key,
4642
- (0, import_common12.safeStringify)(value)
4886
+ (0, import_common13.safeStringify)(value)
4643
4887
  ])
4644
4888
  ).toString();
4645
4889
  url += queryString ? `?${queryString}` : "";
@@ -4672,7 +4916,7 @@ function generateMcpServer(schemaValidator, protocol, host, port, version, appli
4672
4916
  content: [
4673
4917
  {
4674
4918
  type: "text",
4675
- text: (0, import_common12.safeStringify)(await response.json())
4919
+ text: (0, import_common13.safeStringify)(await response.json())
4676
4920
  }
4677
4921
  ]
4678
4922
  };
@@ -4827,20 +5071,20 @@ ${parseErrors.join("\n\n")}`
4827
5071
  }
4828
5072
 
4829
5073
  // src/http/middleware/response/enrichExpressLikeSend.middleware.ts
4830
- var import_common14 = require("@forklaunch/common");
5074
+ var import_common15 = require("@forklaunch/common");
4831
5075
  var import_stream = require("stream");
4832
5076
 
4833
5077
  // src/http/telemetry/recordMetric.ts
4834
- var import_common13 = require("@forklaunch/common");
5078
+ var import_common14 = require("@forklaunch/common");
4835
5079
  var import_semantic_conventions4 = require("@opentelemetry/semantic-conventions");
4836
5080
  function recordMetric(req, res) {
4837
5081
  if (res.metricRecorded) {
4838
5082
  return;
4839
5083
  }
4840
- const serviceName = (0, import_common13.getEnvVar)("OTEL_SERVICE_NAME") || "unknown";
5084
+ const serviceName = (0, import_common14.getEnvVar)("OTEL_SERVICE_NAME") || "unknown";
4841
5085
  const attrs = {
4842
5086
  [import_semantic_conventions4.ATTR_SERVICE_NAME]: serviceName,
4843
- [ATTR_APPLICATION_ID]: (0, import_common13.getEnvVar)("OTEL_APPLICATION_ID"),
5087
+ [ATTR_APPLICATION_ID]: (0, import_common14.getEnvVar)("OTEL_APPLICATION_ID"),
4844
5088
  [ATTR_API_NAME]: req.contractDetails?.name,
4845
5089
  [import_semantic_conventions4.ATTR_HTTP_REQUEST_METHOD]: req.method,
4846
5090
  [import_semantic_conventions4.ATTR_HTTP_ROUTE]: req.originalPath,
@@ -4888,15 +5132,15 @@ function enrichExpressLikeSend(instance, req, res, originalOperation, originalSe
4888
5132
  `attachment; filename="${data.name}"`
4889
5133
  );
4890
5134
  }
4891
- if ((0, import_common14.isNodeJsWriteableStream)(res)) {
4892
- import_stream.Readable.from((0, import_common14.readableStreamToAsyncIterable)(data.stream())).pipe(res);
5135
+ if ((0, import_common15.isNodeJsWriteableStream)(res)) {
5136
+ import_stream.Readable.from((0, import_common15.readableStreamToAsyncIterable)(data.stream())).pipe(res);
4893
5137
  } else {
4894
5138
  res.type("text/plain");
4895
5139
  res.status(500);
4896
5140
  originalSend.call(instance, "Not a NodeJS WritableStream");
4897
5141
  errorSent = true;
4898
5142
  }
4899
- } else if ((0, import_common14.isAsyncGenerator)(data)) {
5143
+ } else if ((0, import_common15.isAsyncGenerator)(data)) {
4900
5144
  let firstPass = true;
4901
5145
  const transformer = new import_stream.Transform({
4902
5146
  objectMode: true,
@@ -4920,7 +5164,7 @@ ${res.locals.errorMessage}`;
4920
5164
  } else {
4921
5165
  let data2 = "";
4922
5166
  for (const [key, value] of Object.entries(chunk)) {
4923
- data2 += `${key}: ${typeof value === "string" ? value : (0, import_common14.safeStringify)(value)}
5167
+ data2 += `${key}: ${typeof value === "string" ? value : (0, import_common15.safeStringify)(value)}
4924
5168
  `;
4925
5169
  }
4926
5170
  data2 += "\n";
@@ -4931,7 +5175,7 @@ ${res.locals.errorMessage}`;
4931
5175
  } else if (!errorSent) {
4932
5176
  let data2 = "";
4933
5177
  for (const [key, value] of Object.entries(chunk)) {
4934
- data2 += `${key}: ${typeof value === "string" ? value : (0, import_common14.safeStringify)(value)}
5178
+ data2 += `${key}: ${typeof value === "string" ? value : (0, import_common15.safeStringify)(value)}
4935
5179
  `;
4936
5180
  }
4937
5181
  data2 += "\n";
@@ -4939,7 +5183,7 @@ ${res.locals.errorMessage}`;
4939
5183
  }
4940
5184
  }
4941
5185
  });
4942
- if ((0, import_common14.isNodeJsWriteableStream)(res)) {
5186
+ if ((0, import_common15.isNodeJsWriteableStream)(res)) {
4943
5187
  import_stream.Readable.from(data).pipe(transformer).pipe(res);
4944
5188
  } else {
4945
5189
  res.type("text/plain");
@@ -4950,7 +5194,7 @@ ${res.locals.errorMessage}`;
4950
5194
  } else {
4951
5195
  const parserType = responseBodies?.[Number(res.statusCode)]?.parserType;
4952
5196
  res.bodyData = data;
4953
- if ((0, import_common14.isRecord)(data)) {
5197
+ if ((0, import_common15.isRecord)(data)) {
4954
5198
  switch (parserType) {
4955
5199
  case "json":
4956
5200
  res.bodyData = "json" in data ? data.json : data;
@@ -4971,7 +5215,7 @@ ${res.locals.errorMessage}`;
4971
5215
  res.bodyData = data;
4972
5216
  break;
4973
5217
  default:
4974
- (0, import_common14.isNever)(parserType);
5218
+ (0, import_common15.isNever)(parserType);
4975
5219
  res.bodyData = data;
4976
5220
  break;
4977
5221
  }
@@ -5006,7 +5250,7 @@ ${res.locals.errorMessage}`;
5006
5250
  }
5007
5251
 
5008
5252
  // src/http/openApiV3Generator/openApiV3Generator.ts
5009
- var import_common15 = require("@forklaunch/common");
5253
+ var import_common16 = require("@forklaunch/common");
5010
5254
  var OPENAPI_DEFAULT_VERSION = /* @__PURE__ */ Symbol("default");
5011
5255
  function toUpperCase(str) {
5012
5256
  return str.charAt(0).toUpperCase() + str.slice(1);
@@ -5229,7 +5473,7 @@ function generateOpenApiSpecs(schemaValidator, serverUrls, serverDescriptions, a
5229
5473
  ].forEach(({ fullPath, router }) => {
5230
5474
  const controllerName = transformBasePath(fullPath);
5231
5475
  router.routes.forEach((route) => {
5232
- const openApiPath = (0, import_common15.openApiCompliantPath)(
5476
+ const openApiPath = (0, import_common16.openApiCompliantPath)(
5233
5477
  `${fullPath}${route.path === "/" ? "" : route.path}`
5234
5478
  );
5235
5479
  const { name, summary, params, versions, auth, options: options2 } = route.contractDetails;
@@ -5333,44 +5577,6 @@ function generateOpenApiSpecs(schemaValidator, serverUrls, serverDescriptions, a
5333
5577
  );
5334
5578
  }
5335
5579
 
5336
- // src/http/telemetry/auditLogger.ts
5337
- var import_crypto5 = require("crypto");
5338
- var AuditLogger = class {
5339
- _otel;
5340
- constructor(otel) {
5341
- this._otel = otel;
5342
- }
5343
- append(entry) {
5344
- try {
5345
- this._otel.info(
5346
- {
5347
- "log.type": "audit",
5348
- "audit.timestamp": entry.timestamp,
5349
- "audit.userId": entry.userId ?? "",
5350
- "audit.tenantId": entry.tenantId ?? "",
5351
- "audit.route": entry.route,
5352
- "audit.method": entry.method,
5353
- "audit.bodyHash": entry.bodyHash,
5354
- "audit.status": entry.status,
5355
- "audit.duration": entry.duration,
5356
- "audit.redactedFields": entry.redactedFields.join(","),
5357
- "audit.eventType": entry.eventType,
5358
- _meta: true
5359
- },
5360
- `audit:${entry.eventType} ${entry.method} ${entry.route} ${entry.status}`
5361
- );
5362
- } catch (error) {
5363
- console.error("Failed to emit audit log via OTEL:", error);
5364
- }
5365
- }
5366
- static hashBody(body) {
5367
- if (body === void 0 || body === null || body === "") {
5368
- return "";
5369
- }
5370
- return (0, import_crypto5.createHash)("sha256").update(body).digest("hex");
5371
- }
5372
- };
5373
-
5374
5580
  // src/http/telemetry/evaluateTelemetryOptions.ts
5375
5581
  function evaluateTelemetryOptions(telemetryOptions) {
5376
5582
  return {
@@ -5494,6 +5700,9 @@ var RateLimiter = class {
5494
5700
  VoiceClient,
5495
5701
  VoiceRequestError,
5496
5702
  WhatsAppRequestError,
5703
+ auditComplianceAccess,
5704
+ auditEnabled,
5705
+ auditEventType,
5497
5706
  createContext,
5498
5707
  createEmailClient,
5499
5708
  createHmacToken,
@@ -5546,6 +5755,7 @@ var RateLimiter = class {
5546
5755
  post,
5547
5756
  put,
5548
5757
  recordMetric,
5758
+ recordRequestAudit,
5549
5759
  resetProcessHandlersForTesting,
5550
5760
  signPlatformEvent,
5551
5761
  smsSegments,
@@ -5554,6 +5764,7 @@ var RateLimiter = class {
5554
5764
  typedAuthHandler,
5555
5765
  typedHandler,
5556
5766
  validateEmailSendRequest,
5767
+ validateListUnsubscribe,
5557
5768
  validateSmsSend,
5558
5769
  verifyPlatformEvent,
5559
5770
  voiceRequestProblem