@ts-cloud/core 0.7.105 → 0.7.109

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.
package/dist/index.js CHANGED
@@ -45382,8 +45382,9 @@ import { cpSync, mkdtempSync, rmSync, writeFileSync as writeFileSync3 } from "no
45382
45382
  import { tmpdir } from "node:os";
45383
45383
  import { dirname as dirname2, isAbsolute as isAbsolute2, join as join7, resolve } from "node:path";
45384
45384
  import { fileURLToPath } from "node:url";
45385
- function adapterSourcePath() {
45386
- return join7(dirname2(fileURLToPath(import.meta.url)), "runtime", "adapter.ts");
45385
+ var RUNTIME_SOURCES = ["adapter.ts", "recursion.ts", "auto-recursion.ts"];
45386
+ function runtimeSourceDir() {
45387
+ return join7(dirname2(fileURLToPath(import.meta.url)), "runtime");
45387
45388
  }
45388
45389
  function runBuildHooks(hooks, cwd, onStep) {
45389
45390
  for (const hook of hooks ?? []) {
@@ -45408,7 +45409,8 @@ async function packageServerlessApp(opts) {
45408
45409
  const entryPath = isAbsolute2(entry) ? entry : join7(projectRoot, entry);
45409
45410
  const stage = mkdtempSync(join7(tmpdir(), "tscloud-pkg-"));
45410
45411
  try {
45411
- cpSync(adapterSourcePath(), join7(stage, "adapter.ts"));
45412
+ for (const source of RUNTIME_SOURCES)
45413
+ cpSync(join7(runtimeSourceDir(), source), join7(stage, source));
45412
45414
  const bootstrapPath = join7(stage, "bootstrap.ts");
45413
45415
  writeFileSync3(bootstrapPath, generateBootstrap({ entryImport: entryPath, adapterImport: "./adapter" }));
45414
45416
  opts.onStep?.("bundling application");
@@ -46475,6 +46477,239 @@ function resolveLatestNode(major) {
46475
46477
  throw new Error(`No Node release found for major version ${major}`);
46476
46478
  return match.version.replace(/^v/, "");
46477
46479
  }
46480
+ // src/serverless/runtime/auto-recursion.ts
46481
+ import { AsyncLocalStorage } from "node:async_hooks";
46482
+
46483
+ // src/serverless/runtime/recursion.ts
46484
+ import { createHash as createHash5 } from "node:crypto";
46485
+ var DEPTH_HEADER = "x-ts-cloud-invoke-depth";
46486
+ var CHAIN_HEADER = "x-ts-cloud-invoke-chain";
46487
+ var TRACE_HEADER = "x-ts-cloud-trace-id";
46488
+ function functionFingerprint(functionId) {
46489
+ return createHash5("sha256").update(functionId).digest("hex").slice(0, 8);
46490
+ }
46491
+ var DEFAULT_RECURSION_LIMITS = {
46492
+ maxDepth: 10,
46493
+ maxRepeats: 3,
46494
+ maxInvocationsPerTrace: 100,
46495
+ traceWindowMs: 60000,
46496
+ breakerThreshold: 5,
46497
+ breakerCooldownMs: 60000
46498
+ };
46499
+ function readHeader(headers, name) {
46500
+ if (typeof headers.get === "function")
46501
+ return headers.get(name) ?? undefined;
46502
+ const record = headers;
46503
+ return record[name] ?? record[name.toLowerCase()] ?? record[name.toUpperCase()];
46504
+ }
46505
+ function parseChain(value, maxEntries = 64) {
46506
+ if (!value)
46507
+ return [];
46508
+ return value.split(".").map((entry) => entry.trim()).filter((entry) => /^[0-9a-f]{8}$/.test(entry)).slice(-maxEntries);
46509
+ }
46510
+ function inspectInvocation(context, limits = DEFAULT_RECURSION_LIMITS) {
46511
+ const fingerprint = functionFingerprint(context.functionId);
46512
+ const incoming = parseChain(readHeader(context.headers, CHAIN_HEADER));
46513
+ const traceId = context.traceId ?? readHeader(context.headers, TRACE_HEADER) ?? crypto.randomUUID();
46514
+ const chain = [...incoming, fingerprint];
46515
+ const repeats = incoming.filter((entry) => entry === fingerprint).length;
46516
+ const selfInvocation = incoming[incoming.length - 1] === fingerprint;
46517
+ const headerDepth = Number.parseInt(readHeader(context.headers, DEPTH_HEADER) ?? "", 10);
46518
+ const depth = Math.max(chain.length, Number.isFinite(headerDepth) ? headerDepth + 1 : 0);
46519
+ let reason = "ok";
46520
+ if (depth > limits.maxDepth)
46521
+ reason = "depth_exceeded";
46522
+ else if (repeats >= limits.maxRepeats)
46523
+ reason = "cycle_detected";
46524
+ else if (selfInvocation && limits.maxRepeats <= 1)
46525
+ reason = "self_invocation";
46526
+ return {
46527
+ reason,
46528
+ depth,
46529
+ chain,
46530
+ traceId,
46531
+ selfInvocation,
46532
+ repeats,
46533
+ propagate: {
46534
+ [DEPTH_HEADER]: String(depth),
46535
+ [CHAIN_HEADER]: chain.join("."),
46536
+ [TRACE_HEADER]: traceId
46537
+ }
46538
+ };
46539
+ }
46540
+ var MESSAGES = {
46541
+ ok: "",
46542
+ depth_exceeded: "Invocation chain is deeper than the configured limit; the call was refused to stop a runaway loop.",
46543
+ cycle_detected: "This function already appears in the invocation chain; the call was refused as a recursion loop.",
46544
+ self_invocation: "This function invoked itself directly; the call was refused.",
46545
+ trace_budget_exceeded: "This request has already made more invocations than its budget allows.",
46546
+ breaker_open: "Recursion protection is open for this function after repeated loop detections."
46547
+ };
46548
+
46549
+ class RecursionGuard {
46550
+ limits;
46551
+ clock;
46552
+ traces = new Map;
46553
+ breakers = new Map;
46554
+ constructor(limits = DEFAULT_RECURSION_LIMITS, clock = () => Date.now()) {
46555
+ this.limits = limits;
46556
+ this.clock = clock;
46557
+ }
46558
+ check(context) {
46559
+ const now = this.clock();
46560
+ this.expireTraces(now);
46561
+ const inspected = inspectInvocation(context, this.limits);
46562
+ const breaker = this.breakers.get(context.functionId);
46563
+ if (breaker?.openUntil != null && breaker.openUntil > now)
46564
+ return this.deny(context.functionId, inspected, "breaker_open", now, false);
46565
+ if (inspected.reason !== "ok")
46566
+ return this.deny(context.functionId, inspected, inspected.reason, now, true);
46567
+ const tally = this.traces.get(inspected.traceId) ?? { count: 0, firstAt: now };
46568
+ if (tally.count >= this.limits.maxInvocationsPerTrace) {
46569
+ this.traces.set(inspected.traceId, tally);
46570
+ return this.deny(context.functionId, inspected, "trace_budget_exceeded", now, true);
46571
+ }
46572
+ this.traces.set(inspected.traceId, { count: tally.count + 1, firstAt: tally.firstAt });
46573
+ if (breaker)
46574
+ this.breakers.set(context.functionId, { consecutiveBlocks: 0 });
46575
+ return { ...inspected, allowed: true, reason: "ok", propagate: inspected.propagate };
46576
+ }
46577
+ deny(functionId, inspected, reason, now, countTowardBreaker) {
46578
+ if (countTowardBreaker) {
46579
+ const state = this.breakers.get(functionId) ?? { consecutiveBlocks: 0 };
46580
+ const consecutiveBlocks = state.consecutiveBlocks + 1;
46581
+ this.breakers.set(functionId, {
46582
+ consecutiveBlocks,
46583
+ openUntil: consecutiveBlocks >= this.limits.breakerThreshold ? now + this.limits.breakerCooldownMs : state.openUntil
46584
+ });
46585
+ }
46586
+ return { ...inspected, allowed: false, reason, message: MESSAGES[reason] };
46587
+ }
46588
+ breakerOpen(functionId) {
46589
+ const state = this.breakers.get(functionId);
46590
+ return state?.openUntil != null && state.openUntil > this.clock();
46591
+ }
46592
+ reset(functionId) {
46593
+ if (functionId) {
46594
+ this.breakers.delete(functionId);
46595
+ return;
46596
+ }
46597
+ this.breakers.clear();
46598
+ this.traces.clear();
46599
+ }
46600
+ expireTraces(now) {
46601
+ if (this.traces.size === 0)
46602
+ return;
46603
+ for (const [traceId, tally] of this.traces)
46604
+ if (now - tally.firstAt > this.limits.traceWindowMs)
46605
+ this.traces.delete(traceId);
46606
+ }
46607
+ get trackedTraces() {
46608
+ return this.traces.size;
46609
+ }
46610
+ }
46611
+ function propagationHeaders(verdict) {
46612
+ return { ...verdict.propagate };
46613
+ }
46614
+ function recursionBlockedResponse(verdict) {
46615
+ return {
46616
+ status: 508,
46617
+ headers: { "content-type": "application/json", "x-ts-cloud-recursion-blocked": verdict.reason },
46618
+ body: {
46619
+ error: "recursion_blocked",
46620
+ reason: verdict.reason,
46621
+ message: verdict.message ?? MESSAGES[verdict.reason],
46622
+ depth: verdict.depth,
46623
+ traceId: verdict.traceId
46624
+ }
46625
+ };
46626
+ }
46627
+
46628
+ // src/serverless/runtime/auto-recursion.ts
46629
+ var invocationContext = new AsyncLocalStorage;
46630
+ function envFlag(name) {
46631
+ const raw = globalThis.process?.env?.[name];
46632
+ if (raw == null || raw === "")
46633
+ return;
46634
+ return raw !== "0" && raw.toLowerCase() !== "false";
46635
+ }
46636
+ function defaultFunctionId() {
46637
+ const env = globalThis.process?.env ?? {};
46638
+ return env.TS_CLOUD_FUNCTION_ID || env.AWS_LAMBDA_FUNCTION_NAME || env.FUNCTION_NAME || "function";
46639
+ }
46640
+ function resolveRecursionConfig(config2) {
46641
+ const explicit = config2 === false ? { enabled: false } : config2 ?? {};
46642
+ const envEnabled = envFlag("TS_CLOUD_RECURSION_PROTECTION");
46643
+ const envDetectionOnly = envFlag("TS_CLOUD_RECURSION_DETECTION_ONLY");
46644
+ return {
46645
+ enabled: envEnabled ?? explicit.enabled ?? true,
46646
+ detectionOnly: envDetectionOnly ?? explicit.detectionOnly ?? false,
46647
+ limits: { ...DEFAULT_RECURSION_LIMITS, ...explicit.limits },
46648
+ functionId: explicit.functionId ?? defaultFunctionId()
46649
+ };
46650
+ }
46651
+ var installedFetch;
46652
+ var originalFetch;
46653
+ function installRecursionFetch() {
46654
+ const current = globalThis.fetch;
46655
+ if (!current || installedFetch === current)
46656
+ return () => {};
46657
+ originalFetch = current;
46658
+ const wrapped = async (input, init) => {
46659
+ const verdict = invocationContext.getStore();
46660
+ if (!verdict)
46661
+ return originalFetch(input, init);
46662
+ const headers = new Headers(init?.headers ?? (input instanceof Request ? input.headers : undefined));
46663
+ for (const [key, value] of Object.entries(propagationHeaders(verdict)))
46664
+ headers.set(key, value);
46665
+ if (input instanceof Request)
46666
+ return originalFetch(new Request(input, { headers }), init);
46667
+ return originalFetch(input, { ...init, headers });
46668
+ };
46669
+ Object.assign(wrapped, current);
46670
+ globalThis.fetch = wrapped;
46671
+ installedFetch = wrapped;
46672
+ return () => {
46673
+ if (globalThis.fetch === installedFetch && originalFetch)
46674
+ globalThis.fetch = originalFetch;
46675
+ installedFetch = undefined;
46676
+ };
46677
+ }
46678
+ var sharedGuard;
46679
+ var sharedLimits;
46680
+ function sharedRecursionGuard(limits) {
46681
+ if (!sharedGuard || JSON.stringify(sharedLimits) !== JSON.stringify(limits)) {
46682
+ sharedGuard = new RecursionGuard(limits);
46683
+ sharedLimits = limits;
46684
+ }
46685
+ return sharedGuard;
46686
+ }
46687
+ function resetRecursionRuntime() {
46688
+ sharedGuard = undefined;
46689
+ sharedLimits = undefined;
46690
+ if (installedFetch && originalFetch && globalThis.fetch === installedFetch)
46691
+ globalThis.fetch = originalFetch;
46692
+ installedFetch = undefined;
46693
+ originalFetch = undefined;
46694
+ }
46695
+ function checkInvocation(headers, config2) {
46696
+ const resolved = resolveRecursionConfig(config2);
46697
+ if (!resolved.enabled)
46698
+ return;
46699
+ const guard = sharedRecursionGuard(resolved.limits);
46700
+ const context = { functionId: resolved.functionId, headers };
46701
+ const verdict = guard.check(context);
46702
+ return {
46703
+ verdict,
46704
+ blocked: !verdict.allowed && !resolved.detectionOnly,
46705
+ observed: !verdict.allowed && resolved.detectionOnly
46706
+ };
46707
+ }
46708
+ function withInvocation(verdict, work) {
46709
+ installRecursionFetch();
46710
+ return invocationContext.run(verdict, work);
46711
+ }
46712
+
46478
46713
  // src/serverless/runtime/adapter.ts
46479
46714
  function resolveApp(mod) {
46480
46715
  const m = mod;
@@ -46566,9 +46801,25 @@ function createHttpHandler(handler8, opts) {
46566
46801
  };
46567
46802
  }
46568
46803
  }
46804
+ const recursion = checkInvocation(event.headers ?? {}, opts?.recursionProtection);
46805
+ if (recursion?.blocked) {
46806
+ const blocked = recursionBlockedResponse(recursion.verdict);
46807
+ return {
46808
+ statusCode: blocked.status,
46809
+ headers: { ...blocked.headers, "retry-after": "60" },
46810
+ body: JSON.stringify(blocked.body),
46811
+ isBase64Encoded: false
46812
+ };
46813
+ }
46569
46814
  const request = eventToRequest(event);
46570
- const response = await handler8(request);
46571
- return responseToResult(response);
46815
+ if (!recursion) {
46816
+ const response = await handler8(request);
46817
+ return responseToResult(response);
46818
+ }
46819
+ if (recursion.observed) {
46820
+ console.warn(`[ts-cloud] recursion detected (${recursion.verdict.reason}) at depth ${recursion.verdict.depth}; detection-only mode let it run`);
46821
+ }
46822
+ return withInvocation(recursion.verdict, async () => responseToResult(await handler8(request)));
46572
46823
  };
46573
46824
  }
46574
46825
  function parseRecordBody(body) {
@@ -46735,7 +46986,7 @@ function buildPhpRuntimeLayerZip(options = {}) {
46735
46986
  }
46736
46987
  // src/serverless-php/package-php.ts
46737
46988
  import { execSync as execSync2 } from "node:child_process";
46738
- import { createHash as createHash5 } from "node:crypto";
46989
+ import { createHash as createHash6 } from "node:crypto";
46739
46990
  import { readdirSync as readdirSync6, readFileSync as readFileSync7, statSync as statSync4 } from "node:fs";
46740
46991
  import { join as join11, relative as relative4, resolve as resolve2 } from "node:path";
46741
46992
  var PHP_DEFAULT_EXCLUDES = [
@@ -46800,7 +47051,7 @@ function packagePhpApp(opts) {
46800
47051
  const handler8 = opts.app.handlers?.http ?? "public/index.php";
46801
47052
  return {
46802
47053
  zip: zip2,
46803
- sha256: createHash5("sha256").update(zip2).digest("hex"),
47054
+ sha256: createHash6("sha256").update(zip2).digest("hex"),
46804
47055
  handlers: {
46805
47056
  http: handler8,
46806
47057
  queue: opts.app.handlers?.queue ?? handler8,
@@ -47253,6 +47504,7 @@ export {
47253
47504
  withSecurity,
47254
47505
  withQueue,
47255
47506
  withMonitoring,
47507
+ withInvocation,
47256
47508
  withDatabase,
47257
47509
  withCache,
47258
47510
  withCDN,
@@ -47280,6 +47532,7 @@ export {
47280
47532
  signRequestAsync,
47281
47533
  signRequest,
47282
47534
  sharedRuntimeLoop,
47535
+ sharedRecursionGuard,
47283
47536
  sha256,
47284
47537
  setStateDir,
47285
47538
  serviceMeshManager,
@@ -47307,6 +47560,7 @@ export {
47307
47560
  resolveServerlessArtifactBucketName,
47308
47561
  resolveServerlessAppStackName,
47309
47562
  resolveRegion,
47563
+ resolveRecursionConfig,
47310
47564
  resolveQueues,
47311
47565
  resolveQueueNames,
47312
47566
  resolveProjectStackName,
@@ -47320,11 +47574,14 @@ export {
47320
47574
  resolveCloudProvider,
47321
47575
  resolveAppDatabase,
47322
47576
  resolveApp,
47577
+ resetRecursionRuntime,
47323
47578
  requiresReplacement,
47324
47579
  replicaManager,
47325
47580
  regionPairManager,
47581
+ recursionBlockedResponse,
47326
47582
  quickHash,
47327
47583
  queueManagementManager,
47584
+ propagationHeaders,
47328
47585
  progressiveDeploymentManager,
47329
47586
  processInChunks,
47330
47587
  previewNotifications,
@@ -47335,6 +47592,7 @@ export {
47335
47592
  performanceManager,
47336
47593
  parseXMLResponse,
47337
47594
  parseJSONResponse,
47595
+ parseChain,
47338
47596
  parallelWithRetry,
47339
47597
  parallelMap,
47340
47598
  parallel,
@@ -47366,6 +47624,9 @@ export {
47366
47624
  isManagementDashboardSiteName,
47367
47625
  isLocalDevelopment,
47368
47626
  isLikelyTypo,
47627
+ invocationContext,
47628
+ installRecursionFetch,
47629
+ inspectInvocation,
47369
47630
  imageScanningManager,
47370
47631
  healthCheckManager,
47371
47632
  hashString,
@@ -47425,6 +47686,7 @@ export {
47425
47686
  generateBootstrap,
47426
47687
  generateApprovalConfig,
47427
47688
  generateAppImageDockerfile,
47689
+ functionFingerprint,
47428
47690
  fromWebIdentity,
47429
47691
  fromSharedCredentials,
47430
47692
  fromEnvironment,
@@ -47501,6 +47763,7 @@ export {
47501
47763
  clearSigningKeyCache,
47502
47764
  chunk,
47503
47765
  checkServiceQuotas,
47766
+ checkInvocation,
47504
47767
  checkIAMPermissions,
47505
47768
  certificateManager,
47506
47769
  categorizeChanges,
@@ -47526,6 +47789,7 @@ export {
47526
47789
  TemplateCache,
47527
47790
  TemplateBuilder,
47528
47791
  TaskList,
47792
+ TRACE_HEADER,
47529
47793
  SyntheticsManager,
47530
47794
  StorageAdvancedManager,
47531
47795
  Storage,
@@ -47556,6 +47820,7 @@ export {
47556
47820
  Registry,
47557
47821
  RegionPairManager,
47558
47822
  Redirects,
47823
+ RecursionGuard,
47559
47824
  RealtimePresets,
47560
47825
  RateLimiter,
47561
47826
  REPLContext,
@@ -47628,8 +47893,10 @@ export {
47628
47893
  DNSSECManager,
47629
47894
  DNS,
47630
47895
  DLQMonitoringManager,
47896
+ DEPTH_HEADER,
47631
47897
  DEFAULT_STATE_DIR,
47632
47898
  DEFAULT_SERVICE_LIMITS,
47899
+ DEFAULT_RECURSION_LIMITS,
47633
47900
  DASHBOARD_STATE_DIR,
47634
47901
  DASHBOARD_PORT_SPAN,
47635
47902
  DASHBOARD_PORT_BASE,
@@ -47650,6 +47917,7 @@ export {
47650
47917
  CanaryManager,
47651
47918
  Cache,
47652
47919
  COMMON_CROSS_ACCOUNT_ROLES,
47920
+ CHAIN_HEADER,
47653
47921
  CDN,
47654
47922
  BuildOptimizationManager,
47655
47923
  BounceComplaintHandler,
@@ -1,3 +1,6 @@
1
+ import type { RecursionProtectionConfig } from './auto-recursion'
2
+ import { checkInvocation, withInvocation } from './auto-recursion'
3
+ import { recursionBlockedResponse } from './recursion'
1
4
  /**
2
5
  * Serverless runtime adapter (Node/Bun).
3
6
  *
@@ -120,6 +123,12 @@ function isTextContentType(contentType: string | null): boolean {
120
123
  // ── HTTP ────────────────────────────────────────────────────────────────────
121
124
 
122
125
  export interface HttpAdapterOptions {
126
+ /**
127
+ * Recursion protection. On by default; pass `false` or set
128
+ * `TS_CLOUD_RECURSION_PROTECTION=0` to disable, or `{ detectionOnly: true }`
129
+ * to watch what it would block before it blocks anything.
130
+ */
131
+ recursionProtection?: RecursionProtectionConfig | false
123
132
  /**
124
133
  * Read maintenance state from the environment. When `MAINTENANCE_MODE` is
125
134
  * truthy, requests get a 503 unless they carry the bypass secret in the
@@ -228,9 +237,33 @@ export function createHttpHandler(handler: FetchHandler | undefined, opts?: Http
228
237
  }
229
238
  }
230
239
 
240
+ // Recursion protection runs after maintenance (a parked app should answer
241
+ // 503 either way) and before the handler, so a loop costs one rejected
242
+ // invocation rather than a full execution plus everything it calls.
243
+ const recursion = checkInvocation(event.headers ?? {}, opts?.recursionProtection)
244
+ if (recursion?.blocked) {
245
+ const blocked = recursionBlockedResponse(recursion.verdict)
246
+ return {
247
+ statusCode: blocked.status,
248
+ headers: { ...blocked.headers, 'retry-after': '60' },
249
+ body: JSON.stringify(blocked.body),
250
+ isBase64Encoded: false,
251
+ }
252
+ }
253
+
231
254
  const request = eventToRequest(event)
232
- const response = await handler(request)
233
- return responseToResult(response)
255
+ if (!recursion) {
256
+ const response = await handler(request)
257
+ return responseToResult(response)
258
+ }
259
+ if (recursion.observed) {
260
+ console.warn(
261
+ `[ts-cloud] recursion detected (${recursion.verdict.reason}) at depth ${recursion.verdict.depth}; detection-only mode let it run`,
262
+ )
263
+ }
264
+ // Run inside the invocation context so outbound fetch carries the chain.
265
+ // Without this the next hop starts a fresh chain and the loop is invisible.
266
+ return withInvocation(recursion.verdict, async () => responseToResult(await handler(request)))
234
267
  }
235
268
  }
236
269
 
@@ -10,3 +10,5 @@ export * from './app-image';
10
10
  export * from './runtime-resolve';
11
11
  export * from './runtimes';
12
12
  export * from './runtime/adapter';
13
+ export * from './runtime/recursion';
14
+ export * from './runtime/auto-recursion';
@@ -1,3 +1,4 @@
1
+ import type { RecursionProtectionConfig } from './auto-recursion';
1
2
  /**
2
3
  * Serverless runtime adapter (Node/Bun).
3
4
  *
@@ -83,6 +84,12 @@ export interface ServerlessApp {
83
84
  */
84
85
  export declare function resolveApp(mod: unknown): ServerlessApp;
85
86
  export interface HttpAdapterOptions {
87
+ /**
88
+ * Recursion protection. On by default; pass `false` or set
89
+ * `TS_CLOUD_RECURSION_PROTECTION=0` to disable, or `{ detectionOnly: true }`
90
+ * to watch what it would block before it blocks anything.
91
+ */
92
+ recursionProtection?: RecursionProtectionConfig | false;
86
93
  /**
87
94
  * Read maintenance state from the environment. When `MAINTENANCE_MODE` is
88
95
  * truthy, requests get a 503 unless they carry the bypass secret in the
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Making recursion protection automatic.
3
+ *
4
+ * `recursion.ts` can detect a loop given an invocation's headers, but detection
5
+ * only helps if something calls it on every invocation and propagates the chain
6
+ * on every outbound request. Asking application authors to do that by hand
7
+ * means it protects the code that already thought about the problem, which is
8
+ * never the code that loops.
9
+ *
10
+ * So the runtime does both:
11
+ *
12
+ * - Every inbound invocation is inspected before the handler runs.
13
+ * - `fetch` is wrapped once, per process, to attach the current invocation's
14
+ * chain to outbound requests.
15
+ *
16
+ * The chain rides in headers, so protection survives the hop between two
17
+ * separate functions and even between two separate deployments. A request that
18
+ * leaves the platform carries the headers too - they are namespaced and inert
19
+ * to anyone who does not read them.
20
+ *
21
+ * **Coverage, stated plainly.** This covers `fetch`, which is what the platform
22
+ * and modern application code use. A handler that reaches for `node:http`
23
+ * directly, or opens a raw socket, is not covered; the depth header still
24
+ * catches those when the receiving side is one of ours, but the chain does not.
25
+ */
26
+ import type { RecursionLimits, RecursionVerdict } from './recursion';
27
+ import { AsyncLocalStorage } from 'node:async_hooks';
28
+ import { RecursionGuard } from './recursion';
29
+ /**
30
+ * The invocation currently on the stack.
31
+ *
32
+ * `AsyncLocalStorage` rather than a module-level variable: a runtime handling
33
+ * concurrent invocations in one process would otherwise attribute one
34
+ * invocation's outbound calls to another's chain, which is both wrong and
35
+ * exactly the case where a loop is hardest to see.
36
+ */
37
+ export declare const invocationContext: AsyncLocalStorage<RecursionVerdict>;
38
+ export interface RecursionProtectionConfig {
39
+ /** Off entirely. The escape hatch for a workload that genuinely re-enters. */
40
+ enabled?: boolean;
41
+ limits?: Partial<RecursionLimits>;
42
+ /**
43
+ * Detect and report, but let the invocation run.
44
+ *
45
+ * The way to roll this out over an existing workload: watch what it would
46
+ * have blocked for a week before it blocks anything.
47
+ */
48
+ detectionOnly?: boolean;
49
+ /** Identifies this function in the chain. Defaults to the Lambda name. */
50
+ functionId?: string;
51
+ }
52
+ /**
53
+ * Resolve configuration from options and the environment.
54
+ *
55
+ * The environment wins, because turning protection off is an operational
56
+ * decision made under pressure - during an incident, from the console, without
57
+ * a redeploy. A config value that could not be overridden that way would be a
58
+ * config value someone works around by deleting the function.
59
+ */
60
+ export declare function resolveRecursionConfig(config?: RecursionProtectionConfig | false): {
61
+ enabled: boolean;
62
+ detectionOnly: boolean;
63
+ limits: RecursionLimits;
64
+ functionId: string;
65
+ };
66
+ /**
67
+ * Wrap `fetch` so outbound calls carry the current chain.
68
+ *
69
+ * Idempotent: a runtime that creates handlers more than once must not stack
70
+ * wrappers, or a chain entry would be appended once per wrapper and a
71
+ * three-hop request would look like a nine-hop loop.
72
+ */
73
+ export declare function installRecursionFetch(): () => void;
74
+ /**
75
+ * One guard per process.
76
+ *
77
+ * The trace budget and the circuit breaker only mean anything if successive
78
+ * invocations in the same container share them - a fresh guard per invocation
79
+ * would reset the counters the loop is being counted with.
80
+ */
81
+ export declare function sharedRecursionGuard(limits: RecursionLimits): RecursionGuard;
82
+ /** Reset process state. Tests need it; nothing in production should call it. */
83
+ export declare function resetRecursionRuntime(): void;
84
+ export interface RecursionCheck {
85
+ verdict: RecursionVerdict;
86
+ /** True when the invocation should be refused. */
87
+ blocked: boolean;
88
+ /** True when a loop was detected but detection-only let it through. */
89
+ observed: boolean;
90
+ }
91
+ /**
92
+ * Inspect an invocation and prepare its context.
93
+ *
94
+ * Returns rather than throws, so the caller decides the response shape - a
95
+ * Lambda HTTP handler answers with a 508 payload while a queue handler needs to
96
+ * fail the record instead.
97
+ */
98
+ export declare function checkInvocation(headers: Record<string, string | undefined> | Headers, config?: RecursionProtectionConfig | false): RecursionCheck | undefined;
99
+ /** Run `work` with the invocation's chain attached to any outbound fetch. */
100
+ export declare function withInvocation<T>(verdict: RecursionVerdict, work: () => Promise<T>): Promise<T>;
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,98 @@
1
+ /** Depth of the current invocation, as an integer string. */
2
+ export declare const DEPTH_HEADER = "x-ts-cloud-invoke-depth";
3
+ /** Dot-separated short hashes of every function in the chain, oldest first. */
4
+ export declare const CHAIN_HEADER = "x-ts-cloud-invoke-chain";
5
+ /** Correlates every hop of one logical request. */
6
+ export declare const TRACE_HEADER = "x-ts-cloud-trace-id";
7
+ /** Chain entries are 8 hex chars: collision-safe enough for a bounded chain, cheap in a header. */
8
+ export declare function functionFingerprint(functionId: string): string;
9
+ export interface RecursionLimits {
10
+ /** Hard ceiling on chain length. Beyond this, block regardless of cycles. */
11
+ maxDepth: number;
12
+ /**
13
+ * How many times one function may appear in a single chain.
14
+ *
15
+ * Not 1: legitimate fan-out patterns re-enter the same handler (a recursive
16
+ * directory walk, a paginated crawl). Two repeats is a pattern; five is a loop.
17
+ */
18
+ maxRepeats: number;
19
+ /** Ceiling on invocations sharing one trace id, across all functions. */
20
+ maxInvocationsPerTrace: number;
21
+ /** How long a trace's tally is remembered. */
22
+ traceWindowMs: number;
23
+ /** Consecutive blocks before the breaker opens for a function. */
24
+ breakerThreshold: number;
25
+ /** How long the breaker stays open. */
26
+ breakerCooldownMs: number;
27
+ }
28
+ export declare const DEFAULT_RECURSION_LIMITS: RecursionLimits;
29
+ export type RecursionReason = 'ok' | 'depth_exceeded' | 'cycle_detected' | 'self_invocation' | 'trace_budget_exceeded' | 'breaker_open';
30
+ export interface InvocationContext {
31
+ functionId: string;
32
+ headers: Record<string, string | undefined> | Headers;
33
+ /** Overrides the header value. Useful when the caller already has a trace. */
34
+ traceId?: string;
35
+ }
36
+ export interface RecursionVerdict {
37
+ allowed: boolean;
38
+ reason: RecursionReason;
39
+ depth: number;
40
+ /** The chain including this invocation. */
41
+ chain: string[];
42
+ traceId: string;
43
+ /** True when the immediate caller was this same function. */
44
+ selfInvocation: boolean;
45
+ /** Times this function already appears in the incoming chain. */
46
+ repeats: number;
47
+ message?: string;
48
+ /** Headers to attach to any call this invocation makes. */
49
+ propagate: Record<string, string>;
50
+ }
51
+ /** Parse the chain header. Bounded and sanitized: a header is attacker-controllable. */
52
+ export declare function parseChain(value: string | undefined, maxEntries?: number): string[];
53
+ /**
54
+ * Inspect an invocation without recording anything.
55
+ *
56
+ * Pure, so a caller can reason about a chain in a test or a dry-run without a
57
+ * guard instance. {@link RecursionGuard.check} adds the stateful backstops.
58
+ */
59
+ export declare function inspectInvocation(context: InvocationContext, limits?: RecursionLimits): Omit<RecursionVerdict, 'allowed' | 'reason' | 'message'> & {
60
+ reason: RecursionReason;
61
+ };
62
+ /**
63
+ * Stateful recursion guard.
64
+ *
65
+ * In-memory on purpose. A loop runs in seconds, so the state that matters is
66
+ * seconds old; paying a database round trip per invocation to protect against
67
+ * a cost problem would be its own cost problem. Each instance protects the
68
+ * process it runs in, and the header chain carries protection across processes.
69
+ */
70
+ export declare class RecursionGuard {
71
+ private readonly limits;
72
+ private readonly clock;
73
+ private readonly traces;
74
+ private readonly breakers;
75
+ constructor(limits?: RecursionLimits, clock?: () => number);
76
+ /** Decide whether an invocation may proceed, and record it if so. */
77
+ check(context: InvocationContext): RecursionVerdict;
78
+ private deny;
79
+ /** Whether the breaker is currently open for a function. */
80
+ breakerOpen(functionId: string): boolean;
81
+ /** Close a breaker manually, e.g. after an operator fixes the loop. */
82
+ reset(functionId?: string): void;
83
+ private expireTraces;
84
+ get trackedTraces(): number;
85
+ }
86
+ /**
87
+ * Headers for an outbound call made from inside a function.
88
+ *
89
+ * Every HTTP client used by platform code should merge these in. Without
90
+ * propagation the chain resets at each hop and the loop becomes invisible.
91
+ */
92
+ export declare function propagationHeaders(verdict: RecursionVerdict): Record<string, string>;
93
+ /** The response a blocked invocation should return: a 508, as the RFC intends. */
94
+ export declare function recursionBlockedResponse(verdict: RecursionVerdict): {
95
+ status: number;
96
+ headers: Record<string, string>;
97
+ body: Record<string, unknown>;
98
+ };
@@ -0,0 +1 @@
1
+ export {};
package/dist/types.d.ts CHANGED
@@ -2543,6 +2543,17 @@ export interface ComputeConfig {
2543
2543
  * primary firewall. @default { enabled: true } for PHP boxes
2544
2544
  */
2545
2545
  firewall?: ComputeFirewallConfig;
2546
+ /**
2547
+ * Kernel-level flood mitigation (nftables + sysctl). On by default; `false`
2548
+ * disables it. UFW decides which ports are open, this decides what happens to
2549
+ * the traffic arriving on them.
2550
+ */
2551
+ ddos?: boolean | ComputeDdosConfig;
2552
+ /**
2553
+ * Web application firewall (zig-waf). On by default in detection-only mode,
2554
+ * so it scores and logs without refusing anything until you promote it.
2555
+ */
2556
+ waf?: boolean | ComputeWafConfig;
2546
2557
  /**
2547
2558
  * Automatic unattended security/system updates (Forge's "maintenance"). When
2548
2559
  * enabled, installs `unattended-upgrades` and enables daily auto-updates.
@@ -2586,7 +2597,78 @@ export interface ComputeMonitoringConfig {
2586
2597
  memPercent?: number;
2587
2598
  /** Alert when root-filesystem usage percentage is ≥ this. @default 90 */
2588
2599
  diskPercent?: number;
2600
+ /**
2601
+ * Monthly bandwidth allowance in TB (decimal, as providers quote it).
2602
+ *
2603
+ * Set it and the collector accumulates month-to-date rx+tx and warns once
2604
+ * {@link bandwidthPercent} of the allowance is used — before the provider's
2605
+ * overage mail. Omit (or 0) to skip bandwidth alerting entirely; the
2606
+ * accounting is still collected either way.
2607
+ */
2608
+ bandwidthTb?: number;
2609
+ /** Alert when month-to-date bandwidth is ≥ this share of the allowance. @default 80 */
2610
+ bandwidthPercent?: number;
2589
2611
  };
2612
+ /**
2613
+ * Endpoints reporting object-storage egress.
2614
+ *
2615
+ * Host network counters cannot see this. When an application redirects
2616
+ * downloads to object storage (or to a CDN in front of it), the bytes never
2617
+ * cross the host's NIC — the box can look idle while the bucket serves
2618
+ * terabytes, and the first sign of an overrun is the provider's invoice. The
2619
+ * only component that knows is the application doing the redirecting, so this
2620
+ * polls it and records what it reports.
2621
+ *
2622
+ * Each endpoint must return an {@link EgressReport}.
2623
+ */
2624
+ egressEndpoints?: EgressEndpointConfig[];
2625
+ }
2626
+ /** An application endpoint reporting its own object-storage egress. */
2627
+ export interface EgressEndpointConfig {
2628
+ /** Short slug identifying this source in metrics (e.g. "registry"). */
2629
+ name: string;
2630
+ /** Absolute URL returning an {@link EgressReport} as JSON. */
2631
+ url: string;
2632
+ /**
2633
+ * Name of an environment variable holding a bearer token for the request.
2634
+ *
2635
+ * The variable name, never the token itself — config is committed, tokens are
2636
+ * not. Omit for a public endpoint.
2637
+ */
2638
+ tokenEnv?: string;
2639
+ }
2640
+ /**
2641
+ * What an {@link EgressEndpointConfig} URL is expected to return.
2642
+ *
2643
+ * Every field is optional and validated on arrival: a partial or malformed
2644
+ * report degrades to fewer recorded series rather than failing the whole
2645
+ * telemetry collection, because losing host metrics to a bad egress endpoint
2646
+ * would be a bad trade.
2647
+ *
2648
+ * `days` is the valuable part. Reporting a per-day history rather than a single
2649
+ * running counter means the collector can replay it — any day missed while the
2650
+ * collector was down still lands, and re-collecting the same day is idempotent
2651
+ * rather than double-counted.
2652
+ */
2653
+ export interface EgressReport {
2654
+ /** UTC day key (YYYY-MM-DD) the `today` figures describe. */
2655
+ today?: string;
2656
+ todayBytes?: number;
2657
+ /** UTC month key (YYYY-MM) the month-to-date figures describe. */
2658
+ month?: string;
2659
+ monthBytes?: number;
2660
+ /** Monthly allowance in bytes; 0 or absent when none is configured. */
2661
+ budgetBytes?: number;
2662
+ /** Share of the allowance consumed, or null when there is no allowance. */
2663
+ budgetUsedPercent?: number | null;
2664
+ /** Straight-line month-end projection in bytes. */
2665
+ projectedMonthBytes?: number;
2666
+ /** Per-day totals, oldest first. */
2667
+ days?: Array<{
2668
+ date: string;
2669
+ bytes: number;
2670
+ downloads?: number;
2671
+ }>;
2590
2672
  }
2591
2673
  /** Host firewall (UFW) configuration. See {@link ComputeConfig.firewall}. */
2592
2674
  export interface ComputeFirewallConfig {
@@ -2595,6 +2677,49 @@ export interface ComputeFirewallConfig {
2595
2677
  /** TCP ports to allow in addition to SSH/80/443 (always allowed). */
2596
2678
  allowedPorts?: number[];
2597
2679
  }
2680
+ /**
2681
+ * Kernel-level flood mitigation (nftables + sysctl).
2682
+ *
2683
+ * On by default. UFW decides which ports are open; this decides what happens to
2684
+ * the traffic arriving on them - SYN floods, connection exhaustion, slow-loris,
2685
+ * and single-source hammering. Set `false` to skip it entirely.
2686
+ */
2687
+ export interface ComputeDdosConfig {
2688
+ enabled?: boolean;
2689
+ /** Ports the ruleset protects. @default [80, 443] */
2690
+ ports?: number[];
2691
+ /** CIDRs that bypass every limit: monitoring, office IPs, a load balancer. */
2692
+ allowlist?: string[];
2693
+ /** CIDRs dropped outright. */
2694
+ blocklist?: string[];
2695
+ /** Count what would be dropped without dropping it. */
2696
+ monitorOnly?: boolean;
2697
+ thresholds?: {
2698
+ newConnectionsPerSecond?: number;
2699
+ concurrentPerSource?: number;
2700
+ synPerSecond?: number;
2701
+ burst?: number;
2702
+ icmpPerSecond?: number;
2703
+ banSeconds?: number;
2704
+ };
2705
+ }
2706
+ /**
2707
+ * Web application firewall (zig-waf), configured at provision time.
2708
+ *
2709
+ * Defaults to `detection`: rules evaluate and matches are logged and scored,
2710
+ * nothing is blocked. Promote to `blocking` once you have read your own
2711
+ * detection log - a ruleset nobody has checked against real traffic will refuse
2712
+ * some of it.
2713
+ */
2714
+ export interface ComputeWafConfig {
2715
+ mode?: 'off' | 'detection' | 'blocking';
2716
+ /** OWASP CRS paranoia level. 1 is the only level safe unattended. */
2717
+ paranoiaLevel?: 1 | 2 | 3 | 4;
2718
+ /** Inbound anomaly score at which a request is blocked in `blocking` mode. */
2719
+ inboundThreshold?: number;
2720
+ /** Paths never inspected. Each one is an unguarded route. */
2721
+ bypassPaths?: string[];
2722
+ }
2598
2723
  /** Scheduled database backup configuration. See {@link ComputeConfig.backups}. */
2599
2724
  export interface ComputeBackupConfig {
2600
2725
  /** Enable scheduled backups. @default false */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ts-cloud/core",
3
3
  "type": "module",
4
- "version": "0.7.105",
4
+ "version": "0.7.109",
5
5
  "description": "Core CloudFormation generation library for ts-cloud",
6
6
  "author": "Chris Breuer <chris@stacksjs.com>",
7
7
  "license": "MIT",
@@ -31,7 +31,7 @@
31
31
  "typecheck": "tsc --noEmit"
32
32
  },
33
33
  "dependencies": {
34
- "@ts-cloud/aws-types": "0.7.105"
34
+ "@ts-cloud/aws-types": "0.7.109"
35
35
  },
36
36
  "devDependencies": {
37
37
  "typescript": "^7.0.2"