@forklaunch/core 0.19.5 → 1.0.2

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.
@@ -242,11 +242,36 @@ function discriminateResponseBodies(schemaValidator, responses) {
242
242
  // src/http/router/routerSharedLogic.ts
243
243
  import { isRecord as isRecord2 } from "@forklaunch/common";
244
244
 
245
+ // src/http/guards/hasPermissionChecks.ts
246
+ function hasPermissionChecks(maybePermissionedAuth) {
247
+ return typeof maybePermissionedAuth === "object" && maybePermissionedAuth !== null && ("allowedPermissions" in maybePermissionedAuth || "forbiddenPermissions" in maybePermissionedAuth);
248
+ }
249
+
250
+ // src/http/guards/hasRoleChecks.ts
251
+ function hasRoleChecks(maybeRoledAuth) {
252
+ if (typeof maybeRoledAuth !== "object" || maybeRoledAuth === null) {
253
+ return false;
254
+ }
255
+ const hasAllowedRoles = "allowedRoles" in maybeRoledAuth && maybeRoledAuth.allowedRoles instanceof Set && maybeRoledAuth.allowedRoles.size > 0;
256
+ const hasForbiddenRoles = "forbiddenRoles" in maybeRoledAuth && maybeRoledAuth.forbiddenRoles instanceof Set && maybeRoledAuth.forbiddenRoles.size > 0;
257
+ return hasAllowedRoles || hasForbiddenRoles;
258
+ }
259
+
260
+ // src/http/guards/hasScopeChecks.ts
261
+ function hasScopeChecks(maybePermissionedAuth) {
262
+ return typeof maybePermissionedAuth === "object" && maybePermissionedAuth !== null && "requiredScope" in maybePermissionedAuth && maybePermissionedAuth.requiredScope != null;
263
+ }
264
+
245
265
  // src/http/guards/hasVersionedSchema.ts
246
266
  function hasVersionedSchema(contractDetails) {
247
267
  return typeof contractDetails === "object" && contractDetails !== null && "versions" in contractDetails && contractDetails.versions !== null;
248
268
  }
249
269
 
270
+ // src/http/guards/isHmacMethod.ts
271
+ function isHmacMethod(maybeHmacMethod) {
272
+ return typeof maybeHmacMethod === "object" && maybeHmacMethod !== null && "hmac" in maybeHmacMethod && maybeHmacMethod.hmac != null;
273
+ }
274
+
250
275
  // src/http/guards/isPathParamContractDetails.ts
251
276
  function isPathParamHttpContractDetails(maybePathParamHttpContractDetails) {
252
277
  return maybePathParamHttpContractDetails != null && typeof maybePathParamHttpContractDetails === "object" && "name" in maybePathParamHttpContractDetails && "summary" in maybePathParamHttpContractDetails && maybePathParamHttpContractDetails.name != null && maybePathParamHttpContractDetails.summary != null && ("responses" in maybePathParamHttpContractDetails && maybePathParamHttpContractDetails.responses != null || "versions" in maybePathParamHttpContractDetails && typeof maybePathParamHttpContractDetails.versions === "object" && maybePathParamHttpContractDetails.versions != null && Object.values(maybePathParamHttpContractDetails.versions).every(
@@ -299,11 +324,6 @@ function isBasicAuthMethod(maybeBasicAuthMethod) {
299
324
  return typeof maybeBasicAuthMethod === "object" && maybeBasicAuthMethod !== null && "basic" in maybeBasicAuthMethod && maybeBasicAuthMethod.basic != null;
300
325
  }
301
326
 
302
- // src/http/guards/isHmacMethod.ts
303
- function isHmacMethod(maybeHmacMethod) {
304
- return typeof maybeHmacMethod === "object" && maybeHmacMethod !== null && "hmac" in maybeHmacMethod && maybeHmacMethod.hmac != null;
305
- }
306
-
307
327
  // src/http/guards/isJwtAuthMethod.ts
308
328
  function isJwtAuthMethod(maybeJwtAuthMethod) {
309
329
  return typeof maybeJwtAuthMethod === "object" && maybeJwtAuthMethod !== null && "jwt" in maybeJwtAuthMethod && maybeJwtAuthMethod.jwt != null;
@@ -433,26 +453,6 @@ function hasFeatureChecks(maybeFeatureAuth) {
433
453
  return typeof maybeFeatureAuth === "object" && maybeFeatureAuth !== null && "requiredFeatures" in maybeFeatureAuth && Array.isArray(maybeFeatureAuth.requiredFeatures) && maybeFeatureAuth.requiredFeatures.length > 0;
434
454
  }
435
455
 
436
- // src/http/guards/hasPermissionChecks.ts
437
- function hasPermissionChecks(maybePermissionedAuth) {
438
- return typeof maybePermissionedAuth === "object" && maybePermissionedAuth !== null && ("allowedPermissions" in maybePermissionedAuth || "forbiddenPermissions" in maybePermissionedAuth);
439
- }
440
-
441
- // src/http/guards/hasRoleChecks.ts
442
- function hasRoleChecks(maybeRoledAuth) {
443
- if (typeof maybeRoledAuth !== "object" || maybeRoledAuth === null) {
444
- return false;
445
- }
446
- const hasAllowedRoles = "allowedRoles" in maybeRoledAuth && maybeRoledAuth.allowedRoles instanceof Set && maybeRoledAuth.allowedRoles.size > 0;
447
- const hasForbiddenRoles = "forbiddenRoles" in maybeRoledAuth && maybeRoledAuth.forbiddenRoles instanceof Set && maybeRoledAuth.forbiddenRoles.size > 0;
448
- return hasAllowedRoles || hasForbiddenRoles;
449
- }
450
-
451
- // src/http/guards/hasScopeChecks.ts
452
- function hasScopeChecks(maybePermissionedAuth) {
453
- return typeof maybePermissionedAuth === "object" && maybePermissionedAuth !== null && "requiredScope" in maybePermissionedAuth && maybePermissionedAuth.requiredScope != null;
454
- }
455
-
456
456
  // src/http/guards/hasSubscriptionChecks.ts
457
457
  function hasSubscriptionChecks(maybeSubscriptionAuth) {
458
458
  return typeof maybeSubscriptionAuth === "object" && maybeSubscriptionAuth !== null && "requireActiveSubscription" in maybeSubscriptionAuth && maybeSubscriptionAuth.requireActiveSubscription === true;
@@ -1247,6 +1247,40 @@ function validateContractDetails(contractDetails, schemaValidator) {
1247
1247
  if (!isHttpContractDetails(contractDetails) && !isPathParamHttpContractDetails(contractDetails)) {
1248
1248
  throw new Error("Contract details are malformed for route definition");
1249
1249
  }
1250
+ const access = contractDetails["access"];
1251
+ const auth = contractDetails["auth"];
1252
+ if (access != null) {
1253
+ const validAccess = ["public", "authenticated", "protected", "internal"];
1254
+ if (!validAccess.includes(access)) {
1255
+ throw new Error(
1256
+ `Route '${contractDetails.name}': invalid access level '${access}'. Must be one of: ${validAccess.join(", ")}`
1257
+ );
1258
+ }
1259
+ if (access === "public" && auth != null) {
1260
+ throw new Error(
1261
+ `Route '${contractDetails.name}': access 'public' cannot have auth configured`
1262
+ );
1263
+ }
1264
+ if (access === "protected") {
1265
+ if (!auth) {
1266
+ throw new Error(
1267
+ `Route '${contractDetails.name}': access 'protected' requires auth with roles, permissions, or scope`
1268
+ );
1269
+ }
1270
+ if (!hasPermissionChecks(auth) && !hasRoleChecks(auth) && !hasScopeChecks(auth)) {
1271
+ throw new Error(
1272
+ `Route '${contractDetails.name}': access 'protected' requires at least one of allowedRoles, forbiddenRoles, allowedPermissions, forbiddenPermissions, or requiredScope`
1273
+ );
1274
+ }
1275
+ }
1276
+ if (access === "internal") {
1277
+ if (!auth || !isHmacMethod(auth)) {
1278
+ throw new Error(
1279
+ `Route '${contractDetails.name}': access 'internal' requires HMAC auth`
1280
+ );
1281
+ }
1282
+ }
1283
+ }
1250
1284
  if (contractDetails.versions) {
1251
1285
  const parserTypes = Object.values(contractDetails.versions).map(
1252
1286
  (version) => discriminateBody(schemaValidator, version.body)?.parserType
@@ -4168,6 +4202,44 @@ function generateOpenApiSpecs(schemaValidator, serverUrls, serverDescriptions, a
4168
4202
  );
4169
4203
  }
4170
4204
 
4205
+ // src/http/telemetry/auditLogger.ts
4206
+ import { createHash } from "crypto";
4207
+ var AuditLogger = class {
4208
+ #otel;
4209
+ constructor(otel) {
4210
+ this.#otel = otel;
4211
+ }
4212
+ append(entry) {
4213
+ try {
4214
+ this.#otel.info(
4215
+ {
4216
+ "log.type": "audit",
4217
+ "audit.timestamp": entry.timestamp,
4218
+ "audit.userId": entry.userId ?? "",
4219
+ "audit.tenantId": entry.tenantId ?? "",
4220
+ "audit.route": entry.route,
4221
+ "audit.method": entry.method,
4222
+ "audit.bodyHash": entry.bodyHash,
4223
+ "audit.status": entry.status,
4224
+ "audit.duration": entry.duration,
4225
+ "audit.redactedFields": entry.redactedFields.join(","),
4226
+ "audit.eventType": entry.eventType,
4227
+ _meta: true
4228
+ },
4229
+ `audit:${entry.eventType} ${entry.method} ${entry.route} ${entry.status}`
4230
+ );
4231
+ } catch (error) {
4232
+ console.error("Failed to emit audit log via OTEL:", error);
4233
+ }
4234
+ }
4235
+ static hashBody(body) {
4236
+ if (body === void 0 || body === null || body === "") {
4237
+ return "";
4238
+ }
4239
+ return createHash("sha256").update(body).digest("hex");
4240
+ }
4241
+ };
4242
+
4171
4243
  // src/http/telemetry/evaluateTelemetryOptions.ts
4172
4244
  function evaluateTelemetryOptions(telemetryOptions) {
4173
4245
  return {
@@ -4188,6 +4260,73 @@ function evaluateTelemetryOptions(telemetryOptions) {
4188
4260
  function metricsDefinitions(metrics2) {
4189
4261
  return metrics2;
4190
4262
  }
4263
+
4264
+ // src/http/rateLimit/rateLimiter.ts
4265
+ var READ_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
4266
+ var RateLimiter = class {
4267
+ constructor(cache) {
4268
+ this.cache = cache;
4269
+ }
4270
+ /**
4271
+ * Check whether the given key is within its rate limit.
4272
+ *
4273
+ * The counter is read from the cache, incremented, and written back.
4274
+ * If the cache is unreachable the limiter **fails open** (allows the
4275
+ * request) so that a cache outage does not take down the service.
4276
+ *
4277
+ * @param key - The rate limit key (see {@link RateLimiter.buildKey}).
4278
+ * @param limit - Maximum number of requests allowed in the window.
4279
+ * @param windowMs - Window duration in milliseconds.
4280
+ */
4281
+ async check(key, limit, windowMs) {
4282
+ try {
4283
+ const now = Date.now();
4284
+ let counter;
4285
+ try {
4286
+ const record = await this.cache.readRecord(key);
4287
+ counter = record.value;
4288
+ if (now >= counter.resetAt) {
4289
+ counter = { count: 0, resetAt: now + windowMs };
4290
+ }
4291
+ } catch {
4292
+ counter = { count: 0, resetAt: now + windowMs };
4293
+ }
4294
+ counter.count += 1;
4295
+ const ttl = Math.max(counter.resetAt - now, 1);
4296
+ await this.cache.putRecord({
4297
+ key,
4298
+ value: counter,
4299
+ ttlMilliseconds: ttl
4300
+ });
4301
+ const allowed = counter.count <= limit;
4302
+ const remaining = Math.max(limit - counter.count, 0);
4303
+ return { allowed, remaining, resetAt: counter.resetAt };
4304
+ } catch {
4305
+ console.warn(`[RateLimiter] Cache error for key "${key}". Failing open.`);
4306
+ return { allowed: true, remaining: limit, resetAt: 0 };
4307
+ }
4308
+ }
4309
+ /**
4310
+ * Build a rate limit key from request context parts.
4311
+ *
4312
+ * Format: `ratelimit:{tenantId}:{route}:{userId}:{operationType}`
4313
+ *
4314
+ * When `tenantId` or `userId` is `null`, the placeholder `"anon"` is used.
4315
+ */
4316
+ static buildKey(parts) {
4317
+ const tenant = parts.tenantId ?? "anon";
4318
+ const user = parts.userId ?? "anon";
4319
+ return `ratelimit:${tenant}:${parts.route}:${user}:${parts.operationType}`;
4320
+ }
4321
+ /**
4322
+ * Determine whether an HTTP method is a read or write operation.
4323
+ *
4324
+ * GET, HEAD, and OPTIONS are reads; everything else is a write.
4325
+ */
4326
+ static operationType(method) {
4327
+ return READ_METHODS.has(method.toUpperCase()) ? "read" : "write";
4328
+ }
4329
+ };
4191
4330
  export {
4192
4331
  ATTR_API_NAME,
4193
4332
  ATTR_APPLICATION_ID,
@@ -4196,12 +4335,14 @@ export {
4196
4335
  ATTR_HTTP_RESPONSE_STATUS_CODE,
4197
4336
  ATTR_HTTP_ROUTE,
4198
4337
  ATTR_SERVICE_NAME,
4338
+ AuditLogger,
4199
4339
  ForklaunchExpressLikeApplication,
4200
4340
  ForklaunchExpressLikeRouter,
4201
4341
  HTTPStatuses,
4202
4342
  OPENAPI_DEFAULT_VERSION,
4203
4343
  OpenTelemetryCollector,
4204
4344
  PinoLogger,
4345
+ RateLimiter,
4205
4346
  createContext,
4206
4347
  createHmacToken,
4207
4348
  delete_,