@gethelio/proxy 0.12.0 → 0.13.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.
package/dist/index.js CHANGED
@@ -35,6 +35,11 @@ var RESERVED_TRANSPORT_HEADERS = /* @__PURE__ */ new Set([
35
35
  "content-type",
36
36
  "content-length",
37
37
  "host",
38
+ // The Accept is Helio-owned per HTTP upstream leg: where Helio
39
+ // advertises at all it advertises its own response parsing (the SSE
40
+ // message POSTs assert none), so an operator value could only
41
+ // misadvertise it, never extend it (issue #304).
42
+ "accept",
38
43
  // Modern (2026-07-28) transport headers Helio owns on the wire for every
39
44
  // Streamable HTTP POST it sends upstream — relayed client traffic and
40
45
  // proxy-initiated requests (era probe, revalidation) alike — see
@@ -44,8 +49,8 @@ var RESERVED_TRANSPORT_HEADERS = /* @__PURE__ */ new Set([
44
49
  ]);
45
50
  var transportSchema = z.enum(["streamable-http", "sse", "stdio"]);
46
51
  var protocolVersionSchema = z.enum(["auto", "2025-06-18", "2026-07-28"]);
47
- var upstreamSchema = z.object({
48
- url: z.string(),
52
+ var upstreamObjectSchema = z.object({
53
+ url: z.string().optional(),
49
54
  transport: transportSchema.default("streamable-http"),
50
55
  protocol_version: protocolVersionSchema.default("auto"),
51
56
  command: z.string().optional(),
@@ -54,10 +59,22 @@ var upstreamSchema = z.object({
54
59
  request_timeout: durationSchema.default("30s"),
55
60
  forward_headers: z.array(z.string().min(1)).default([]),
56
61
  headers: z.record(z.string(), z.string()).default({})
57
- }).strict().refine((data) => data.transport !== "stdio" || data.command !== void 0, {
58
- message: '"command" is required when transport is "stdio"',
59
- path: ["command"]
60
- }).superRefine((data, ctx) => {
62
+ }).strict();
63
+ function upstreamEntryChecks(data, ctx) {
64
+ if (data.transport === "stdio" && data.command === void 0) {
65
+ ctx.addIssue({
66
+ code: "custom",
67
+ path: ["command"],
68
+ message: '"command" is required when transport is "stdio"'
69
+ });
70
+ }
71
+ if (data.transport !== "stdio" && data.url === void 0) {
72
+ ctx.addIssue({
73
+ code: "custom",
74
+ path: ["url"],
75
+ message: `"url" is required when transport is "${data.transport}"`
76
+ });
77
+ }
61
78
  if (data.protocol_version === "2026-07-28" && data.transport !== "streamable-http") {
62
79
  ctx.addIssue({
63
80
  code: "custom",
@@ -83,6 +100,26 @@ var upstreamSchema = z.object({
83
100
  });
84
101
  }
85
102
  }
103
+ }
104
+ var upstreamSchema = upstreamObjectSchema.superRefine(upstreamEntryChecks);
105
+ var upstreamNameSchema = z.string().min(1).max(64).regex(/^[a-zA-Z0-9_-]+$/, {
106
+ message: 'Upstream names may only contain letters, digits, "_" and "-"'
107
+ });
108
+ var namedUpstreamEntrySchema = z.object({ name: upstreamNameSchema, ...upstreamObjectSchema.shape }).strict().superRefine(upstreamEntryChecks);
109
+ var upstreamsListSchema = z.array(namedUpstreamEntrySchema).min(1, {
110
+ message: 'upstreams: must declare at least one upstream \u2014 an empty list would serve nothing. For a single upstream you can keep the "upstream:" form.'
111
+ }).superRefine((entries, ctx) => {
112
+ const seen = /* @__PURE__ */ new Set();
113
+ for (const [index, entry] of entries.entries()) {
114
+ if (seen.has(entry.name)) {
115
+ ctx.addIssue({
116
+ code: "custom",
117
+ path: [index, "name"],
118
+ message: `Duplicate upstream name "${entry.name}". Upstream names embed in mount paths, limiter keys, and audit records \u2014 each upstream needs its own.`
119
+ });
120
+ }
121
+ seen.add(entry.name);
122
+ }
86
123
  });
87
124
  var listenSchema = z.object({
88
125
  port: z.number().int().min(1).max(65535).default(3e3),
@@ -241,7 +278,11 @@ var matchSchema = z.object({
241
278
  annotations: annotationsMatchSchema.optional(),
242
279
  input: z.record(z.string(), inputConditionSchema).optional(),
243
280
  environment: z.string().optional(),
244
- metadata: z.record(z.string(), metadataConditionSchema).optional()
281
+ metadata: z.record(z.string(), metadataConditionSchema).optional(),
282
+ /** Configured upstream names the rule is scoped to (issue #293). */
283
+ upstreams: z.array(z.string().min(1)).min(1, {
284
+ message: "match.upstreams must name at least one upstream \u2014 an empty list matches nothing."
285
+ }).optional()
245
286
  }).strict();
246
287
  var policyActionSchema = z.enum([
247
288
  "allow",
@@ -364,7 +405,11 @@ var budgetContributorMatchSchema = z.object({
364
405
  // Same operators and AND-combination as rule `match.input`. Other rule
365
406
  // matchers (annotations, environment, metadata) stay strict-rejected
366
407
  // until the budget charge context can actually evaluate them.
367
- input: z.record(z.string(), inputConditionSchema).optional()
408
+ input: z.record(z.string(), inputConditionSchema).optional(),
409
+ /** Configured upstream names the contributor is scoped to (issue #293). */
410
+ upstreams: z.array(z.string().min(1)).min(1, {
411
+ message: "match.upstreams must name at least one upstream \u2014 an empty list matches nothing."
412
+ }).optional()
368
413
  }).strict();
369
414
  var modernBudgetContributorSchema = z.object({
370
415
  match: budgetContributorMatchSchema,
@@ -476,9 +521,7 @@ var sdkSchema = z.object({
476
521
  */
477
522
  evaluation_ttl: durationSchema.default("10m")
478
523
  }).strict();
479
- var helioConfigBaseSchema = z.object({
480
- version: z.literal("1"),
481
- upstream: upstreamSchema,
524
+ var rootSectionSchemas = {
482
525
  listen: listenSchema.prefault({}),
483
526
  environment: z.string().optional(),
484
527
  // Session precedes policies deliberately: upstream/listen/environment say
@@ -495,6 +538,16 @@ var helioConfigBaseSchema = z.object({
495
538
  // the request path (canonical section order, #89/#163).
496
539
  dashboard: dashboardSchema.prefault({}),
497
540
  sdk: sdkSchema.prefault({})
541
+ };
542
+ var singularConfigBase = z.object({
543
+ version: z.literal("1"),
544
+ upstream: upstreamSchema,
545
+ ...rootSectionSchemas
546
+ }).strict();
547
+ var namedConfigBase = z.object({
548
+ version: z.literal("1"),
549
+ upstreams: upstreamsListSchema,
550
+ ...rootSectionSchemas
498
551
  }).strict();
499
552
  function stripRootExtensionKeys(value) {
500
553
  if (value === null || typeof value !== "object" || Array.isArray(value)) return value;
@@ -502,7 +555,7 @@ function stripRootExtensionKeys(value) {
502
555
  Object.entries(value).filter(([key]) => !key.startsWith("x-"))
503
556
  );
504
557
  }
505
- var helioConfigRefinedSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
558
+ function rootConfigChecks(cfg, ctx) {
506
559
  const hasConfiguredEnvironment = typeof cfg.environment === "string" && cfg.environment.trim().length > 0;
507
560
  const requiresSecret = cfg.policies.flag_destructive === "require_approval" || cfg.policies.on_tool_drift === "require_approval" || cfg.policies.rules.some((rule) => rule.action === "require_approval") || cfg.budgets.some((budget) => budget.on_exceed === "require_approval");
508
561
  const hasSecret = hasDashboardApiSecret(cfg.dashboard.api_secret);
@@ -511,7 +564,7 @@ var helioConfigRefinedSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
511
564
  ctx.addIssue({
512
565
  code: "custom",
513
566
  path: ["dashboard", "api_secret"],
514
- message: 'dashboard.api_secret is required when any rule uses require_approval, any budget uses on_exceed: require_approval, or policies.flag_destructive or policies.on_tool_drift is "require_approval". Generate one with: `openssl rand -hex 32` and set it under `dashboard.api_secret` in your helio.yaml. (See docs/approvals.md.)'
567
+ message: 'dashboard.api_secret is required when any rule uses require_approval, any budget uses on_exceed: require_approval, or policies.flag_destructive or policies.on_tool_drift is "require_approval". Run `helio secret` and set the printed digest under `dashboard.api_secret` in your helio.yaml (a plaintext value is accepted but warned about at startup). (See docs/approvals.md.)'
515
568
  });
516
569
  }
517
570
  }
@@ -519,7 +572,7 @@ var helioConfigRefinedSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
519
572
  ctx.addIssue({
520
573
  code: "custom",
521
574
  path: ["dashboard", "api_secret"],
522
- message: "dashboard.api_secret is required when dashboard.enabled is true unless dashboard.allow_open_mode is explicitly set to true. Generate one with `openssl rand -hex 32` and set it under dashboard.api_secret in helio.yaml."
575
+ message: "dashboard.api_secret is required when dashboard.enabled is true unless dashboard.allow_open_mode is explicitly set to true. Run `helio secret` and set the printed digest under dashboard.api_secret in helio.yaml."
523
576
  });
524
577
  }
525
578
  if (!requiresSecret && cfg.dashboard.enabled && !hasSecret && cfg.dashboard.allow_open_mode && !isLoopbackHost(cfg.dashboard.host)) {
@@ -722,11 +775,170 @@ var helioConfigRefinedSchema = helioConfigBaseSchema.superRefine((cfg, ctx) => {
722
775
  }
723
776
  }
724
777
  }
778
+ }
779
+ function upstreamVocabularyChecks(cfg, ctx, configuredNames) {
780
+ for (const [ruleIndex, rule] of cfg.policies.rules.entries()) {
781
+ const upstreams = rule.match.upstreams;
782
+ if (upstreams === void 0) continue;
783
+ if (configuredNames === null) {
784
+ ctx.addIssue({
785
+ code: "custom",
786
+ path: ["policies", "rules", ruleIndex, "match", "upstreams"],
787
+ message: 'Rule sets match.upstreams but the config declares a single "upstream:", which has no name on purpose. Upstream-scoped rules require the named "upstreams:" list.'
788
+ });
789
+ } else {
790
+ for (const [entryIndex, name] of upstreams.entries()) {
791
+ if (!configuredNames.has(name)) {
792
+ ctx.addIssue({
793
+ code: "custom",
794
+ path: ["policies", "rules", ruleIndex, "match", "upstreams", entryIndex],
795
+ message: `Rule names upstream "${name}" in match.upstreams but no configured upstream has that name. Every entry must name an upstream from the upstreams: list.`
796
+ });
797
+ }
798
+ }
799
+ }
800
+ if (rule.match.metadata !== void 0) {
801
+ ctx.addIssue({
802
+ code: "custom",
803
+ path: ["policies", "rules", ruleIndex, "match", "upstreams"],
804
+ message: "match.upstreams cannot be combined with match.metadata \u2014 metadata rules only match on the sideband (host) path and upstream-scoped rules only on the MCP path, so the combination can never match. Split it into two rules."
805
+ });
806
+ }
807
+ if (rule.limits?.key === "sender_id") {
808
+ ctx.addIssue({
809
+ code: "custom",
810
+ path: ["policies", "rules", ruleIndex, "limits", "key"],
811
+ message: 'limits.key "sender_id" cannot be combined with match.upstreams \u2014 an upstream-scoped rule only matches on the MCP path, where sender_id is absent and the key would silently collapse to tool scope.'
812
+ });
813
+ }
814
+ if (rule.limits?.max_spend?.key === "sender_id") {
815
+ ctx.addIssue({
816
+ code: "custom",
817
+ path: ["policies", "rules", ruleIndex, "limits", "max_spend", "key"],
818
+ message: 'limits.max_spend.key "sender_id" cannot be combined with match.upstreams \u2014 an upstream-scoped rule only matches on the MCP path, where sender_id is absent and the key would silently collapse to tool scope.'
819
+ });
820
+ }
821
+ }
822
+ for (const [budgetIndex, budget] of cfg.budgets.entries()) {
823
+ let hasUnscopedContributor = false;
824
+ for (const [contributorIndex, contributor] of budget.contributors.entries()) {
825
+ const upstreams = contributor.match?.upstreams;
826
+ if (upstreams === void 0) {
827
+ hasUnscopedContributor = true;
828
+ continue;
829
+ }
830
+ if (configuredNames === null) {
831
+ ctx.addIssue({
832
+ code: "custom",
833
+ path: ["budgets", budgetIndex, "contributors", contributorIndex, "match", "upstreams"],
834
+ message: 'Contributor sets match.upstreams but the config declares a single "upstream:", which has no name on purpose. Upstream-scoped contributors require the named "upstreams:" list.'
835
+ });
836
+ } else {
837
+ for (const [entryIndex, name] of upstreams.entries()) {
838
+ if (!configuredNames.has(name)) {
839
+ ctx.addIssue({
840
+ code: "custom",
841
+ path: [
842
+ "budgets",
843
+ budgetIndex,
844
+ "contributors",
845
+ contributorIndex,
846
+ "match",
847
+ "upstreams",
848
+ entryIndex
849
+ ],
850
+ message: `Contributor names upstream "${name}" in match.upstreams but no configured upstream has that name. Every entry must name an upstream from the upstreams: list.`
851
+ });
852
+ }
853
+ }
854
+ }
855
+ }
856
+ if (budget.key === "sender_id" && !hasUnscopedContributor) {
857
+ ctx.addIssue({
858
+ code: "custom",
859
+ path: ["budgets", budgetIndex, "key"],
860
+ message: 'budget key "sender_id" requires at least one contributor without an "upstreams" scope \u2014 upstream-scoped contributors only match MCP calls, which never carry a sender, so every charge would land in the shared "unknown" pot while sideband calls (the only ones with real senders) never feed this budget.'
861
+ });
862
+ }
863
+ }
864
+ if (configuredNames !== null) {
865
+ const hasEvidenceGatedRule = cfg.policies.rules.some(
866
+ (rule) => (rule.evidence?.requires.length ?? 0) > 0 || (rule.requires?.length ?? 0) > 0
867
+ );
868
+ if (hasEvidenceGatedRule) {
869
+ const legacyIndex = cfg.session.identity.findIndex(
870
+ (source) => source.source === "legacy_header"
871
+ );
872
+ if (legacyIndex !== -1) {
873
+ ctx.addIssue({
874
+ code: "custom",
875
+ path: ["session", "identity", legacyIndex],
876
+ message: `session.identity includes "legacy_header" while named upstreams and evidence-gated rules ("evidence"/"requires") are configured. On the legacy relay flow the Mcp-Session-Id a client echoes was minted by the upstream itself, so with multiple upstreams a hostile server could collide session identities across doors and pollute another door's evidence gates. Remove legacy_header from session.identity and use a caller-owned source such as the default "x-helio-session-id" header.`
877
+ });
878
+ }
879
+ }
880
+ }
881
+ }
882
+ var singularConfigSchema = singularConfigBase.superRefine((cfg, ctx) => {
883
+ rootConfigChecks(cfg, ctx);
884
+ upstreamVocabularyChecks(cfg, ctx, null);
885
+ });
886
+ var namedConfigSchema = namedConfigBase.superRefine((cfg, ctx) => {
887
+ rootConfigChecks(cfg, ctx);
888
+ upstreamVocabularyChecks(cfg, ctx, new Set(cfg.upstreams.map((entry) => entry.name)));
725
889
  });
726
- var helioConfigSchema = z.preprocess(stripRootExtensionKeys, helioConfigRefinedSchema);
890
+ var objectRootSchema = z.object({});
891
+ function dispatchByMode(raw, ctx) {
892
+ const isObject2 = raw !== null && typeof raw === "object" && !Array.isArray(raw);
893
+ if (!isObject2) {
894
+ const typeResult = objectRootSchema.safeParse(raw);
895
+ if (!typeResult.success) {
896
+ for (const issue of typeResult.error.issues) {
897
+ ctx.addIssue(issue);
898
+ }
899
+ }
900
+ return z.NEVER;
901
+ }
902
+ const hasUpstream = "upstream" in raw;
903
+ const hasUpstreams = "upstreams" in raw;
904
+ if (hasUpstream && hasUpstreams) {
905
+ ctx.addIssue({
906
+ code: "custom",
907
+ path: ["upstreams"],
908
+ message: 'Set exactly one of "upstream:" (single upstream) or "upstreams:" (named multi-upstream list) \u2014 not both. To migrate, move the upstream: fields into an upstreams: entry and give it a name.'
909
+ });
910
+ return z.NEVER;
911
+ }
912
+ if (!hasUpstream && !hasUpstreams) {
913
+ ctx.addIssue({
914
+ code: "custom",
915
+ message: 'Missing upstream configuration: set exactly one of "upstream:" (single upstream) or "upstreams:" (named multi-upstream list).'
916
+ });
917
+ return z.NEVER;
918
+ }
919
+ const result = hasUpstreams ? namedConfigSchema.safeParse(raw) : singularConfigSchema.safeParse(raw);
920
+ if (!result.success) {
921
+ for (const issue of result.error.issues) {
922
+ ctx.addIssue(issue);
923
+ }
924
+ return z.NEVER;
925
+ }
926
+ return result.data;
927
+ }
928
+ var helioConfigSchema = z.preprocess(
929
+ stripRootExtensionKeys,
930
+ z.unknown().transform(dispatchByMode)
931
+ );
932
+ function isSingularConfig(config) {
933
+ return !("upstreams" in config);
934
+ }
935
+ function isNamedConfig(config) {
936
+ return "upstreams" in config;
937
+ }
727
938
 
728
939
  // src/config/loader.ts
729
940
  import { readFile } from "fs/promises";
941
+ import { createHash } from "crypto";
730
942
  import yaml from "js-yaml";
731
943
 
732
944
  // src/util/format-zod-errors.ts
@@ -746,36 +958,42 @@ var ConfigError = class extends Error {
746
958
  }
747
959
  };
748
960
  var ENV_VAR_PATTERN = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
749
- function interpolateEnvVars(value, env = process.env) {
961
+ function interpolateTracked(value, env, path, out) {
750
962
  if (typeof value === "string") {
751
- return value.replace(ENV_VAR_PATTERN, (_match, varName) => {
963
+ let substitutions = 0;
964
+ const result = value.replace(ENV_VAR_PATTERN, (_match, varName) => {
752
965
  const envValue = env[varName];
753
966
  if (envValue === void 0) {
754
967
  throw new ConfigError(`Environment variable "${varName}" is not set`);
755
968
  }
969
+ substitutions += 1;
756
970
  return envValue;
757
971
  });
972
+ if (substitutions > 0 && out !== void 0) out.push(path.join("."));
973
+ return result;
758
974
  }
759
975
  if (Array.isArray(value)) {
760
- return value.map((item) => interpolateEnvVars(item, env));
976
+ return value.map((item, index) => interpolateTracked(item, env, [...path, String(index)], out));
761
977
  }
762
978
  if (value !== null && typeof value === "object") {
763
979
  return Object.fromEntries(
764
980
  Object.entries(value).map(([k, v]) => [
765
981
  k,
766
- interpolateEnvVars(v, env)
982
+ interpolateTracked(v, env, [...path, k], out)
767
983
  ])
768
984
  );
769
985
  }
770
986
  return value;
771
987
  }
772
- async function loadConfig(filePath, env) {
773
- let raw;
988
+ async function loadConfigWithMeta(filePath, env) {
989
+ let bytes;
774
990
  try {
775
- raw = await readFile(filePath, "utf-8");
991
+ bytes = await readFile(filePath);
776
992
  } catch {
777
993
  throw new ConfigError(`Cannot read config file: ${filePath}`);
778
994
  }
995
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
996
+ const raw = bytes.toString("utf-8");
779
997
  let parsed;
780
998
  try {
781
999
  parsed = yaml.load(raw);
@@ -783,7 +1001,8 @@ async function loadConfig(filePath, env) {
783
1001
  const message = err instanceof Error ? err.message : String(err);
784
1002
  throw new ConfigError(`YAML parse error in ${filePath}: ${message}`);
785
1003
  }
786
- const interpolated = interpolateEnvVars(parsed, env);
1004
+ const interpolatedPaths = [];
1005
+ const interpolated = interpolateTracked(parsed, env ?? process.env, [], interpolatedPaths);
787
1006
  const result = helioConfigSchema.safeParse(interpolated);
788
1007
  if (!result.success) {
789
1008
  const details = formatZodErrors(result.error).map(
@@ -795,7 +1014,10 @@ async function loadConfig(filePath, env) {
795
1014
  details
796
1015
  );
797
1016
  }
798
- return result.data;
1017
+ return { config: result.data, sha256, interpolatedPaths };
1018
+ }
1019
+ async function loadConfig(filePath, env) {
1020
+ return (await loadConfigWithMeta(filePath, env)).config;
799
1021
  }
800
1022
 
801
1023
  // src/config/watcher.ts
@@ -904,7 +1126,8 @@ function compileMatch(match, ruleIndex, ruleName) {
904
1126
  ...match.environment !== void 0 && { environment: match.environment },
905
1127
  ...match.metadata !== void 0 && {
906
1128
  metadata: flattenMetadataConditions(match.metadata, ruleIndex, ruleName)
907
- }
1129
+ },
1130
+ ...match.upstreams !== void 0 && { upstreams: [...match.upstreams] }
908
1131
  };
909
1132
  }
910
1133
  function compileToolMatcher(pattern, ruleIndex, ruleName) {
@@ -1107,6 +1330,9 @@ function compileContributor(contributor, budgetName, index) {
1107
1330
  ) : void 0;
1108
1331
  return {
1109
1332
  match: { tool, ...input !== void 0 && { input } },
1333
+ ...contributor.match.upstreams !== void 0 && {
1334
+ upstreams: [...contributor.match.upstreams]
1335
+ },
1110
1336
  field: contributor.field
1111
1337
  };
1112
1338
  }
@@ -1371,6 +1597,8 @@ function buildStandardRequestHeaders(method, params) {
1371
1597
  }
1372
1598
 
1373
1599
  // src/upstream/merge-headers.ts
1600
+ var UPSTREAM_POST_ACCEPT = "application/json, text/event-stream";
1601
+ var UPSTREAM_SSE_CONNECT_ACCEPT = "text/event-stream";
1374
1602
  function mergeUpstreamHeaders(base, forwarded, staticHeaders) {
1375
1603
  const out = {};
1376
1604
  const apply = (headers) => {
@@ -1384,6 +1612,11 @@ function mergeUpstreamHeaders(base, forwarded, staticHeaders) {
1384
1612
  return out;
1385
1613
  }
1386
1614
 
1615
+ // src/util/log-label.ts
1616
+ function helioLogTag(upstreamName) {
1617
+ return upstreamName ? `[helio][${upstreamName}]` : "[helio]";
1618
+ }
1619
+
1387
1620
  // src/upstream/connection-error.ts
1388
1621
  var UPSTREAM_DOCS_URL = "https://github.com/gethelio/helio/blob/main/docs/getting-started.md";
1389
1622
  var UNREACHABLE_CODES = /* @__PURE__ */ new Set([
@@ -1419,7 +1652,7 @@ function describeUnreachableUpstream(error, url) {
1419
1652
  }
1420
1653
  const codeSuffix = code ? ` (${code})` : "";
1421
1654
  return new Error(
1422
- `Upstream MCP server at ${url} is unreachable${codeSuffix} \u2014 is it running? Helio proxies an existing MCP server: set upstream.url in helio.yaml to a reachable server, or start the server it points at. See ${UPSTREAM_DOCS_URL}`
1655
+ `Upstream MCP server at ${url} is unreachable${codeSuffix} \u2014 is it running? Helio proxies an existing MCP server: set upstream.url (or upstreams[].url) in helio.yaml to a reachable server, or start the server it points at. See ${UPSTREAM_DOCS_URL}`
1423
1656
  );
1424
1657
  }
1425
1658
 
@@ -1526,11 +1759,13 @@ var UpstreamSessionManager = class {
1526
1759
  inflight;
1527
1760
  inflightProbe;
1528
1761
  probeBackoffUntil = 0;
1762
+ logTag;
1529
1763
  constructor(options) {
1530
1764
  this.url = options.url;
1531
1765
  this.staticHeaders = options.staticHeaders;
1532
1766
  this.requestTimeoutMs = options.requestTimeoutMs ?? 3e4;
1533
1767
  this.pin = options.protocolVersion ?? "auto";
1768
+ this.logTag = helioLogTag(options.upstreamName);
1534
1769
  }
1535
1770
  /** Return the internal session, establishing it once if needed. */
1536
1771
  ensureInternalSession() {
@@ -1662,7 +1897,7 @@ var UpstreamSessionManager = class {
1662
1897
  this.capture = void 0;
1663
1898
  this.probeBackoffUntil = Date.now() + ERA_PROBE_BACKOFF_MS;
1664
1899
  console.error(
1665
- `[helio] Upstream MCP era cleared: ${door}; relays presume legacy and re-probing is throttled for ${String(ERA_PROBE_BACKOFF_MS / 1e3)}s`
1900
+ `${this.logTag} Upstream MCP era cleared: ${door}; relays presume legacy and re-probing is throttled for ${String(ERA_PROBE_BACKOFF_MS / 1e3)}s`
1666
1901
  );
1667
1902
  }
1668
1903
  /** Convert a fetch failure into an actionable error for the given step. */
@@ -1706,7 +1941,7 @@ var UpstreamSessionManager = class {
1706
1941
  if (this.era === era) return;
1707
1942
  this.era = era;
1708
1943
  console.error(
1709
- era === "modern" ? `[helio] Upstream MCP era detected: modern (${HELIO_MCP_MODERN_PROTOCOL_VERSION}, via server/discover)` : "[helio] Upstream MCP era detected: legacy (initialize handshake)"
1944
+ era === "modern" ? `${this.logTag} Upstream MCP era detected: modern (${HELIO_MCP_MODERN_PROTOCOL_VERSION}, via server/discover)` : `${this.logTag} Upstream MCP era detected: legacy (initialize handshake)`
1710
1945
  );
1711
1946
  }
1712
1947
  /** A modern upstream neither mints nor echoes session ids — nothing to hold. */
@@ -1730,7 +1965,7 @@ var UpstreamSessionManager = class {
1730
1965
  const headers = mergeUpstreamHeaders(
1731
1966
  {
1732
1967
  "content-type": "application/json",
1733
- accept: "application/json, text/event-stream",
1968
+ accept: UPSTREAM_POST_ACCEPT,
1734
1969
  "mcp-protocol-version": HELIO_MCP_MODERN_PROTOCOL_VERSION,
1735
1970
  "mcp-method": "server/discover"
1736
1971
  },
@@ -1739,6 +1974,7 @@ var UpstreamSessionManager = class {
1739
1974
  );
1740
1975
  headers["mcp-method"] = "server/discover";
1741
1976
  delete headers["mcp-name"];
1977
+ headers["accept"] = UPSTREAM_POST_ACCEPT;
1742
1978
  const probeBody = {
1743
1979
  jsonrpc: "2.0",
1744
1980
  id: ERA_PROBE_REQUEST_ID,
@@ -1811,13 +2047,14 @@ var UpstreamSessionManager = class {
1811
2047
  const headers = mergeUpstreamHeaders(
1812
2048
  {
1813
2049
  "content-type": "application/json",
1814
- accept: "application/json, text/event-stream"
2050
+ accept: UPSTREAM_POST_ACCEPT
1815
2051
  },
1816
2052
  {},
1817
2053
  this.staticHeaders
1818
2054
  );
1819
2055
  delete headers["mcp-method"];
1820
2056
  delete headers["mcp-name"];
2057
+ headers["accept"] = UPSTREAM_POST_ACCEPT;
1821
2058
  const initBody = {
1822
2059
  jsonrpc: "2.0",
1823
2060
  id: 0,
@@ -2431,6 +2668,7 @@ function createSseRoute(forwarder, options = {}) {
2431
2668
  const forwardHeaderAllowlist = options.forwardHeadersAllowlist ?? [];
2432
2669
  const sessionIdentity = options.session ?? DEFAULT_SESSION_IDENTITY;
2433
2670
  const maxConcurrentSessions = options.maxConcurrentSessions ?? MAX_CONCURRENT_SESSIONS;
2671
+ const routeLabel = options.routeLabel ?? "/sse";
2434
2672
  let refusalCount = 0;
2435
2673
  let lastRefusalLogAt = null;
2436
2674
  const logRefusal = () => {
@@ -2439,7 +2677,7 @@ function createSseRoute(forwarder, options = {}) {
2439
2677
  if (lastRefusalLogAt !== null && now - lastRefusalLogAt < REFUSAL_LOG_WINDOW_MS) return;
2440
2678
  lastRefusalLogAt = now;
2441
2679
  console.error(
2442
- `[helio] /sse at session cap (${String(maxConcurrentSessions)}); refusing new streams (${String(refusalCount)} refusals so far).`
2680
+ `[helio] ${routeLabel} at session cap (${String(maxConcurrentSessions)}); refusing new streams (${String(refusalCount)} refusals so far).`
2443
2681
  );
2444
2682
  };
2445
2683
  app.use("*", createOriginGuard(options.allowedOrigins ?? []));
@@ -2645,6 +2883,11 @@ function createServerHandle(server) {
2645
2883
  };
2646
2884
  }
2647
2885
  function createApp(config, forwarder, options) {
2886
+ if (isNamedConfig(config)) {
2887
+ throw new Error(
2888
+ "createApp serves a single-upstream (upstream:) config only. Named multi-upstream configs are composed by createMultiApp."
2889
+ );
2890
+ }
2648
2891
  const app = new Hono3();
2649
2892
  const forwardHeadersAllowlist = config.upstream.forward_headers;
2650
2893
  const allowedOrigins = config.listen.allowed_origins;
@@ -2665,6 +2908,77 @@ function createApp(config, forwarder, options) {
2665
2908
  }
2666
2909
  return app;
2667
2910
  }
2911
+ function createMultiApp(config, forwarders, options) {
2912
+ if (!isNamedConfig(config)) {
2913
+ throw new Error(
2914
+ "createMultiApp composes a named multi-upstream (upstreams:) config only. Singular configs are served by createApp."
2915
+ );
2916
+ }
2917
+ const doors = [];
2918
+ const missing = [];
2919
+ for (const entry of config.upstreams) {
2920
+ const forwarder = forwarders[entry.name];
2921
+ if (forwarder === void 0) missing.push(entry.name);
2922
+ else doors.push({ entry, forwarder });
2923
+ }
2924
+ const configured = new Set(config.upstreams.map((entry) => entry.name));
2925
+ const unexpected = Object.keys(forwarders).filter((name) => !configured.has(name));
2926
+ if (missing.length > 0 || unexpected.length > 0) {
2927
+ throw new Error(
2928
+ `createMultiApp forwarders must match the configured upstream names exactly \u2014 missing: [${missing.join(", ")}], unexpected: [${unexpected.join(", ")}].`
2929
+ );
2930
+ }
2931
+ const app = new Hono3();
2932
+ const allowedOrigins = config.listen.allowed_origins;
2933
+ const session = compileSessionIdentity(config.session);
2934
+ app.get("/healthz", (c) => c.json({ status: "ok" }));
2935
+ for (const { entry, forwarder } of doors) {
2936
+ const name = entry.name;
2937
+ app.route(
2938
+ `/mcp/${name}`,
2939
+ createStreamableHttpRoute(forwarder, {
2940
+ forwardHeadersAllowlist: entry.forward_headers,
2941
+ allowedOrigins,
2942
+ session,
2943
+ onHeaderMismatch: options?.onHeaderMismatch ? (rejection) => options.onHeaderMismatch?.(rejection, name) : void 0
2944
+ })
2945
+ );
2946
+ app.route(
2947
+ `/sse/${name}`,
2948
+ createSseRoute(forwarder, {
2949
+ forwardHeadersAllowlist: entry.forward_headers,
2950
+ allowedOrigins,
2951
+ session,
2952
+ routeLabel: `/sse/${name}`,
2953
+ maxConcurrentSessions: options?.sse?.maxConcurrentSessions
2954
+ })
2955
+ );
2956
+ }
2957
+ if (options?.slackActionApp) {
2958
+ app.route("/slack/actions", options.slackActionApp);
2959
+ }
2960
+ app.all(
2961
+ "/mcp/*",
2962
+ (c) => c.json(
2963
+ makeJsonRpcErrorWithoutId(
2964
+ INVALID_REQUEST,
2965
+ "No MCP endpoint answers this request: this Helio serves named upstreams at /mcp/<name>."
2966
+ ),
2967
+ 404
2968
+ )
2969
+ );
2970
+ app.all(
2971
+ "/sse/*",
2972
+ (c) => c.json(
2973
+ makeJsonRpcErrorWithoutId(
2974
+ INVALID_REQUEST,
2975
+ "No MCP endpoint answers this request: this Helio serves named upstreams at /sse/<name>."
2976
+ ),
2977
+ 404
2978
+ )
2979
+ );
2980
+ return app;
2981
+ }
2668
2982
  function startServer(app, config) {
2669
2983
  const server = serve({
2670
2984
  fetch: app.fetch,
@@ -2718,7 +3032,8 @@ var StreamableHttpForwarder = class {
2718
3032
  url: this.url,
2719
3033
  staticHeaders: this.staticHeaders,
2720
3034
  requestTimeoutMs: this.requestTimeoutMs,
2721
- protocolVersion: options.protocolVersion
3035
+ protocolVersion: options.protocolVersion,
3036
+ upstreamName: options.upstreamName
2722
3037
  });
2723
3038
  }
2724
3039
  /** Lifecycle parity with sse/stdio. No eager connect — sessions are lazy. */
@@ -2891,11 +3206,15 @@ var StreamableHttpForwarder = class {
2891
3206
  const headers = mergeUpstreamHeaders(
2892
3207
  {
2893
3208
  "content-type": "application/json",
2894
- accept: "application/json, text/event-stream"
3209
+ accept: UPSTREAM_POST_ACCEPT
2895
3210
  },
2896
3211
  request.headers ?? {},
2897
3212
  this.staticHeaders
2898
3213
  );
3214
+ headers["content-type"] = "application/json";
3215
+ delete headers["content-length"];
3216
+ headers["accept"] = UPSTREAM_POST_ACCEPT;
3217
+ delete headers["mcp-session-id"];
2899
3218
  if (session.sessionId) headers["mcp-session-id"] = session.sessionId;
2900
3219
  if (modern) {
2901
3220
  delete headers["mcp-session-id"];
@@ -3070,13 +3389,16 @@ var SseUpstreamForwarder = class {
3070
3389
  connect() {
3071
3390
  const controller = new AbortController();
3072
3391
  this.abortController = controller;
3392
+ const headers = mergeUpstreamHeaders(
3393
+ { accept: UPSTREAM_SSE_CONNECT_ACCEPT },
3394
+ {},
3395
+ this.staticHeaders
3396
+ );
3397
+ headers["accept"] = UPSTREAM_SSE_CONNECT_ACCEPT;
3073
3398
  return new Promise((resolve, reject) => {
3074
3399
  let resolved = false;
3075
3400
  fetch(this.url, {
3076
- headers: {
3077
- accept: "text/event-stream",
3078
- ...this.staticHeaders
3079
- },
3401
+ headers,
3080
3402
  signal: AbortSignal.any([controller.signal, AbortSignal.timeout(this.connectTimeoutMs)])
3081
3403
  }).then((res) => {
3082
3404
  if (!res.ok) {
@@ -3128,9 +3450,12 @@ var SseUpstreamForwarder = class {
3128
3450
  request.headers ?? {},
3129
3451
  this.staticHeaders
3130
3452
  );
3453
+ headers["content-type"] = "application/json";
3454
+ delete headers["content-length"];
3131
3455
  delete headers["mcp-method"];
3132
3456
  delete headers["mcp-name"];
3133
3457
  delete headers["mcp-session-id"];
3458
+ delete headers["accept"];
3134
3459
  if (request.transportSessionId) {
3135
3460
  headers["mcp-session-id"] = request.transportSessionId;
3136
3461
  }
@@ -3297,6 +3622,7 @@ var StdioForwarder = class {
3297
3622
  maxRetries;
3298
3623
  retryDelayMs;
3299
3624
  pending;
3625
+ logTag;
3300
3626
  child = null;
3301
3627
  buffer = "";
3302
3628
  retryCount = 0;
@@ -3308,6 +3634,7 @@ var StdioForwarder = class {
3308
3634
  this.maxRetries = options.maxRetries ?? DEFAULT_MAX_RETRIES;
3309
3635
  this.retryDelayMs = options.retryDelayMs ?? DEFAULT_RETRY_DELAY_MS;
3310
3636
  this.pending = new PendingRequests(options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS2);
3637
+ this.logTag = helioLogTag(options.upstreamName);
3311
3638
  }
3312
3639
  /** Spawn the child process and set up event handlers. */
3313
3640
  start() {
@@ -3436,7 +3763,9 @@ var StdioForwarder = class {
3436
3763
  }, this.retryDelayMs);
3437
3764
  } else {
3438
3765
  this.dead = true;
3439
- console.error(`[helio] Stdio forwarder: max retries (${String(this.maxRetries)}) exceeded`);
3766
+ console.error(
3767
+ `${this.logTag} Stdio forwarder: max retries (${String(this.maxRetries)}) exceeded`
3768
+ );
3440
3769
  this.pending.rejectAll(new Error("stdio forwarder is dead (max retries exceeded)"));
3441
3770
  }
3442
3771
  }
@@ -3511,6 +3840,10 @@ function matchEnvironment(required, ctx) {
3511
3840
  if (ctx.environment === void 0) return false;
3512
3841
  return ctx.environment === required;
3513
3842
  }
3843
+ function matchUpstreams(required, ctx) {
3844
+ if (ctx.upstream === void 0) return false;
3845
+ return required.includes(ctx.upstream);
3846
+ }
3514
3847
  function matchMetadata(conditions, ctx) {
3515
3848
  if (conditions.length === 0) return true;
3516
3849
  if (ctx.metadata === void 0) return false;
@@ -3536,6 +3869,7 @@ function matchRule(rule, ctx) {
3536
3869
  if (match.input !== void 0 && !matchInput(match.input, ctx)) return false;
3537
3870
  if (match.environment !== void 0 && !matchEnvironment(match.environment, ctx)) return false;
3538
3871
  if (match.metadata !== void 0 && !matchMetadata(match.metadata, ctx)) return false;
3872
+ if (match.upstreams !== void 0 && !matchUpstreams(match.upstreams, ctx)) return false;
3539
3873
  return true;
3540
3874
  }
3541
3875
 
@@ -3690,7 +4024,8 @@ function decide(input) {
3690
4024
  annotations,
3691
4025
  toolArguments,
3692
4026
  environment,
3693
- metadata
4027
+ metadata,
4028
+ upstream: input.upstream
3694
4029
  });
3695
4030
  if (driftEvent && driftMode === "log") {
3696
4031
  const currentDecision = evaluatePolicy(policy, {
@@ -3698,7 +4033,8 @@ function decide(input) {
3698
4033
  annotations: input.currentAnnotations,
3699
4034
  toolArguments,
3700
4035
  environment,
3701
- metadata
4036
+ metadata,
4037
+ upstream: input.upstream
3702
4038
  });
3703
4039
  decision = stricterDecision(decision, currentDecision);
3704
4040
  }
@@ -4311,394 +4647,83 @@ function buildBudgetApprovalTimeoutFeedback(decision, breaches, timeoutMs) {
4311
4647
  };
4312
4648
  }
4313
4649
 
4314
- // src/policy/spend-limiter.ts
4315
- function spendBucketKey(baseKey, ruleIndex) {
4650
+ // src/policy/bucket-key.ts
4651
+ function ruleBucketKey(baseKey, ruleIndex) {
4316
4652
  return `${baseKey}:rule:${String(ruleIndex)}`;
4317
4653
  }
4318
4654
  var RULE_SUFFIX_RE = /:rule:(\d+)$/;
4319
- var SpendLimiter = class {
4320
- buckets = /* @__PURE__ */ new Map();
4321
- now;
4322
- onWarning;
4323
- warningThreshold;
4324
- timer = null;
4325
- closed = false;
4326
- constructor(options = {}) {
4327
- this.now = options.now ?? Date.now;
4328
- this.onWarning = options.onWarning;
4329
- this.warningThreshold = options.warningThreshold ?? 0.8;
4330
- const intervalMs = options.cleanupIntervalMs ?? 6e4;
4331
- if (intervalMs > 0) {
4332
- this.timer = setInterval(() => {
4333
- this.cleanup();
4334
- }, intervalMs);
4335
- this.timer.unref();
4655
+ function parseRuleIndex(key) {
4656
+ const match = RULE_SUFFIX_RE.exec(key);
4657
+ return match ? Number(match[1]) : void 0;
4658
+ }
4659
+ function toolLimitKey(toolName, upstreamName) {
4660
+ return upstreamName ? `upstream:${upstreamName}:tool:${toolName}` : `tool:${toolName}`;
4661
+ }
4662
+
4663
+ // src/policy/governed-forwarder.ts
4664
+ var POLICY_DENIED = -32001;
4665
+ function blocked(result) {
4666
+ return { proceed: false, result, approvalWaitMs: 0 };
4667
+ }
4668
+ function budgetChainBlock(entry, kind) {
4669
+ return {
4670
+ name: entry.budget.name,
4671
+ bucket_key: entry.bucketKey,
4672
+ allowed: entry.allowed,
4673
+ amount: entry.amount,
4674
+ spent: entry.spent,
4675
+ limit: entry.budget.limit,
4676
+ remaining: entry.remaining,
4677
+ currency: entry.budget.currency,
4678
+ ...kind ? { kind } : {},
4679
+ ...entry.stale ? { stale: true } : {}
4680
+ };
4681
+ }
4682
+ var GovernedForwarder = class {
4683
+ inner;
4684
+ policy;
4685
+ environment;
4686
+ session;
4687
+ auditWriter;
4688
+ evidenceStore;
4689
+ approvalRouter;
4690
+ rateLimiter;
4691
+ spendLimiter;
4692
+ budgetEngine;
4693
+ upstreamName;
4694
+ annotationCache = new ToolAnnotationCache();
4695
+ agentKeyWarned = false;
4696
+ senderKeyWarned = false;
4697
+ constructor(inner, policy, options) {
4698
+ this.inner = inner;
4699
+ this.policy = policy;
4700
+ this.environment = options?.environment;
4701
+ this.auditWriter = options?.auditWriter;
4702
+ this.evidenceStore = options?.evidenceStore;
4703
+ this.approvalRouter = options?.approvalRouter;
4704
+ this.rateLimiter = options?.rateLimiter;
4705
+ this.spendLimiter = options?.spendLimiter;
4706
+ this.budgetEngine = options?.budgetEngine;
4707
+ this.upstreamName = options?.upstreamName;
4708
+ this.session = options?.session ?? DEFAULT_SESSION_IDENTITY;
4709
+ if (this.evidenceStore) {
4710
+ this.evidenceStore.setAllowedEvidenceKeys(collectAllowedEvidenceKeys(policy));
4336
4711
  }
4337
4712
  }
4338
- // -------------------------------------------------------------------------
4339
- // Core operations
4340
- // -------------------------------------------------------------------------
4341
4713
  /**
4342
- * Check and optionally record a spend against the limit.
4714
+ * Swap the compiled policy atomically and reconcile limit bucket state
4715
+ * against the new configuration.
4343
4716
  *
4344
- * Evicts expired entries, sums remaining amounts, then checks:
4345
- * - Under limit (currentSpend + amount <= limit): records and returns `allowed: true`
4346
- * - Would exceed: does NOT record (rejected spends don't consume budget)
4347
- */
4348
- check(params) {
4349
- const { key, amount, limit, windowMs } = params;
4350
- const now = this.now();
4351
- const windowStart = now - windowMs;
4352
- if (!Number.isFinite(amount) || amount < 0) {
4353
- const existing = this.buckets.get(key);
4354
- const activeEntries = existing ? existing.entries.filter((e) => e.timestamp > windowStart) : [];
4355
- const currentSpend2 = activeEntries.reduce((sum, e) => sum + e.amount, 0);
4356
- const oldest = activeEntries[0];
4357
- return {
4358
- allowed: false,
4359
- currentSpend: currentSpend2,
4360
- limit,
4361
- windowMs,
4362
- resetAtMs: oldest ? oldest.timestamp + windowMs : 0,
4363
- reason: "invalid_amount"
4364
- };
4365
- }
4366
- let bucket = this.buckets.get(key);
4367
- if (!bucket) {
4368
- bucket = { entries: [], limit, currency: "", windowMs };
4369
- this.buckets.set(key, bucket);
4370
- }
4371
- bucket.limit = limit;
4372
- bucket.windowMs = windowMs;
4373
- bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
4374
- const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
4375
- if (currentSpend + amount > limit) {
4376
- const oldest = bucket.entries[0];
4377
- return {
4378
- allowed: false,
4379
- currentSpend,
4380
- limit,
4381
- windowMs,
4382
- resetAtMs: oldest ? oldest.timestamp + windowMs : 0
4383
- };
4384
- }
4385
- bucket.entries.push({ timestamp: now, amount });
4386
- const newSpend = currentSpend + amount;
4387
- const resetAtMs = (bucket.entries[0]?.timestamp ?? now) + windowMs;
4388
- if (this.onWarning && newSpend / limit >= this.warningThreshold) {
4389
- this.safeWarn({
4390
- key,
4391
- current_spend: newSpend,
4392
- limit,
4393
- currency: bucket.currency,
4394
- window_ms: windowMs,
4395
- reset_at_ms: resetAtMs
4396
- });
4397
- }
4398
- return {
4399
- allowed: true,
4400
- currentSpend: newSpend,
4401
- limit,
4402
- windowMs,
4403
- resetAtMs
4404
- };
4405
- }
4406
- /**
4407
- * Unconditionally record a spend against the limit.
4408
- *
4409
- * Unlike check(), this always appends the amount — even when it pushes the
4410
- * window past the limit — because the spend it represents has already been
4411
- * incurred. The sideband peeks at /evaluate and commits here at /audit once
4412
- * the external call ran (issue #12, D3).
4413
- *
4414
- * Throws on a negative or non-finite amount: such amounts are rejected at
4415
- * /evaluate, so one reaching record() is a logic bug we surface loudly rather
4416
- * than silently corrupt the sliding-window sum. Warnings fire only while the
4417
- * post-append spend stays within the limit (parity with check()).
4418
- */
4419
- record(params) {
4420
- const { key, amount, limit, windowMs } = params;
4421
- if (!Number.isFinite(amount) || amount < 0) {
4422
- throw new RangeError(
4423
- `SpendLimiter.record() received an invalid amount (${String(amount)}); invalid amounts must be rejected at /evaluate, never committed`
4424
- );
4425
- }
4426
- const now = this.now();
4427
- const windowStart = now - windowMs;
4428
- let bucket = this.buckets.get(key);
4429
- if (!bucket) {
4430
- bucket = { entries: [], limit, currency: "", windowMs };
4431
- this.buckets.set(key, bucket);
4432
- }
4433
- bucket.limit = limit;
4434
- bucket.windowMs = windowMs;
4435
- bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
4436
- bucket.entries.push({ timestamp: now, amount });
4437
- const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
4438
- const resetAtMs = (bucket.entries[0]?.timestamp ?? now) + windowMs;
4439
- if (this.onWarning && currentSpend <= limit && currentSpend / limit >= this.warningThreshold) {
4440
- this.safeWarn({
4441
- key,
4442
- current_spend: currentSpend,
4443
- limit,
4444
- currency: bucket.currency,
4445
- window_ms: windowMs,
4446
- reset_at_ms: resetAtMs
4447
- });
4448
- }
4449
- return {
4450
- allowed: currentSpend <= limit,
4451
- currentSpend,
4452
- limit,
4453
- windowMs,
4454
- resetAtMs
4455
- };
4456
- }
4457
- /**
4458
- * Check the spend limit without recording the spend (non-destructive).
4459
- *
4460
- * Used by dry-run mode to determine what would happen without consuming
4461
- * budget in the bucket.
4462
- */
4463
- peek(params) {
4464
- const { key, amount, limit, windowMs } = params;
4465
- const now = this.now();
4466
- const windowStart = now - windowMs;
4467
- const bucket = this.buckets.get(key);
4468
- if (!Number.isFinite(amount) || amount < 0) {
4469
- const activeEntries2 = bucket ? bucket.entries.filter((e) => e.timestamp > windowStart) : [];
4470
- const currentSpend2 = activeEntries2.reduce((sum, e) => sum + e.amount, 0);
4471
- const oldest2 = activeEntries2[0];
4472
- return {
4473
- allowed: false,
4474
- currentSpend: currentSpend2,
4475
- limit,
4476
- windowMs,
4477
- resetAtMs: oldest2 ? oldest2.timestamp + windowMs : 0,
4478
- reason: "invalid_amount"
4479
- };
4480
- }
4481
- if (!bucket) {
4482
- const wouldExceed = amount > limit;
4483
- return {
4484
- allowed: !wouldExceed,
4485
- currentSpend: wouldExceed ? 0 : amount,
4486
- limit,
4487
- windowMs,
4488
- resetAtMs: now + windowMs
4489
- };
4490
- }
4491
- const activeEntries = bucket.entries.filter((e) => e.timestamp > windowStart);
4492
- const currentSpend = activeEntries.reduce((sum, e) => sum + e.amount, 0);
4493
- if (currentSpend + amount > limit) {
4494
- const oldest2 = activeEntries[0];
4495
- return {
4496
- allowed: false,
4497
- currentSpend,
4498
- limit,
4499
- windowMs,
4500
- resetAtMs: oldest2 ? oldest2.timestamp + windowMs : 0
4501
- };
4502
- }
4503
- const newSpend = currentSpend + amount;
4504
- const oldest = activeEntries[0];
4505
- return {
4506
- allowed: true,
4507
- currentSpend: newSpend,
4508
- limit,
4509
- windowMs,
4510
- resetAtMs: oldest ? oldest.timestamp + windowMs : now + windowMs
4511
- };
4512
- }
4513
- /**
4514
- * Set the display currency for a key. Called by the governed forwarder
4515
- * after check() so dashboard reads include the currency label.
4516
- */
4517
- setCurrency(key, currency) {
4518
- const bucket = this.buckets.get(key);
4519
- if (bucket) bucket.currency = currency;
4520
- }
4521
- // -------------------------------------------------------------------------
4522
- // Read operations (for dashboard API)
4523
- // -------------------------------------------------------------------------
4524
- /** Get the current state of a single key. Returns undefined if not tracked. */
4525
- getKeyState(key) {
4526
- const bucket = this.buckets.get(key);
4527
- if (!bucket) return void 0;
4528
- const windowStart = this.now() - bucket.windowMs;
4529
- bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
4530
- if (bucket.entries.length === 0) {
4531
- this.buckets.delete(key);
4532
- return void 0;
4533
- }
4534
- const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
4535
- return {
4536
- key,
4537
- current_spend: currentSpend,
4538
- limit: bucket.limit,
4539
- currency: bucket.currency,
4540
- window_ms: bucket.windowMs,
4541
- reset_at_ms: (bucket.entries[0]?.timestamp ?? 0) + bucket.windowMs
4542
- };
4543
- }
4544
- /** List all tracked keys with their current state. */
4545
- listKeyStates() {
4546
- const states = [];
4547
- for (const key of [...this.buckets.keys()]) {
4548
- const state = this.getKeyState(key);
4549
- if (state) states.push(state);
4550
- }
4551
- return states;
4552
- }
4553
- // -------------------------------------------------------------------------
4554
- // Maintenance
4555
- // -------------------------------------------------------------------------
4556
- /** Sweep all buckets: remove expired entries, delete empty buckets. */
4557
- cleanup() {
4558
- const now = this.now();
4559
- for (const [key, bucket] of this.buckets) {
4560
- const windowStart = now - bucket.windowMs;
4561
- bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
4562
- if (bucket.entries.length === 0) {
4563
- this.buckets.delete(key);
4564
- }
4565
- }
4566
- }
4567
- /** Clear all spend limit state. Called on policy hot-reload. */
4568
- reset() {
4569
- this.buckets.clear();
4570
- }
4571
- /**
4572
- * Reconcile bucket state against a new policy's spend configuration.
4573
- *
4574
- * Walks every existing bucket and checks whether its last-seen
4575
- * `{ limit, currency, windowMs }` tuple still appears in `validConfigs`.
4576
- * Buckets whose config is unchanged are left untouched — cumulative spend
4577
- * and elapsed-window progress are preserved across hot-reloads. Buckets
4578
- * whose config is gone (rule changed or removed) are evicted so the next
4579
- * check lazy-creates a fresh bucket under the new config.
4580
- *
4581
- * Keys built by {@link spendBucketKey} carry the owning rule's index, and
4582
- * for those the tuple must match at THAT index (`config.ruleIndex`): a
4583
- * reorder that shifts a spend rule's index evicts its old-index bucket
4584
- * instead of leaving an orphan no rule reads again — or worse, letting
4585
- * whatever rule now sits at that index adopt another rule's accrued spend.
4586
- * Un-suffixed keys keep the tuple-anywhere match.
4587
- *
4588
- * Currency is part of the tuple because a USD→EUR switch is a meaningful
4589
- * policy change — the same numeric limit buys a different amount of real
4590
- * spend, so the bucket must reset. This replaces the old `reset()` call
4591
- * on every hot-reload, which wiped all state even when the matching rule
4592
- * was unchanged.
4593
- */
4594
- reconcile(validConfigs) {
4595
- const valid = /* @__PURE__ */ new Set();
4596
- const byIndex = /* @__PURE__ */ new Map();
4597
- for (const config of validConfigs) {
4598
- const tuple = `${String(config.limit)}|${config.currency}|${String(config.windowMs)}`;
4599
- if (config.ruleIndex === void 0) {
4600
- valid.add(tuple);
4601
- } else {
4602
- byIndex.set(config.ruleIndex, tuple);
4603
- }
4604
- }
4605
- for (const [key, bucket] of this.buckets) {
4606
- const tuple = `${String(bucket.limit)}|${bucket.currency}|${String(bucket.windowMs)}`;
4607
- const suffix = RULE_SUFFIX_RE.exec(key);
4608
- const survives = suffix ? byIndex.get(Number(suffix[1])) === tuple : valid.has(tuple);
4609
- if (!survives) {
4610
- this.buckets.delete(key);
4611
- }
4612
- }
4613
- }
4614
- /** Stop the cleanup timer and mark as closed. */
4615
- /**
4616
- * Invoke the warning callback without letting a subscriber throw into the
4617
- * limiter's caller: a warning fires after state has already mutated, and a
4618
- * governed call must not be blocked (or double-charged on retry) by an
4619
- * observability bug.
4620
- */
4621
- safeWarn(state) {
4622
- if (!this.onWarning) return;
4623
- try {
4624
- this.onWarning(state);
4625
- } catch (err) {
4626
- console.error("[helio] limit warning subscriber threw:", err);
4627
- }
4628
- }
4629
- close() {
4630
- if (this.closed) return;
4631
- this.closed = true;
4632
- if (this.timer) {
4633
- clearInterval(this.timer);
4634
- this.timer = null;
4635
- }
4636
- this.buckets.clear();
4637
- }
4638
- };
4639
-
4640
- // src/policy/governed-forwarder.ts
4641
- var POLICY_DENIED = -32001;
4642
- function blocked(result) {
4643
- return { proceed: false, result, approvalWaitMs: 0 };
4644
- }
4645
- function budgetChainBlock(entry, kind) {
4646
- return {
4647
- name: entry.budget.name,
4648
- bucket_key: entry.bucketKey,
4649
- allowed: entry.allowed,
4650
- amount: entry.amount,
4651
- spent: entry.spent,
4652
- limit: entry.budget.limit,
4653
- remaining: entry.remaining,
4654
- currency: entry.budget.currency,
4655
- ...kind ? { kind } : {},
4656
- ...entry.stale ? { stale: true } : {}
4657
- };
4658
- }
4659
- var GovernedForwarder = class {
4660
- inner;
4661
- policy;
4662
- environment;
4663
- session;
4664
- auditWriter;
4665
- evidenceStore;
4666
- approvalRouter;
4667
- rateLimiter;
4668
- spendLimiter;
4669
- budgetEngine;
4670
- annotationCache = new ToolAnnotationCache();
4671
- agentKeyWarned = false;
4672
- senderKeyWarned = false;
4673
- constructor(inner, policy, options) {
4674
- this.inner = inner;
4675
- this.policy = policy;
4676
- this.environment = options?.environment;
4677
- this.auditWriter = options?.auditWriter;
4678
- this.evidenceStore = options?.evidenceStore;
4679
- this.approvalRouter = options?.approvalRouter;
4680
- this.rateLimiter = options?.rateLimiter;
4681
- this.spendLimiter = options?.spendLimiter;
4682
- this.budgetEngine = options?.budgetEngine;
4683
- this.session = options?.session ?? DEFAULT_SESSION_IDENTITY;
4684
- if (this.evidenceStore) {
4685
- this.evidenceStore.setAllowedEvidenceKeys(collectAllowedEvidenceKeys(policy));
4686
- }
4687
- }
4688
- /**
4689
- * Swap the compiled policy atomically and reconcile limit bucket state
4690
- * against the new configuration.
4691
- *
4692
- * Rate and spend limit buckets are preserved when their underlying rule
4693
- * config is unchanged — this is what makes a benign hot-reload (e.g. a
4694
- * `vim :w` with no real edits, or a whitespace-only config change) safe:
4695
- * operators do not get a surprise zero of their live rate/spend state
4696
- * mid-window. Buckets whose config changed or whose rule was removed are
4697
- * evicted by the limiters' `reconcile()` methods, so the next check
4698
- * lazy-creates a fresh bucket under the new config.
4699
- *
4700
- * See `packages/proxy/src/policy/rate-limiter.ts` and `spend-limiter.ts`
4701
- * for the per-bucket compare-and-evict semantics.
4717
+ * Rate and spend limit buckets are preserved when their underlying rule
4718
+ * config is unchanged this is what makes a benign hot-reload (e.g. a
4719
+ * `vim :w` with no real edits, or a whitespace-only config change) safe:
4720
+ * operators do not get a surprise zero of their live rate/spend state
4721
+ * mid-window. Buckets whose config changed or whose rule was removed are
4722
+ * evicted by the limiters' `reconcile()` methods, so the next check
4723
+ * lazy-creates a fresh bucket under the new config.
4724
+ *
4725
+ * See `packages/proxy/src/policy/rate-limiter.ts` and `spend-limiter.ts`
4726
+ * for the per-bucket compare-and-evict semantics.
4702
4727
  */
4703
4728
  updatePolicy(policy) {
4704
4729
  this.policy = policy;
@@ -4710,7 +4735,13 @@ var GovernedForwarder = class {
4710
4735
  for (const rule of policy.rules) {
4711
4736
  const limits = rule.limits;
4712
4737
  if (limits?.maxCalls !== void 0 && limits.windowMs !== void 0) {
4713
- rateConfigs.push({ maxCalls: limits.maxCalls, windowMs: limits.windowMs });
4738
+ rateConfigs.push({
4739
+ maxCalls: limits.maxCalls,
4740
+ windowMs: limits.windowMs,
4741
+ // Rate bucket keys are rule-discriminated (ruleBucketKey), so
4742
+ // reconcile must match tuples at the owning rule's index.
4743
+ ruleIndex: rule.index
4744
+ });
4714
4745
  }
4715
4746
  }
4716
4747
  this.rateLimiter.reconcile(rateConfigs);
@@ -4724,7 +4755,7 @@ var GovernedForwarder = class {
4724
4755
  limit: maxSpend.limit,
4725
4756
  currency: maxSpend.currency,
4726
4757
  windowMs: maxSpend.windowMs,
4727
- // Spend bucket keys are rule-discriminated (spendBucketKey), so
4758
+ // Spend bucket keys are rule-discriminated (ruleBucketKey), so
4728
4759
  // reconcile must match tuples at the owning rule's index.
4729
4760
  ruleIndex: rule.index
4730
4761
  });
@@ -4870,7 +4901,8 @@ var GovernedForwarder = class {
4870
4901
  origin: "mcp",
4871
4902
  metadata: null,
4872
4903
  // Drift is a cache event, not a request: no protocol claim exists.
4873
- protocol_version: null
4904
+ protocol_version: null,
4905
+ upstream: this.upstreamName ?? null
4874
4906
  });
4875
4907
  }
4876
4908
  async handleToolsCall(original) {
@@ -4914,7 +4946,8 @@ var GovernedForwarder = class {
4914
4946
  evidenceStore: this.evidenceStore,
4915
4947
  baselineAnnotations: this.annotationCache.get(toolName),
4916
4948
  currentAnnotations: this.annotationCache.getCurrent(toolName),
4917
- driftEvent: this.annotationCache.getDrift(toolName)
4949
+ driftEvent: this.annotationCache.getDrift(toolName),
4950
+ upstream: this.upstreamName
4918
4951
  });
4919
4952
  const auditRecordId = randomUUID2();
4920
4953
  let result;
@@ -5057,6 +5090,8 @@ var GovernedForwarder = class {
5057
5090
  tool_input: toolArguments ?? {},
5058
5091
  matched_rule: decision.matchedRule,
5059
5092
  session_id: request.session?.id ?? null,
5093
+ session_source: request.session?.source ?? null,
5094
+ upstream: this.upstreamName ?? null,
5060
5095
  breached_budgets: gate.breachContexts,
5061
5096
  approval: gate.approval
5062
5097
  },
@@ -5204,8 +5239,9 @@ var GovernedForwarder = class {
5204
5239
  toolName,
5205
5240
  toolArguments,
5206
5241
  sessionId: sessionGate.ok ? sessionGate.session : null,
5207
- senderId: null
5242
+ senderId: null,
5208
5243
  // adapter context; absent on the MCP path
5244
+ upstream: this.upstreamName ?? null
5209
5245
  });
5210
5246
  if (charges.length === 0 && failures.length === 0) return { kind: "proceed" };
5211
5247
  const gated = gateBudgetCharges({ charges, failures }, sessionGate);
@@ -5351,7 +5387,8 @@ var GovernedForwarder = class {
5351
5387
  record_kind: "tool_call",
5352
5388
  origin: "mcp",
5353
5389
  metadata: null,
5354
- protocol_version: request.protocolVersion ?? null
5390
+ protocol_version: request.protocolVersion ?? null,
5391
+ upstream: this.upstreamName ?? null
5355
5392
  });
5356
5393
  }
5357
5394
  return result;
@@ -5364,7 +5401,9 @@ var GovernedForwarder = class {
5364
5401
  tool_name: toolName,
5365
5402
  tool_input: toolArguments ?? {},
5366
5403
  matched_rule: decision.matchedRule,
5367
- session_id: request.session?.id ?? null
5404
+ session_id: request.session?.id ?? null,
5405
+ session_source: request.session?.source ?? null,
5406
+ upstream: this.upstreamName ?? null
5368
5407
  },
5369
5408
  request.signal
5370
5409
  );
@@ -5446,8 +5485,9 @@ var GovernedForwarder = class {
5446
5485
  }
5447
5486
  handleRateLimit(request, decision, toolName) {
5448
5487
  const limiter = this.rateLimiter;
5449
- const limits = decision.matchedRule?.limits;
5450
- if (!limits?.maxCalls || !limits.windowMs) {
5488
+ const matchedRule = decision.matchedRule;
5489
+ const limits = matchedRule?.limits;
5490
+ if (!matchedRule || !limits?.maxCalls || !limits.windowMs) {
5451
5491
  const result = this.makePolicyMisconfiguredResult(
5452
5492
  request,
5453
5493
  decision,
@@ -5460,7 +5500,7 @@ var GovernedForwarder = class {
5460
5500
  rateLimitResult: { allowed: false, current: 0, limit: 0, windowMs: 0, resetAtMs: 0 }
5461
5501
  };
5462
5502
  }
5463
- let key;
5503
+ let baseKey;
5464
5504
  if (limits.key === "session") {
5465
5505
  const sessionKey = this.gateSessionLimitKey(request);
5466
5506
  if (sessionKey === null) {
@@ -5470,15 +5510,16 @@ var GovernedForwarder = class {
5470
5510
  approvalWaitMs: 0
5471
5511
  };
5472
5512
  }
5473
- key = sessionKey;
5513
+ baseKey = sessionKey;
5474
5514
  } else {
5475
- key = this.buildLimitKey(limits.key, toolName);
5515
+ baseKey = this.buildLimitKey(limits.key, toolName);
5476
5516
  }
5517
+ const key = ruleBucketKey(baseKey, matchedRule.index);
5477
5518
  const params = { key, maxCalls: limits.maxCalls, windowMs: limits.windowMs };
5478
5519
  const rateLimitResult = limiter.peek(params);
5479
5520
  if (!rateLimitResult.allowed) {
5480
5521
  const feedback = buildRateLimitedFeedback(decision, rateLimitResult);
5481
- const message = decision.matchedRule?.feedback?.message ?? `Rate limit exceeded for ${key}`;
5522
+ const message = matchedRule.feedback?.message ?? `Rate limit exceeded for ${key}`;
5482
5523
  return {
5483
5524
  proceed: false,
5484
5525
  result: makeErrorResult(request, POLICY_DENIED, message, { ...feedback }),
@@ -5525,7 +5566,7 @@ var GovernedForwarder = class {
5525
5566
  } else {
5526
5567
  baseKey = this.buildLimitKey(maxSpend.key, toolName);
5527
5568
  }
5528
- const key = spendBucketKey(baseKey, decision.matchedRule.index);
5569
+ const key = ruleBucketKey(baseKey, decision.matchedRule.index);
5529
5570
  const rawAmount = resolvePath(maxSpend.field, toolArguments ?? {});
5530
5571
  if (typeof rawAmount !== "number") {
5531
5572
  console.error(
@@ -5598,14 +5639,14 @@ var GovernedForwarder = class {
5598
5639
  case "rate_limit":
5599
5640
  if (this.rateLimiter && decision.matchedRule?.limits?.maxCalls && decision.matchedRule.limits.windowMs) {
5600
5641
  const limits = decision.matchedRule.limits;
5601
- const key = limits.key === "session" ? this.gateSessionLimitKey(request) : this.buildLimitKey(limits.key, toolName);
5602
- if (key === null) {
5642
+ const baseKey = limits.key === "session" ? this.gateSessionLimitKey(request) : this.buildLimitKey(limits.key, toolName);
5643
+ if (baseKey === null) {
5603
5644
  wouldForward = false;
5604
5645
  limitsOk = false;
5605
5646
  sessionUnresolved = true;
5606
5647
  } else {
5607
5648
  const peekResult = this.rateLimiter.peek({
5608
- key,
5649
+ key: ruleBucketKey(baseKey, decision.matchedRule.index),
5609
5650
  maxCalls: decision.matchedRule.limits.maxCalls,
5610
5651
  windowMs: decision.matchedRule.limits.windowMs
5611
5652
  });
@@ -5638,7 +5679,7 @@ var GovernedForwarder = class {
5638
5679
  sessionUnresolved = true;
5639
5680
  } else {
5640
5681
  const peekResult = this.spendLimiter.peek({
5641
- key: spendBucketKey(baseKey, decision.matchedRule.index),
5682
+ key: ruleBucketKey(baseKey, decision.matchedRule.index),
5642
5683
  amount: rawAmount,
5643
5684
  limit: maxSpend.limit,
5644
5685
  windowMs: maxSpend.windowMs
@@ -5658,7 +5699,8 @@ var GovernedForwarder = class {
5658
5699
  toolName,
5659
5700
  toolArguments,
5660
5701
  sessionId: sessionGate.ok ? sessionGate.session : null,
5661
- senderId: null
5702
+ senderId: null,
5703
+ upstream: this.upstreamName ?? null
5662
5704
  });
5663
5705
  if (failures.length > 0 || charges.length > 0) {
5664
5706
  const gated = gateBudgetCharges({ charges, failures }, sessionGate);
@@ -5699,7 +5741,9 @@ var GovernedForwarder = class {
5699
5741
  );
5700
5742
  }
5701
5743
  /**
5702
- * Construct a non-session limit bucket key. Session keys are deliberately
5744
+ * Construct a non-session limit bucket key. Tool-scope keys route through
5745
+ * the shared `toolLimitKey` leaf, which prefixes them with the configured
5746
+ * upstream name when one is set (issue #295). Session keys are deliberately
5703
5747
  * NOT built here: they come only from the gate module's `sessionLimitKey`,
5704
5748
  * whose `GatedSession` parameter makes skipping the identity gate a
5705
5749
  * compile error (issue #218) — call sites branch on `key === 'session'`.
@@ -5713,7 +5757,7 @@ var GovernedForwarder = class {
5713
5757
  '[helio] Warning: limits.key "agent" is not yet supported, falling back to "tool"'
5714
5758
  );
5715
5759
  }
5716
- return `tool:${toolName}`;
5760
+ return toolLimitKey(toolName, this.upstreamName);
5717
5761
  case "sender_id":
5718
5762
  if (!this.senderKeyWarned) {
5719
5763
  this.senderKeyWarned = true;
@@ -5721,10 +5765,10 @@ var GovernedForwarder = class {
5721
5765
  '[helio] Warning: limits.key "sender_id" has no sender on the MCP path, falling back to "tool"'
5722
5766
  );
5723
5767
  }
5724
- return `tool:${toolName}`;
5768
+ return toolLimitKey(toolName, this.upstreamName);
5725
5769
  case "tool":
5726
5770
  default:
5727
- return `tool:${toolName}`;
5771
+ return toolLimitKey(toolName, this.upstreamName);
5728
5772
  }
5729
5773
  }
5730
5774
  /**
@@ -5879,7 +5923,8 @@ var GovernedForwarder = class {
5879
5923
  record_kind: "tool_call",
5880
5924
  origin: "mcp",
5881
5925
  metadata: null,
5882
- protocol_version: request.protocolVersion ?? null
5926
+ protocol_version: request.protocolVersion ?? null,
5927
+ upstream: this.upstreamName ?? null
5883
5928
  };
5884
5929
  const isEnforcementDecision = !isDryRun && (!forwarded || approvalOutcome !== void 0 || budgetApproval !== void 0);
5885
5930
  if (isEnforcementDecision) {
@@ -5950,113 +5995,376 @@ var GovernedForwarder = class {
5950
5995
  headers: { "content-type": "application/json" },
5951
5996
  body
5952
5997
  };
5953
- return { response, durationMs: 0 };
5954
- }
5955
- makeEvidenceBlockResult(request, decision, evidenceResult, dependencyResult) {
5956
- const reason = evidenceResult && !evidenceResult.satisfied ? evidenceResult.expired.length > 0 ? "evidence_expired" : "evidence_missing" : "dependency_missing";
5957
- const builder = reason === "evidence_expired" ? buildEvidenceExpiredFeedback : reason === "evidence_missing" ? buildEvidenceMissingFeedback : buildDependencyMissingFeedback;
5958
- const feedback = builder(decision, evidenceResult, dependencyResult);
5959
- return makeErrorResult(
5960
- request,
5961
- POLICY_DENIED,
5962
- `Evidence grounding failed: ${decision.reason}`,
5963
- { ...feedback }
5964
- );
5965
- }
5966
- makeSessionRequiredBlockResult(request, decision) {
5967
- const feedback = buildPolicyDeniedFeedback(decision);
5968
- return makeErrorResult(request, POLICY_DENIED, decision.reason, {
5969
- ...feedback,
5970
- retry_allowed: true
5971
- });
5998
+ return { response, durationMs: 0 };
5999
+ }
6000
+ makeEvidenceBlockResult(request, decision, evidenceResult, dependencyResult) {
6001
+ const reason = evidenceResult && !evidenceResult.satisfied ? evidenceResult.expired.length > 0 ? "evidence_expired" : "evidence_missing" : "dependency_missing";
6002
+ const builder = reason === "evidence_expired" ? buildEvidenceExpiredFeedback : reason === "evidence_missing" ? buildEvidenceMissingFeedback : buildDependencyMissingFeedback;
6003
+ const feedback = builder(decision, evidenceResult, dependencyResult);
6004
+ return makeErrorResult(
6005
+ request,
6006
+ POLICY_DENIED,
6007
+ `Evidence grounding failed: ${decision.reason}`,
6008
+ { ...feedback }
6009
+ );
6010
+ }
6011
+ makeSessionRequiredBlockResult(request, decision) {
6012
+ const feedback = buildPolicyDeniedFeedback(decision);
6013
+ return makeErrorResult(request, POLICY_DENIED, decision.reason, {
6014
+ ...feedback,
6015
+ retry_allowed: true
6016
+ });
6017
+ }
6018
+ makeClientDisconnectedBlockResult(request, decision) {
6019
+ const feedback = buildClientDisconnectedFeedback(decision);
6020
+ return makeErrorResult(request, POLICY_DENIED, "Client disconnected before completion", {
6021
+ ...feedback
6022
+ });
6023
+ }
6024
+ };
6025
+ function collectAllowedEvidenceKeys(policy) {
6026
+ const keys = /* @__PURE__ */ new Set();
6027
+ for (const rule of policy.rules) {
6028
+ for (const key of rule.evidence?.requires ?? []) {
6029
+ keys.add(key);
6030
+ }
6031
+ }
6032
+ return [...keys];
6033
+ }
6034
+ function makeErrorResult(request, code, message, data) {
6035
+ const body = {
6036
+ jsonrpc: "2.0",
6037
+ id: request.id ?? null,
6038
+ error: { code, message, data }
6039
+ };
6040
+ const response = {
6041
+ status: 200,
6042
+ headers: { "content-type": "application/json" },
6043
+ body
6044
+ };
6045
+ return { response, durationMs: 0 };
6046
+ }
6047
+ function approvedByOf(outcome) {
6048
+ return outcome && "resolvedBy" in outcome ? outcome.resolvedBy : null;
6049
+ }
6050
+ function hasJsonRpcError(result) {
6051
+ const body = result.response.body;
6052
+ return body?.["error"] !== void 0;
6053
+ }
6054
+ function classifyPrimeFailure(response) {
6055
+ if (response.status >= 400) {
6056
+ return `upstream returned HTTP ${String(response.status)} to tools/list (session/initialize may be required)`;
6057
+ }
6058
+ const rawBody = response.body;
6059
+ if (typeof rawBody !== "object" || rawBody === null) {
6060
+ return `upstream tools/list returned a non-JSON body (content-type ${response.headers["content-type"] ?? "unknown"})`;
6061
+ }
6062
+ const body = rawBody;
6063
+ const error = body["error"];
6064
+ if (typeof error === "string") {
6065
+ return `upstream tools/list returned a JSON-RPC error: ${error}`;
6066
+ }
6067
+ if (error !== null && typeof error === "object") {
6068
+ const message = error["message"];
6069
+ if (typeof message === "string") {
6070
+ return `upstream tools/list returned a JSON-RPC error: ${message}`;
6071
+ }
6072
+ }
6073
+ return "upstream tools/list response was missing result.tools";
6074
+ }
6075
+ function extractBlockReason(result) {
6076
+ const body = result.response.body;
6077
+ const error = body?.["error"];
6078
+ if (!error || typeof error !== "object") return null;
6079
+ const data = error["data"];
6080
+ if (!data || data["blocked"] !== true) return null;
6081
+ return typeof data["reason"] === "string" ? data["reason"] : null;
6082
+ }
6083
+ function buildEvidenceChain(evidenceResult, dependencyResult, blocked2) {
6084
+ if (!evidenceResult && !dependencyResult) return null;
6085
+ const chain = { blocked: blocked2 ?? false };
6086
+ if (evidenceResult) {
6087
+ chain["evidence"] = {
6088
+ required: [...evidenceResult.found, ...evidenceResult.missing, ...evidenceResult.expired],
6089
+ found: evidenceResult.found,
6090
+ missing: evidenceResult.missing,
6091
+ expired: evidenceResult.expired
6092
+ };
6093
+ }
6094
+ if (dependencyResult) {
6095
+ chain["dependencies"] = {
6096
+ satisfied: dependencyResult.satisfied,
6097
+ missing: dependencyResult.missing
6098
+ };
6099
+ }
6100
+ return chain;
6101
+ }
6102
+
6103
+ // src/policy/rate-limiter.ts
6104
+ var RateLimiter = class {
6105
+ buckets = /* @__PURE__ */ new Map();
6106
+ now;
6107
+ onWarning;
6108
+ warningThreshold;
6109
+ timer = null;
6110
+ closed = false;
6111
+ constructor(options = {}) {
6112
+ this.now = options.now ?? Date.now;
6113
+ this.onWarning = options.onWarning;
6114
+ this.warningThreshold = options.warningThreshold ?? 0.8;
6115
+ const intervalMs = options.cleanupIntervalMs ?? 6e4;
6116
+ if (intervalMs > 0) {
6117
+ this.timer = setInterval(() => {
6118
+ this.cleanup();
6119
+ }, intervalMs);
6120
+ this.timer.unref();
6121
+ }
6122
+ }
6123
+ // -------------------------------------------------------------------------
6124
+ // Core operations
6125
+ // -------------------------------------------------------------------------
6126
+ /**
6127
+ * Check and optionally record a call against the rate limit.
6128
+ *
6129
+ * Evicts expired timestamps, then checks the count:
6130
+ * - Under limit: records the timestamp and returns `allowed: true`
6131
+ * - At/over limit: does NOT record (blocked calls don't consume a slot)
6132
+ */
6133
+ check(params) {
6134
+ const { key, maxCalls, windowMs } = params;
6135
+ const now = this.now();
6136
+ const windowStart = now - windowMs;
6137
+ let bucket = this.buckets.get(key);
6138
+ if (!bucket) {
6139
+ bucket = { timestamps: [], maxCalls, windowMs };
6140
+ this.buckets.set(key, bucket);
6141
+ }
6142
+ bucket.maxCalls = maxCalls;
6143
+ bucket.windowMs = windowMs;
6144
+ bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
6145
+ if (bucket.timestamps.length >= maxCalls) {
6146
+ const oldest = bucket.timestamps[0] ?? 0;
6147
+ return {
6148
+ allowed: false,
6149
+ current: bucket.timestamps.length,
6150
+ limit: maxCalls,
6151
+ windowMs,
6152
+ resetAtMs: oldest + windowMs
6153
+ };
6154
+ }
6155
+ bucket.timestamps.push(now);
6156
+ const current = bucket.timestamps.length;
6157
+ const resetAtMs = (bucket.timestamps[0] ?? now) + windowMs;
6158
+ if (this.onWarning && current / maxCalls >= this.warningThreshold) {
6159
+ this.safeWarn({ key, current, limit: maxCalls, window_ms: windowMs, reset_at_ms: resetAtMs });
6160
+ }
6161
+ return {
6162
+ allowed: true,
6163
+ current,
6164
+ limit: maxCalls,
6165
+ windowMs,
6166
+ resetAtMs
6167
+ };
6168
+ }
6169
+ /**
6170
+ * Unconditionally record a call against the rate limit.
6171
+ *
6172
+ * Unlike check(), this always appends the timestamp — even when the bucket
6173
+ * is already at/over the limit — because the call it represents has already
6174
+ * executed. The sideband splits decision from execution: /evaluate peeks
6175
+ * (non-destructive), and /audit calls record() once the external call ran,
6176
+ * so refusing to record at the limit (as check() does) would let real calls
6177
+ * escape accounting and under-count subsequent peeks. (issue #12, D3.)
6178
+ *
6179
+ * Warnings fire only while the post-append count stays within the limit —
6180
+ * exact parity with check(), which never warns on its over-limit path — so a
6181
+ * burst of over-limit audits cannot flood the dashboard's limit_warning feed.
6182
+ */
6183
+ record(params) {
6184
+ const { key, maxCalls, windowMs } = params;
6185
+ const now = this.now();
6186
+ const windowStart = now - windowMs;
6187
+ let bucket = this.buckets.get(key);
6188
+ if (!bucket) {
6189
+ bucket = { timestamps: [], maxCalls, windowMs };
6190
+ this.buckets.set(key, bucket);
6191
+ }
6192
+ bucket.maxCalls = maxCalls;
6193
+ bucket.windowMs = windowMs;
6194
+ bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
6195
+ bucket.timestamps.push(now);
6196
+ const current = bucket.timestamps.length;
6197
+ const resetAtMs = (bucket.timestamps[0] ?? now) + windowMs;
6198
+ if (this.onWarning && current <= maxCalls && current / maxCalls >= this.warningThreshold) {
6199
+ this.safeWarn({ key, current, limit: maxCalls, window_ms: windowMs, reset_at_ms: resetAtMs });
6200
+ }
6201
+ return {
6202
+ allowed: current <= maxCalls,
6203
+ current,
6204
+ limit: maxCalls,
6205
+ windowMs,
6206
+ resetAtMs
6207
+ };
5972
6208
  }
5973
- makeClientDisconnectedBlockResult(request, decision) {
5974
- const feedback = buildClientDisconnectedFeedback(decision);
5975
- return makeErrorResult(request, POLICY_DENIED, "Client disconnected before completion", {
5976
- ...feedback
5977
- });
6209
+ /**
6210
+ * Check the rate limit without recording the call (non-destructive).
6211
+ *
6212
+ * Used by dry-run mode to determine what would happen without consuming
6213
+ * a slot in the bucket.
6214
+ */
6215
+ peek(params) {
6216
+ const { key, maxCalls, windowMs } = params;
6217
+ const now = this.now();
6218
+ const windowStart = now - windowMs;
6219
+ const bucket = this.buckets.get(key);
6220
+ if (!bucket) {
6221
+ return {
6222
+ allowed: true,
6223
+ current: 1,
6224
+ limit: maxCalls,
6225
+ windowMs,
6226
+ resetAtMs: now + windowMs
6227
+ };
6228
+ }
6229
+ const activeCount = bucket.timestamps.filter((ts) => ts > windowStart).length;
6230
+ if (activeCount >= maxCalls) {
6231
+ const oldest2 = bucket.timestamps.find((ts) => ts > windowStart) ?? 0;
6232
+ return {
6233
+ allowed: false,
6234
+ current: activeCount,
6235
+ limit: maxCalls,
6236
+ windowMs,
6237
+ resetAtMs: oldest2 + windowMs
6238
+ };
6239
+ }
6240
+ const oldest = bucket.timestamps.find((ts) => ts > windowStart) ?? now;
6241
+ return {
6242
+ allowed: true,
6243
+ current: activeCount + 1,
6244
+ limit: maxCalls,
6245
+ windowMs,
6246
+ resetAtMs: oldest + windowMs
6247
+ };
5978
6248
  }
5979
- };
5980
- function collectAllowedEvidenceKeys(policy) {
5981
- const keys = /* @__PURE__ */ new Set();
5982
- for (const rule of policy.rules) {
5983
- for (const key of rule.evidence?.requires ?? []) {
5984
- keys.add(key);
6249
+ // -------------------------------------------------------------------------
6250
+ // Read operations (for dashboard API)
6251
+ // -------------------------------------------------------------------------
6252
+ /** Get the current state of a single key. Returns undefined if not tracked. */
6253
+ getKeyState(key) {
6254
+ const bucket = this.buckets.get(key);
6255
+ if (!bucket) return void 0;
6256
+ const windowStart = this.now() - bucket.windowMs;
6257
+ bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
6258
+ if (bucket.timestamps.length === 0) {
6259
+ this.buckets.delete(key);
6260
+ return void 0;
5985
6261
  }
6262
+ return {
6263
+ key,
6264
+ current: bucket.timestamps.length,
6265
+ limit: bucket.maxCalls,
6266
+ window_ms: bucket.windowMs,
6267
+ reset_at_ms: (bucket.timestamps[0] ?? 0) + bucket.windowMs
6268
+ };
5986
6269
  }
5987
- return [...keys];
5988
- }
5989
- function makeErrorResult(request, code, message, data) {
5990
- const body = {
5991
- jsonrpc: "2.0",
5992
- id: request.id ?? null,
5993
- error: { code, message, data }
5994
- };
5995
- const response = {
5996
- status: 200,
5997
- headers: { "content-type": "application/json" },
5998
- body
5999
- };
6000
- return { response, durationMs: 0 };
6001
- }
6002
- function approvedByOf(outcome) {
6003
- return outcome && "resolvedBy" in outcome ? outcome.resolvedBy : null;
6004
- }
6005
- function hasJsonRpcError(result) {
6006
- const body = result.response.body;
6007
- return body?.["error"] !== void 0;
6008
- }
6009
- function classifyPrimeFailure(response) {
6010
- if (response.status >= 400) {
6011
- return `upstream returned HTTP ${String(response.status)} to tools/list (session/initialize may be required)`;
6270
+ /** List all tracked keys with their current state. */
6271
+ listKeyStates() {
6272
+ const states = [];
6273
+ for (const key of [...this.buckets.keys()]) {
6274
+ const state = this.getKeyState(key);
6275
+ if (state) states.push(state);
6276
+ }
6277
+ return states;
6012
6278
  }
6013
- const rawBody = response.body;
6014
- if (typeof rawBody !== "object" || rawBody === null) {
6015
- return `upstream tools/list returned a non-JSON body (content-type ${response.headers["content-type"] ?? "unknown"})`;
6279
+ // -------------------------------------------------------------------------
6280
+ // Maintenance
6281
+ // -------------------------------------------------------------------------
6282
+ /** Sweep all buckets: remove expired timestamps, delete empty buckets. */
6283
+ cleanup() {
6284
+ const now = this.now();
6285
+ for (const [key, bucket] of this.buckets) {
6286
+ const windowStart = now - bucket.windowMs;
6287
+ bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
6288
+ if (bucket.timestamps.length === 0) {
6289
+ this.buckets.delete(key);
6290
+ }
6291
+ }
6016
6292
  }
6017
- const body = rawBody;
6018
- const error = body["error"];
6019
- if (typeof error === "string") {
6020
- return `upstream tools/list returned a JSON-RPC error: ${error}`;
6293
+ /** Clear all rate limit state. Called on policy hot-reload. */
6294
+ reset() {
6295
+ this.buckets.clear();
6021
6296
  }
6022
- if (error !== null && typeof error === "object") {
6023
- const message = error["message"];
6024
- if (typeof message === "string") {
6025
- return `upstream tools/list returned a JSON-RPC error: ${message}`;
6297
+ /**
6298
+ * Reconcile bucket state against a new policy's limit configuration.
6299
+ *
6300
+ * Walks every existing bucket and checks whether its last-seen
6301
+ * `{ maxCalls, windowMs }` tuple still appears in `validConfigs`.
6302
+ * Buckets whose config is still present are left untouched — counters and
6303
+ * elapsed-window progress are preserved across hot-reloads. Buckets whose
6304
+ * config is gone (rule changed or removed) are evicted so the next check
6305
+ * lazy-creates a fresh bucket under the new config.
6306
+ *
6307
+ * Keys built by `ruleBucketKey` (bucket-key.ts) carry the owning rule's
6308
+ * index, and for those the tuple must match at THAT index
6309
+ * (`config.ruleIndex`): a reorder that shifts a rate rule's index evicts
6310
+ * its old-index bucket instead of leaving an orphan no rule reads again —
6311
+ * or worse, letting whatever rule now sits at that index adopt another
6312
+ * rule's accrued calls. Un-suffixed keys keep the tuple-anywhere match,
6313
+ * but only against index-less configs — a caller that passes only indexed
6314
+ * configs (as the proxy does) evicts every un-suffixed bucket, fail-closed.
6315
+ *
6316
+ * This is the compare-and-evict semantic that replaces the old `reset()`
6317
+ * call on every hot-reload, which wiped all state even when the matching
6318
+ * rule was unchanged.
6319
+ */
6320
+ reconcile(validConfigs) {
6321
+ const valid = /* @__PURE__ */ new Set();
6322
+ const byIndex = /* @__PURE__ */ new Map();
6323
+ for (const config of validConfigs) {
6324
+ const tuple = `${String(config.maxCalls)}|${String(config.windowMs)}`;
6325
+ if (config.ruleIndex === void 0) {
6326
+ valid.add(tuple);
6327
+ } else {
6328
+ byIndex.set(config.ruleIndex, tuple);
6329
+ }
6330
+ }
6331
+ for (const [key, bucket] of this.buckets) {
6332
+ const tuple = `${String(bucket.maxCalls)}|${String(bucket.windowMs)}`;
6333
+ const ruleIndex = parseRuleIndex(key);
6334
+ const survives = ruleIndex === void 0 ? valid.has(tuple) : byIndex.get(ruleIndex) === tuple;
6335
+ if (!survives) {
6336
+ this.buckets.delete(key);
6337
+ }
6026
6338
  }
6027
6339
  }
6028
- return "upstream tools/list response was missing result.tools";
6029
- }
6030
- function extractBlockReason(result) {
6031
- const body = result.response.body;
6032
- const error = body?.["error"];
6033
- if (!error || typeof error !== "object") return null;
6034
- const data = error["data"];
6035
- if (!data || data["blocked"] !== true) return null;
6036
- return typeof data["reason"] === "string" ? data["reason"] : null;
6037
- }
6038
- function buildEvidenceChain(evidenceResult, dependencyResult, blocked2) {
6039
- if (!evidenceResult && !dependencyResult) return null;
6040
- const chain = { blocked: blocked2 ?? false };
6041
- if (evidenceResult) {
6042
- chain["evidence"] = {
6043
- required: [...evidenceResult.found, ...evidenceResult.missing, ...evidenceResult.expired],
6044
- found: evidenceResult.found,
6045
- missing: evidenceResult.missing,
6046
- expired: evidenceResult.expired
6047
- };
6340
+ /** Stop the cleanup timer and mark as closed. */
6341
+ /**
6342
+ * Invoke the warning callback without letting a subscriber throw into the
6343
+ * limiter's caller: a warning fires after state has already mutated, and a
6344
+ * governed call must not be blocked (or double-charged on retry) by an
6345
+ * observability bug.
6346
+ */
6347
+ safeWarn(state) {
6348
+ if (!this.onWarning) return;
6349
+ try {
6350
+ this.onWarning(state);
6351
+ } catch (err) {
6352
+ console.error("[helio] limit warning subscriber threw:", err);
6353
+ }
6048
6354
  }
6049
- if (dependencyResult) {
6050
- chain["dependencies"] = {
6051
- satisfied: dependencyResult.satisfied,
6052
- missing: dependencyResult.missing
6053
- };
6355
+ close() {
6356
+ if (this.closed) return;
6357
+ this.closed = true;
6358
+ if (this.timer) {
6359
+ clearInterval(this.timer);
6360
+ this.timer = null;
6361
+ }
6362
+ this.buckets.clear();
6054
6363
  }
6055
- return chain;
6056
- }
6364
+ };
6057
6365
 
6058
- // src/policy/rate-limiter.ts
6059
- var RateLimiter = class {
6366
+ // src/policy/spend-limiter.ts
6367
+ var SpendLimiter = class {
6060
6368
  buckets = /* @__PURE__ */ new Map();
6061
6369
  now;
6062
6370
  onWarning;
@@ -6079,128 +6387,185 @@ var RateLimiter = class {
6079
6387
  // Core operations
6080
6388
  // -------------------------------------------------------------------------
6081
6389
  /**
6082
- * Check and optionally record a call against the rate limit.
6390
+ * Check and optionally record a spend against the limit.
6083
6391
  *
6084
- * Evicts expired timestamps, then checks the count:
6085
- * - Under limit: records the timestamp and returns `allowed: true`
6086
- * - At/over limit: does NOT record (blocked calls don't consume a slot)
6392
+ * Evicts expired entries, sums remaining amounts, then checks:
6393
+ * - Under limit (currentSpend + amount <= limit): records and returns `allowed: true`
6394
+ * - Would exceed: does NOT record (rejected spends don't consume budget)
6087
6395
  */
6088
6396
  check(params) {
6089
- const { key, maxCalls, windowMs } = params;
6397
+ const { key, amount, limit, windowMs } = params;
6090
6398
  const now = this.now();
6091
6399
  const windowStart = now - windowMs;
6400
+ if (!Number.isFinite(amount) || amount < 0) {
6401
+ const existing = this.buckets.get(key);
6402
+ const activeEntries = existing ? existing.entries.filter((e) => e.timestamp > windowStart) : [];
6403
+ const currentSpend2 = activeEntries.reduce((sum, e) => sum + e.amount, 0);
6404
+ const oldest = activeEntries[0];
6405
+ return {
6406
+ allowed: false,
6407
+ currentSpend: currentSpend2,
6408
+ limit,
6409
+ windowMs,
6410
+ resetAtMs: oldest ? oldest.timestamp + windowMs : 0,
6411
+ reason: "invalid_amount"
6412
+ };
6413
+ }
6092
6414
  let bucket = this.buckets.get(key);
6093
6415
  if (!bucket) {
6094
- bucket = { timestamps: [], maxCalls, windowMs };
6416
+ bucket = { entries: [], limit, currency: "", windowMs };
6095
6417
  this.buckets.set(key, bucket);
6096
6418
  }
6097
- bucket.maxCalls = maxCalls;
6419
+ bucket.limit = limit;
6098
6420
  bucket.windowMs = windowMs;
6099
- bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
6100
- if (bucket.timestamps.length >= maxCalls) {
6101
- const oldest = bucket.timestamps[0] ?? 0;
6421
+ bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
6422
+ const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
6423
+ if (currentSpend + amount > limit) {
6424
+ const oldest = bucket.entries[0];
6102
6425
  return {
6103
6426
  allowed: false,
6104
- current: bucket.timestamps.length,
6105
- limit: maxCalls,
6427
+ currentSpend,
6428
+ limit,
6106
6429
  windowMs,
6107
- resetAtMs: oldest + windowMs
6430
+ resetAtMs: oldest ? oldest.timestamp + windowMs : 0
6108
6431
  };
6109
6432
  }
6110
- bucket.timestamps.push(now);
6111
- const current = bucket.timestamps.length;
6112
- const resetAtMs = (bucket.timestamps[0] ?? now) + windowMs;
6113
- if (this.onWarning && current / maxCalls >= this.warningThreshold) {
6114
- this.safeWarn({ key, current, limit: maxCalls, window_ms: windowMs, reset_at_ms: resetAtMs });
6433
+ bucket.entries.push({ timestamp: now, amount });
6434
+ const newSpend = currentSpend + amount;
6435
+ const resetAtMs = (bucket.entries[0]?.timestamp ?? now) + windowMs;
6436
+ if (this.onWarning && newSpend / limit >= this.warningThreshold) {
6437
+ this.safeWarn({
6438
+ key,
6439
+ current_spend: newSpend,
6440
+ limit,
6441
+ currency: bucket.currency,
6442
+ window_ms: windowMs,
6443
+ reset_at_ms: resetAtMs
6444
+ });
6115
6445
  }
6116
6446
  return {
6117
6447
  allowed: true,
6118
- current,
6119
- limit: maxCalls,
6448
+ currentSpend: newSpend,
6449
+ limit,
6120
6450
  windowMs,
6121
6451
  resetAtMs
6122
6452
  };
6123
6453
  }
6124
6454
  /**
6125
- * Unconditionally record a call against the rate limit.
6455
+ * Unconditionally record a spend against the limit.
6126
6456
  *
6127
- * Unlike check(), this always appends the timestamp — even when the bucket
6128
- * is already at/over the limit — because the call it represents has already
6129
- * executed. The sideband splits decision from execution: /evaluate peeks
6130
- * (non-destructive), and /audit calls record() once the external call ran,
6131
- * so refusing to record at the limit (as check() does) would let real calls
6132
- * escape accounting and under-count subsequent peeks. (issue #12, D3.)
6457
+ * Unlike check(), this always appends the amount — even when it pushes the
6458
+ * window past the limit — because the spend it represents has already been
6459
+ * incurred. The sideband peeks at /evaluate and commits here at /audit once
6460
+ * the external call ran (issue #12, D3).
6133
6461
  *
6134
- * Warnings fire only while the post-append count stays within the limit
6135
- * exact parity with check(), which never warns on its over-limit path — so a
6136
- * burst of over-limit audits cannot flood the dashboard's limit_warning feed.
6462
+ * Throws on a negative or non-finite amount: such amounts are rejected at
6463
+ * /evaluate, so one reaching record() is a logic bug we surface loudly rather
6464
+ * than silently corrupt the sliding-window sum. Warnings fire only while the
6465
+ * post-append spend stays within the limit (parity with check()).
6137
6466
  */
6138
6467
  record(params) {
6139
- const { key, maxCalls, windowMs } = params;
6468
+ const { key, amount, limit, windowMs } = params;
6469
+ if (!Number.isFinite(amount) || amount < 0) {
6470
+ throw new RangeError(
6471
+ `SpendLimiter.record() received an invalid amount (${String(amount)}); invalid amounts must be rejected at /evaluate, never committed`
6472
+ );
6473
+ }
6140
6474
  const now = this.now();
6141
6475
  const windowStart = now - windowMs;
6142
6476
  let bucket = this.buckets.get(key);
6143
6477
  if (!bucket) {
6144
- bucket = { timestamps: [], maxCalls, windowMs };
6478
+ bucket = { entries: [], limit, currency: "", windowMs };
6145
6479
  this.buckets.set(key, bucket);
6146
6480
  }
6147
- bucket.maxCalls = maxCalls;
6481
+ bucket.limit = limit;
6148
6482
  bucket.windowMs = windowMs;
6149
- bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
6150
- bucket.timestamps.push(now);
6151
- const current = bucket.timestamps.length;
6152
- const resetAtMs = (bucket.timestamps[0] ?? now) + windowMs;
6153
- if (this.onWarning && current <= maxCalls && current / maxCalls >= this.warningThreshold) {
6154
- this.safeWarn({ key, current, limit: maxCalls, window_ms: windowMs, reset_at_ms: resetAtMs });
6483
+ bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
6484
+ bucket.entries.push({ timestamp: now, amount });
6485
+ const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
6486
+ const resetAtMs = (bucket.entries[0]?.timestamp ?? now) + windowMs;
6487
+ if (this.onWarning && currentSpend <= limit && currentSpend / limit >= this.warningThreshold) {
6488
+ this.safeWarn({
6489
+ key,
6490
+ current_spend: currentSpend,
6491
+ limit,
6492
+ currency: bucket.currency,
6493
+ window_ms: windowMs,
6494
+ reset_at_ms: resetAtMs
6495
+ });
6155
6496
  }
6156
6497
  return {
6157
- allowed: current <= maxCalls,
6158
- current,
6159
- limit: maxCalls,
6498
+ allowed: currentSpend <= limit,
6499
+ currentSpend,
6500
+ limit,
6160
6501
  windowMs,
6161
6502
  resetAtMs
6162
6503
  };
6163
6504
  }
6164
6505
  /**
6165
- * Check the rate limit without recording the call (non-destructive).
6506
+ * Check the spend limit without recording the spend (non-destructive).
6166
6507
  *
6167
6508
  * Used by dry-run mode to determine what would happen without consuming
6168
- * a slot in the bucket.
6509
+ * budget in the bucket.
6169
6510
  */
6170
6511
  peek(params) {
6171
- const { key, maxCalls, windowMs } = params;
6512
+ const { key, amount, limit, windowMs } = params;
6172
6513
  const now = this.now();
6173
6514
  const windowStart = now - windowMs;
6174
6515
  const bucket = this.buckets.get(key);
6516
+ if (!Number.isFinite(amount) || amount < 0) {
6517
+ const activeEntries2 = bucket ? bucket.entries.filter((e) => e.timestamp > windowStart) : [];
6518
+ const currentSpend2 = activeEntries2.reduce((sum, e) => sum + e.amount, 0);
6519
+ const oldest2 = activeEntries2[0];
6520
+ return {
6521
+ allowed: false,
6522
+ currentSpend: currentSpend2,
6523
+ limit,
6524
+ windowMs,
6525
+ resetAtMs: oldest2 ? oldest2.timestamp + windowMs : 0,
6526
+ reason: "invalid_amount"
6527
+ };
6528
+ }
6175
6529
  if (!bucket) {
6530
+ const wouldExceed = amount > limit;
6176
6531
  return {
6177
- allowed: true,
6178
- current: 1,
6179
- limit: maxCalls,
6532
+ allowed: !wouldExceed,
6533
+ currentSpend: wouldExceed ? 0 : amount,
6534
+ limit,
6180
6535
  windowMs,
6181
6536
  resetAtMs: now + windowMs
6182
6537
  };
6183
6538
  }
6184
- const activeCount = bucket.timestamps.filter((ts) => ts > windowStart).length;
6185
- if (activeCount >= maxCalls) {
6186
- const oldest2 = bucket.timestamps.find((ts) => ts > windowStart) ?? 0;
6539
+ const activeEntries = bucket.entries.filter((e) => e.timestamp > windowStart);
6540
+ const currentSpend = activeEntries.reduce((sum, e) => sum + e.amount, 0);
6541
+ if (currentSpend + amount > limit) {
6542
+ const oldest2 = activeEntries[0];
6187
6543
  return {
6188
6544
  allowed: false,
6189
- current: activeCount,
6190
- limit: maxCalls,
6545
+ currentSpend,
6546
+ limit,
6191
6547
  windowMs,
6192
- resetAtMs: oldest2 + windowMs
6548
+ resetAtMs: oldest2 ? oldest2.timestamp + windowMs : 0
6193
6549
  };
6194
6550
  }
6195
- const oldest = bucket.timestamps.find((ts) => ts > windowStart) ?? now;
6551
+ const newSpend = currentSpend + amount;
6552
+ const oldest = activeEntries[0];
6196
6553
  return {
6197
6554
  allowed: true,
6198
- current: activeCount + 1,
6199
- limit: maxCalls,
6555
+ currentSpend: newSpend,
6556
+ limit,
6200
6557
  windowMs,
6201
- resetAtMs: oldest + windowMs
6558
+ resetAtMs: oldest ? oldest.timestamp + windowMs : now + windowMs
6202
6559
  };
6203
6560
  }
6561
+ /**
6562
+ * Set the display currency for a key. Called by the governed forwarder
6563
+ * after check() so dashboard reads include the currency label.
6564
+ */
6565
+ setCurrency(key, currency) {
6566
+ const bucket = this.buckets.get(key);
6567
+ if (bucket) bucket.currency = currency;
6568
+ }
6204
6569
  // -------------------------------------------------------------------------
6205
6570
  // Read operations (for dashboard API)
6206
6571
  // -------------------------------------------------------------------------
@@ -6209,17 +6574,19 @@ var RateLimiter = class {
6209
6574
  const bucket = this.buckets.get(key);
6210
6575
  if (!bucket) return void 0;
6211
6576
  const windowStart = this.now() - bucket.windowMs;
6212
- bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
6213
- if (bucket.timestamps.length === 0) {
6577
+ bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
6578
+ if (bucket.entries.length === 0) {
6214
6579
  this.buckets.delete(key);
6215
6580
  return void 0;
6216
6581
  }
6582
+ const currentSpend = bucket.entries.reduce((sum, e) => sum + e.amount, 0);
6217
6583
  return {
6218
6584
  key,
6219
- current: bucket.timestamps.length,
6220
- limit: bucket.maxCalls,
6585
+ current_spend: currentSpend,
6586
+ limit: bucket.limit,
6587
+ currency: bucket.currency,
6221
6588
  window_ms: bucket.windowMs,
6222
- reset_at_ms: (bucket.timestamps[0] ?? 0) + bucket.windowMs
6589
+ reset_at_ms: (bucket.entries[0]?.timestamp ?? 0) + bucket.windowMs
6223
6590
  };
6224
6591
  }
6225
6592
  /** List all tracked keys with their current state. */
@@ -6234,43 +6601,62 @@ var RateLimiter = class {
6234
6601
  // -------------------------------------------------------------------------
6235
6602
  // Maintenance
6236
6603
  // -------------------------------------------------------------------------
6237
- /** Sweep all buckets: remove expired timestamps, delete empty buckets. */
6604
+ /** Sweep all buckets: remove expired entries, delete empty buckets. */
6238
6605
  cleanup() {
6239
6606
  const now = this.now();
6240
6607
  for (const [key, bucket] of this.buckets) {
6241
6608
  const windowStart = now - bucket.windowMs;
6242
- bucket.timestamps = bucket.timestamps.filter((ts) => ts > windowStart);
6243
- if (bucket.timestamps.length === 0) {
6609
+ bucket.entries = bucket.entries.filter((e) => e.timestamp > windowStart);
6610
+ if (bucket.entries.length === 0) {
6244
6611
  this.buckets.delete(key);
6245
6612
  }
6246
6613
  }
6247
6614
  }
6248
- /** Clear all rate limit state. Called on policy hot-reload. */
6615
+ /** Clear all spend limit state. Called on policy hot-reload. */
6249
6616
  reset() {
6250
6617
  this.buckets.clear();
6251
6618
  }
6252
6619
  /**
6253
- * Reconcile bucket state against a new policy's limit configuration.
6620
+ * Reconcile bucket state against a new policy's spend configuration.
6254
6621
  *
6255
6622
  * Walks every existing bucket and checks whether its last-seen
6256
- * `{ maxCalls, windowMs }` tuple still appears anywhere in `validConfigs`.
6257
- * Buckets whose config is still present are left untouched — counters and
6258
- * elapsed-window progress are preserved across hot-reloads. Buckets whose
6259
- * config is gone (rule changed or removed) are evicted so the next check
6260
- * lazy-creates a fresh bucket under the new config.
6623
+ * `{ limit, currency, windowMs }` tuple still appears in `validConfigs`.
6624
+ * Buckets whose config is unchanged are left untouched — cumulative spend
6625
+ * and elapsed-window progress are preserved across hot-reloads. Buckets
6626
+ * whose config is gone (rule changed or removed) are evicted so the next
6627
+ * check lazy-creates a fresh bucket under the new config.
6261
6628
  *
6262
- * This is the compare-and-evict semantic that replaces the old `reset()`
6263
- * call on every hot-reload, which wiped all state even when the matching
6264
- * rule was unchanged.
6629
+ * Keys built by `ruleBucketKey` (bucket-key.ts) carry the owning rule's
6630
+ * index, and for those the tuple must match at THAT index (`config.ruleIndex`): a
6631
+ * reorder that shifts a spend rule's index evicts its old-index bucket
6632
+ * instead of leaving an orphan no rule reads again — or worse, letting
6633
+ * whatever rule now sits at that index adopt another rule's accrued spend.
6634
+ * Un-suffixed keys keep the tuple-anywhere match, but only against
6635
+ * index-less configs — a caller that passes only indexed configs (as the
6636
+ * proxy does) evicts every un-suffixed bucket, fail-closed.
6637
+ *
6638
+ * Currency is part of the tuple because a USD→EUR switch is a meaningful
6639
+ * policy change — the same numeric limit buys a different amount of real
6640
+ * spend, so the bucket must reset. This replaces the old `reset()` call
6641
+ * on every hot-reload, which wiped all state even when the matching rule
6642
+ * was unchanged.
6265
6643
  */
6266
6644
  reconcile(validConfigs) {
6267
6645
  const valid = /* @__PURE__ */ new Set();
6646
+ const byIndex = /* @__PURE__ */ new Map();
6268
6647
  for (const config of validConfigs) {
6269
- valid.add(`${String(config.maxCalls)}|${String(config.windowMs)}`);
6648
+ const tuple = `${String(config.limit)}|${config.currency}|${String(config.windowMs)}`;
6649
+ if (config.ruleIndex === void 0) {
6650
+ valid.add(tuple);
6651
+ } else {
6652
+ byIndex.set(config.ruleIndex, tuple);
6653
+ }
6270
6654
  }
6271
6655
  for (const [key, bucket] of this.buckets) {
6272
- const tuple = `${String(bucket.maxCalls)}|${String(bucket.windowMs)}`;
6273
- if (!valid.has(tuple)) {
6656
+ const tuple = `${String(bucket.limit)}|${bucket.currency}|${String(bucket.windowMs)}`;
6657
+ const ruleIndex = parseRuleIndex(key);
6658
+ const survives = ruleIndex === void 0 ? valid.has(tuple) : byIndex.get(ruleIndex) === tuple;
6659
+ if (!survives) {
6274
6660
  this.buckets.delete(key);
6275
6661
  }
6276
6662
  }
@@ -6350,14 +6736,18 @@ var BudgetEngine = class {
6350
6736
  /**
6351
6737
  * Resolve which budgets a call feeds and how much it charges each.
6352
6738
  *
6353
- * A contributor participates when its tool glob matches the tool name AND
6354
- * every `match.input` condition holds (absent conditions means the glob
6355
- * alone decides); the FIRST participating contributor (config order, over
6356
- * that combined predicate) supplies the amount field. A call that matches
6357
- * the glob but not the conditions simply does not feed the budget — no
6358
- * charge, no failure. Once a contributor is selected, a missing,
6359
- * non-numeric, negative, or non-finite amount fails closed as a `failures`
6360
- * entry the caller must deny the call.
6739
+ * A contributor participates when its upstream scope admits the call's
6740
+ * door (absent scope admits every door; a scoped contributor never
6741
+ * participates when `ctx.upstream` is null sideband, singular mode) AND
6742
+ * its tool glob matches the tool name AND every `match.input` condition
6743
+ * holds (absent conditions means the glob alone decides); the FIRST
6744
+ * participating contributor (config order, over that combined predicate)
6745
+ * supplies the amount field. A call that matches the glob but not the
6746
+ * conditions or the scope simply does not feed the budget — no charge, no
6747
+ * failure — and a later contributor may still participate. Once a
6748
+ * contributor is selected, a missing, non-numeric, negative, or non-finite
6749
+ * amount fails closed as a `failures` entry — the caller must deny the
6750
+ * call.
6361
6751
  */
6362
6752
  resolveCharges(ctx) {
6363
6753
  const charges = [];
@@ -6368,7 +6758,7 @@ var BudgetEngine = class {
6368
6758
  };
6369
6759
  for (const budget of this.budgets.values()) {
6370
6760
  const contributor = budget.contributors.find(
6371
- (c) => c.match.tool.test(ctx.toolName) && (c.match.input === void 0 || matchInput(c.match.input, matchCtx))
6761
+ (c) => (c.upstreams === void 0 || ctx.upstream !== null && c.upstreams.includes(ctx.upstream)) && c.match.tool.test(ctx.toolName) && (c.match.input === void 0 || matchInput(c.match.input, matchCtx))
6372
6762
  );
6373
6763
  if (!contributor) continue;
6374
6764
  const raw = resolvePath(contributor.field, ctx.toolArguments ?? {});
@@ -6393,7 +6783,8 @@ var BudgetEngine = class {
6393
6783
  budget,
6394
6784
  bucketKey: this.bucketKey(budget, ctx),
6395
6785
  amount: raw,
6396
- generation: this.generations.get(budget.name) ?? 0
6786
+ generation: this.generations.get(budget.name) ?? 0,
6787
+ ...ctx.upstream !== null && { upstream: ctx.upstream }
6397
6788
  });
6398
6789
  }
6399
6790
  return { charges, failures };
@@ -6473,7 +6864,8 @@ var BudgetEngine = class {
6473
6864
  remaining: snapshot.remaining,
6474
6865
  limit: charge.budget.limit,
6475
6866
  currency: charge.budget.currency,
6476
- utilization: snapshot.spent / charge.budget.limit
6867
+ utilization: snapshot.spent / charge.budget.limit,
6868
+ upstream: charge.upstream ?? null
6477
6869
  });
6478
6870
  } catch (err) {
6479
6871
  console.error("[helio] budget onCommit subscriber threw:", err);
@@ -6499,7 +6891,8 @@ var BudgetEngine = class {
6499
6891
  attempted_amount: entry.amount,
6500
6892
  spent: entry.spent,
6501
6893
  limit: entry.budget.limit,
6502
- currency: entry.budget.currency
6894
+ currency: entry.budget.currency,
6895
+ upstream: entry.upstream
6503
6896
  });
6504
6897
  } catch (err) {
6505
6898
  console.error("[helio] budget onBreach subscriber threw:", err);
@@ -6809,7 +7202,8 @@ var BudgetEngine = class {
6809
7202
  allowed: checkedAgainst + charge.amount <= charge.budget.limit,
6810
7203
  spent,
6811
7204
  remaining: Math.max(0, charge.budget.limit - spent),
6812
- resetAtMs
7205
+ resetAtMs,
7206
+ upstream: charge.upstream ?? null
6813
7207
  };
6814
7208
  }
6815
7209
  };
@@ -6839,6 +7233,14 @@ import Database from "better-sqlite3";
6839
7233
  import { randomUUID as randomUUID3 } from "crypto";
6840
7234
  import { chmodSync } from "fs";
6841
7235
 
7236
+ // src/startup-error.ts
7237
+ var StartupError = class extends Error {
7238
+ constructor(message) {
7239
+ super(message);
7240
+ this.name = "StartupError";
7241
+ }
7242
+ };
7243
+
6842
7244
  // src/upstream/response-summary.ts
6843
7245
  function extractResponseSummary(body) {
6844
7246
  if (body == null || typeof body !== "object") {
@@ -6933,7 +7335,8 @@ CREATE TABLE IF NOT EXISTS audit_records (
6933
7335
  origin TEXT NOT NULL DEFAULT 'mcp',
6934
7336
  metadata TEXT,
6935
7337
  protocol_version TEXT,
6936
- created_at TEXT NOT NULL
7338
+ created_at TEXT NOT NULL,
7339
+ upstream TEXT
6937
7340
  );
6938
7341
  `;
6939
7342
  var CREATE_INDEX_DDL = `
@@ -6945,6 +7348,7 @@ CREATE INDEX IF NOT EXISTS idx_audit_block_reason ON audit_records (block_re
6945
7348
  CREATE INDEX IF NOT EXISTS idx_audit_upstream_status_created_at ON audit_records (upstream_http_status, created_at);
6946
7349
  CREATE INDEX IF NOT EXISTS idx_audit_record_kind ON audit_records (record_kind);
6947
7350
  CREATE INDEX IF NOT EXISTS idx_audit_origin ON audit_records (origin);
7351
+ CREATE INDEX IF NOT EXISTS idx_audit_upstream ON audit_records (upstream);
6948
7352
  `;
6949
7353
  var INSERT_SQL = `
6950
7354
  INSERT INTO audit_records (
@@ -6953,14 +7357,16 @@ INSERT INTO audit_records (
6953
7357
  approved_by, upstream_response, upstream_error, upstream_latency_ms,
6954
7358
  upstream_http_status,
6955
7359
  total_duration_ms, approval_wait_ms, proxy_compute_ms,
6956
- flagged_destructive, dry_run, record_kind, origin, metadata, protocol_version, created_at
7360
+ flagged_destructive, dry_run, record_kind, origin, metadata, protocol_version, created_at,
7361
+ upstream
6957
7362
  ) VALUES (
6958
7363
  @id, @timestamp, @session_id, @session_source, @agent_id, @environment, @tool_name, @tool_input,
6959
7364
  @policy_decision, @block_reason, @matched_rule, @matched_rule_index, @evidence_chain, @approval_status,
6960
7365
  @approved_by, @upstream_response, @upstream_error, @upstream_latency_ms,
6961
7366
  @upstream_http_status,
6962
7367
  @total_duration_ms, @approval_wait_ms, @proxy_compute_ms,
6963
- @flagged_destructive, @dry_run, @record_kind, @origin, @metadata, @protocol_version, @created_at
7368
+ @flagged_destructive, @dry_run, @record_kind, @origin, @metadata, @protocol_version, @created_at,
7369
+ @upstream
6964
7370
  )
6965
7371
  `;
6966
7372
  var REQUIRED_AUDIT_COLUMNS = [
@@ -6979,7 +7385,11 @@ var REQUIRED_AUDIT_COLUMNS = [
6979
7385
  "session_source",
6980
7386
  // Same clean break, same unreleased cycle (issue #219): released users see
6981
7387
  // ONE break, at v0.12.0.
6982
- "protocol_version"
7388
+ "protocol_version",
7389
+ // The one ratified exception to the clean break (issue #292): a
7390
+ // v0.12.0-complete database missing ONLY this column is migrated in place
7391
+ // by migrateAuditUpstreamColumn instead of failing the assertion.
7392
+ "upstream"
6983
7393
  ];
6984
7394
  function deserializeRow(row) {
6985
7395
  return {
@@ -7011,6 +7421,7 @@ function deserializeRow(row) {
7011
7421
  origin: row.origin,
7012
7422
  metadata: row.metadata ? JSON.parse(row.metadata) : null,
7013
7423
  protocol_version: row.protocol_version,
7424
+ upstream: row.upstream,
7014
7425
  created_at: row.created_at
7015
7426
  };
7016
7427
  }
@@ -7052,6 +7463,14 @@ function buildWhereClause(filters) {
7052
7463
  conditions.push("session_id = ?");
7053
7464
  params.push(filters.session_id);
7054
7465
  }
7466
+ if (filters.session_source !== void 0) {
7467
+ conditions.push("session_source = ?");
7468
+ params.push(filters.session_source);
7469
+ }
7470
+ if (filters.upstream !== void 0) {
7471
+ conditions.push("upstream = ?");
7472
+ params.push(filters.upstream);
7473
+ }
7055
7474
  if (filters.agent_id !== void 0) {
7056
7475
  conditions.push("agent_id = ?");
7057
7476
  params.push(filters.agent_id);
@@ -7083,6 +7502,22 @@ function buildWhereClause(filters) {
7083
7502
  const clause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
7084
7503
  return { clause, params };
7085
7504
  }
7505
+ function migrateAuditUpstreamColumn(db) {
7506
+ const probe = () => {
7507
+ const rows = db.pragma("table_info(audit_records)");
7508
+ return new Set(rows.map((row) => row.name));
7509
+ };
7510
+ const existing = probe();
7511
+ const missing = REQUIRED_AUDIT_COLUMNS.filter((name) => !existing.has(name));
7512
+ if (missing.length !== 1 || missing[0] !== "upstream") return false;
7513
+ try {
7514
+ db.exec("ALTER TABLE audit_records ADD COLUMN upstream TEXT");
7515
+ } catch (err) {
7516
+ if (probe().has("upstream")) return false;
7517
+ throw err;
7518
+ }
7519
+ return true;
7520
+ }
7086
7521
  function restrictAuditFilePerms(dbPath) {
7087
7522
  if (dbPath === ":memory:" || process.platform === "win32") return;
7088
7523
  try {
@@ -7111,6 +7546,9 @@ var AuditStore = class {
7111
7546
  this.retentionMs = parseDuration(options.retention);
7112
7547
  this.includeResponses = options.includeResponses;
7113
7548
  this.db.exec(CREATE_TABLE_DDL);
7549
+ if (migrateAuditUpstreamColumn(this.db)) {
7550
+ console.error('[helio] Audit DB migrated: added column "upstream"');
7551
+ }
7114
7552
  this.assertRequiredSchema(options.path);
7115
7553
  this.db.exec(CREATE_INDEX_DDL);
7116
7554
  this.insertStmt = this.db.prepare(INSERT_SQL);
@@ -7176,7 +7614,7 @@ var AuditStore = class {
7176
7614
  const missing = REQUIRED_AUDIT_COLUMNS.filter((name) => !existing.has(name));
7177
7615
  if (missing.length === 0) return;
7178
7616
  const quotedColumns = missing.map((name) => `"${name}"`).join(", ");
7179
- throw new Error(
7617
+ throw new StartupError(
7180
7618
  `[helio] Audit DB schema mismatch: missing required columns ${quotedColumns}. This local database was created by an older Helio build. Delete "${dbPath}", "${dbPath}-wal", and "${dbPath}-shm", then restart Helio.`
7181
7619
  );
7182
7620
  }
@@ -7219,6 +7657,7 @@ var AuditStore = class {
7219
7657
  origin: record.origin,
7220
7658
  metadata: record.metadata ? JSON.stringify(record.metadata) : null,
7221
7659
  protocol_version: record.protocol_version,
7660
+ upstream: record.upstream ?? null,
7222
7661
  created_at: now
7223
7662
  });
7224
7663
  return resolvedId;
@@ -7294,9 +7733,14 @@ var AuditStore = class {
7294
7733
  const result = this.db.prepare(`SELECT COUNT(*) as total FROM audit_records ${clause}`).get(...params);
7295
7734
  return result.total;
7296
7735
  }
7297
- /** Get aggregate statistics for a time range. */
7298
- aggregate(from, to) {
7299
- const rangeFilters = { from, to };
7736
+ /**
7737
+ * Get aggregate statistics for a time range. The optional upstream filter
7738
+ * scopes EVERY sub-aggregate (totals, by_decision, by_block_reason,
7739
+ * top_tools, approval_rate, per_hour) — "analytics for this door", not one
7740
+ * filtered chart. Exact match: null-upstream rows never match any value.
7741
+ */
7742
+ aggregate(from, to, filters = {}) {
7743
+ const rangeFilters = { from, to, upstream: filters.upstream };
7300
7744
  const { clause, params } = buildWhereClause(rangeFilters);
7301
7745
  const totals = this.db.prepare(
7302
7746
  `SELECT
@@ -7322,9 +7766,9 @@ var AuditStore = class {
7322
7766
  ).all(...params);
7323
7767
  const toolsClause = clause ? `${clause} AND policy_decision NOT IN ${NON_TOOL_DECISIONS_SQL}` : `WHERE policy_decision NOT IN ${NON_TOOL_DECISIONS_SQL}`;
7324
7768
  const top_tools = this.db.prepare(
7325
- `SELECT tool_name, COUNT(*) as count
7769
+ `SELECT tool_name, upstream, COUNT(*) as count
7326
7770
  FROM audit_records ${toolsClause}
7327
- GROUP BY tool_name
7771
+ GROUP BY tool_name, upstream
7328
7772
  ORDER BY count DESC
7329
7773
  LIMIT 10`
7330
7774
  ).all(...params);
@@ -7408,10 +7852,11 @@ var CSV_HEADERS = [
7408
7852
  "record_kind",
7409
7853
  "origin",
7410
7854
  "metadata",
7411
- // Appended LAST (issues #218, #219): positional consumers of the existing
7412
- // columns keep working — new columns always go at the end.
7855
+ // Appended LAST (issues #218, #219, #292): positional consumers of the
7856
+ // existing columns keep working — new columns always go at the end.
7413
7857
  "session_source",
7414
- "protocol_version"
7858
+ "protocol_version",
7859
+ "upstream"
7415
7860
  ];
7416
7861
  var FORMULA_PREFIXES = /^[=+\-@\t\r]/;
7417
7862
  function csvEscape(value) {
@@ -7454,7 +7899,10 @@ var BUDGET_EVENT_CSV_HEADERS = [
7454
7899
  "audit_record_id",
7455
7900
  "timestamp",
7456
7901
  "timestamp_ms",
7457
- "created_at"
7902
+ "created_at",
7903
+ // Appended LAST (issue #292): positional consumers of the existing
7904
+ // columns keep working — new columns always go at the end.
7905
+ "upstream"
7458
7906
  ];
7459
7907
  function eventToRow(event) {
7460
7908
  return BUDGET_EVENT_CSV_HEADERS.map((h) => {
@@ -7770,19 +8218,23 @@ import { HTTPException } from "hono/http-exception";
7770
8218
  import { z as z5 } from "zod";
7771
8219
 
7772
8220
  // src/auth/bearer.ts
7773
- import { createHash, timingSafeEqual } from "crypto";
8221
+ import { createHash as createHash2, timingSafeEqual } from "crypto";
8222
+ var BEARER_PREFIX = "Bearer ";
8223
+ var DIGEST_PATTERN = /^sha256:([0-9a-f]{64})$/;
7774
8224
  function verifyBearer(authHeader, expected) {
7775
8225
  if (!authHeader || !expected) return false;
7776
- const expectedHeader = `Bearer ${expected}`;
7777
- const actualDigest = createHash("sha256").update(authHeader).digest();
7778
- const expectedDigest = createHash("sha256").update(expectedHeader).digest();
8226
+ if (!authHeader.startsWith(BEARER_PREFIX)) return false;
8227
+ const presented = authHeader.slice(BEARER_PREFIX.length);
8228
+ const storedHex = DIGEST_PATTERN.exec(expected)?.[1];
8229
+ const expectedDigest = storedHex !== void 0 ? Buffer.from(storedHex, "hex") : createHash2("sha256").update(expected, "utf-8").digest();
8230
+ const actualDigest = createHash2("sha256").update(presented, "utf-8").digest();
7779
8231
  return timingSafeEqual(actualDigest, expectedDigest);
7780
8232
  }
7781
8233
 
7782
8234
  // src/sideband/governance-api.ts
7783
8235
  import { Hono as Hono4 } from "hono";
7784
8236
  import { z as z4 } from "zod";
7785
- import { createHash as createHash2 } from "crypto";
8237
+ import { createHash as createHash3 } from "crypto";
7786
8238
  var originSchema = z4.string().regex(/^[a-z0-9_-]{1,64}$/, "origin must match ^[a-z0-9_-]{1,64}$").default("sideband");
7787
8239
  var metadataSchema = z4.record(z4.string(), z4.unknown()).nullish();
7788
8240
  var toolDefinitionSchema = z4.object({
@@ -7939,7 +8391,7 @@ function auditPayloadHash(data) {
7939
8391
  actual_amount: data.actual_amount ?? null,
7940
8392
  evidence: canonicalEvidence(data.evidence)
7941
8393
  };
7942
- return createHash2("sha256").update(canonicalize(semantic)).digest("hex");
8394
+ return createHash3("sha256").update(canonicalize(semantic)).digest("hex");
7943
8395
  }
7944
8396
  function canonicalEvidence(evidence) {
7945
8397
  if (!evidence || evidence.length === 0) return null;
@@ -8285,7 +8737,9 @@ var GovernanceService = class {
8285
8737
  toolName,
8286
8738
  toolArguments: req.arguments,
8287
8739
  sessionId: budgetSessionGate.ok ? budgetSessionGate.session : null,
8288
- senderId
8740
+ senderId,
8741
+ upstream: null
8742
+ // a sideband call has no upstream (issue #295)
8289
8743
  });
8290
8744
  const gatedCharges = charges.length > 0 || failures.length > 0 ? gateBudgetCharges({ charges, failures }, budgetSessionGate) : void 0;
8291
8745
  if (gatedCharges && !gatedCharges.ok) {
@@ -9007,11 +9461,12 @@ var GovernanceService = class {
9007
9461
  };
9008
9462
  }
9009
9463
  planRate(decision, toolName, sessionId, senderId) {
9010
- const limits = decision.matchedRule?.limits;
9011
- if (!this.rateLimiter || !limits?.maxCalls || !limits.windowMs) {
9464
+ const matchedRule = decision.matchedRule;
9465
+ const limits = matchedRule?.limits;
9466
+ if (!this.rateLimiter || !matchedRule || !limits?.maxCalls || !limits.windowMs) {
9012
9467
  return { allowed: true };
9013
9468
  }
9014
- let key;
9469
+ let baseKey;
9015
9470
  if (limits.key === "session") {
9016
9471
  const gate = gateSession(sessionId, this.session.onUnresolved);
9017
9472
  if (!gate.ok) {
@@ -9019,10 +9474,11 @@ var GovernanceService = class {
9019
9474
  return { allowed: false, sessionUnresolved: true };
9020
9475
  }
9021
9476
  if (gate.anonymous) warnAnonymousPoolingOnce();
9022
- key = sessionLimitKey(gate.session);
9477
+ baseKey = sessionLimitKey(gate.session);
9023
9478
  } else {
9024
- key = buildLimitKey(limits.key, toolName, senderId);
9479
+ baseKey = buildLimitKey(limits.key, toolName, senderId);
9025
9480
  }
9481
+ const key = ruleBucketKey(baseKey, matchedRule.index);
9026
9482
  const peek = this.rateLimiter.peek({
9027
9483
  key,
9028
9484
  maxCalls: limits.maxCalls,
@@ -9054,7 +9510,7 @@ var GovernanceService = class {
9054
9510
  } else {
9055
9511
  baseKey = buildLimitKey(maxSpend.key, toolName, senderId);
9056
9512
  }
9057
- const key = spendBucketKey(baseKey, decision.matchedRule.index);
9513
+ const key = ruleBucketKey(baseKey, decision.matchedRule.index);
9058
9514
  const rawAmount = resolvePath(maxSpend.field, args ?? {});
9059
9515
  if (typeof rawAmount !== "number" || !Number.isFinite(rawAmount) || rawAmount < 0) {
9060
9516
  return { allowed: false, block: { reason: "invalid_amount", limit: maxSpend.limit } };
@@ -9213,7 +9669,9 @@ var GovernanceService = class {
9213
9669
  origin: args.origin,
9214
9670
  metadata: args.metadata,
9215
9671
  // The sideband has no MCP wire, so no protocol claim exists.
9216
- protocol_version: null
9672
+ protocol_version: null,
9673
+ // No door on the sideband either: upstream attribution is MCP-only.
9674
+ upstream: null
9217
9675
  };
9218
9676
  const isEnforcement = args.recordKind === "evaluation_expired" || blockReason !== null || args.approvalStatus != null;
9219
9677
  if (isEnforcement) this.auditWriter.pushImmediate(record, id);
@@ -9278,7 +9736,7 @@ function buildLimitKey(keyType, toolName, senderId) {
9278
9736
  case "agent":
9279
9737
  case "tool":
9280
9738
  default:
9281
- return `tool:${toolName}`;
9739
+ return toolLimitKey(toolName);
9282
9740
  }
9283
9741
  }
9284
9742
  function senderIdOf(metadata) {
@@ -9477,7 +9935,7 @@ var AuditWriter = class {
9477
9935
  };
9478
9936
 
9479
9937
  // src/audit/header-mismatch.ts
9480
- function buildHeaderMismatchAuditRecord(rejection, environment) {
9938
+ function buildHeaderMismatchAuditRecord(rejection, environment, upstream) {
9481
9939
  return {
9482
9940
  timestamp: (/* @__PURE__ */ new Date()).toISOString(),
9483
9941
  session_id: rejection.session?.id ?? null,
@@ -9514,7 +9972,10 @@ function buildHeaderMismatchAuditRecord(rejection, environment) {
9514
9972
  record_kind: "tool_call",
9515
9973
  origin: "mcp",
9516
9974
  metadata: null,
9517
- protocol_version: rejection.protocolVersion ?? null
9975
+ protocol_version: rejection.protocolVersion ?? null,
9976
+ // The door context lives with the caller (the composition root), not
9977
+ // the rejection payload; singular composition passes nothing.
9978
+ upstream: upstream ?? null
9518
9979
  };
9519
9980
  }
9520
9981
 
@@ -9556,6 +10017,9 @@ var ApprovalQueue = class {
9556
10017
  rule_index: params.rule_index,
9557
10018
  channel_name: params.channel_name,
9558
10019
  session_id: params.session_id,
10020
+ // Wire-darkness spelling: set only when attributed, never null.
10021
+ ...params.session_source != null && { session_source: params.session_source },
10022
+ ...params.upstream != null && { upstream: params.upstream },
9559
10023
  requested_at: new Date(now).toISOString(),
9560
10024
  timeout_at: new Date(now + params.timeout_ms).toISOString(),
9561
10025
  timeout_ms: params.timeout_ms,
@@ -9675,6 +10139,8 @@ var ApprovalRouter = class {
9675
10139
  rule_index: rule?.index ?? null,
9676
10140
  channel_name: channelName,
9677
10141
  session_id: params.session_id,
10142
+ session_source: params.session_source,
10143
+ upstream: params.upstream,
9678
10144
  timeout_ms: timeoutMs,
9679
10145
  breached_budgets: params.breached_budgets
9680
10146
  });
@@ -9771,6 +10237,10 @@ var ApprovalRouter = class {
9771
10237
  rule_index: rule?.index ?? null,
9772
10238
  channel_name: `${NATIVE_CHANNEL_PREFIX}${params.origin}`,
9773
10239
  session_id: params.session_id,
10240
+ // Adapter-supplied ids are sideband-attributed by definition (issue
10241
+ // #251); deriving it at this single choke point means no future
10242
+ // adapter can forget it. Upstream stays absent: no MCP door here.
10243
+ session_source: params.session_id != null ? "sideband" : null,
9774
10244
  timeout_ms: timeoutMs,
9775
10245
  breached_budgets: params.breached_budgets
9776
10246
  });
@@ -9940,15 +10410,22 @@ function buildApprovalBlocks(ticket) {
9940
10410
  const safeName = sanitizeCodeSpanContent(ticket.tool_name);
9941
10411
  const rawInput = truncate(JSON.stringify(ticket.tool_input), MAX_INPUT_LENGTH);
9942
10412
  const safeInput = sanitizeForCodeBlock(rawInput);
9943
- const detailLines = [`*Tool:* \`${safeName}\``, `*Input:*
10413
+ const detailLines = [`*Tool:* \`${safeName}\``];
10414
+ if (ticket.upstream) {
10415
+ detailLines.push(`*Upstream:* \`${sanitizeCodeSpanContent(ticket.upstream)}\``);
10416
+ }
10417
+ detailLines.push(`*Input:*
9944
10418
  \`\`\`
9945
10419
  ${safeInput}
9946
- \`\`\``];
10420
+ \`\`\``);
9947
10421
  if (ticket.matched_rule) {
9948
10422
  detailLines.push(`*Rule:* ${sanitizeMrkdwnText(ticket.matched_rule)}`);
9949
10423
  }
9950
10424
  if (ticket.session_id) {
9951
- detailLines.push(`*Session:* \`${sanitizeCodeSpanContent(ticket.session_id)}\``);
10425
+ const sessionLine = `*Session:* \`${sanitizeCodeSpanContent(ticket.session_id)}\``;
10426
+ detailLines.push(
10427
+ ticket.session_source ? `${sessionLine} (${sanitizeMrkdwnText(ticket.session_source)})` : sessionLine
10428
+ );
9952
10429
  }
9953
10430
  const budgetBlocks = buildBudgetBlocks(ticket);
9954
10431
  return [
@@ -10538,7 +11015,7 @@ function createApprovalApp(router, queue, options) {
10538
11015
  // src/dashboard/api.ts
10539
11016
  import { readFileSync } from "fs";
10540
11017
  import { join } from "path";
10541
- import { randomUUID as randomUUID8 } from "crypto";
11018
+ import { randomBytes as randomBytes2, randomUUID as randomUUID8 } from "crypto";
10542
11019
  import { Hono as Hono8 } from "hono";
10543
11020
  import { HTTPException as HTTPException2 } from "hono/http-exception";
10544
11021
  import { z as z8 } from "zod";
@@ -10547,16 +11024,16 @@ import { serveStatic } from "@hono/node-server/serve-static";
10547
11024
  import { streamSSE } from "hono/streaming";
10548
11025
 
10549
11026
  // src/dashboard/session.ts
10550
- import { createHash as createHash3, createHmac as createHmac3, randomBytes, timingSafeEqual as timingSafeEqual3 } from "crypto";
11027
+ import { createHash as createHash4, createHmac as createHmac3, randomBytes, timingSafeEqual as timingSafeEqual3 } from "crypto";
10551
11028
  var DashboardSessionStore = class {
10552
- secret;
11029
+ signingKey;
10553
11030
  ttlMs;
10554
11031
  now;
10555
11032
  records = /* @__PURE__ */ new Map();
10556
11033
  timer = null;
10557
11034
  closed = false;
10558
11035
  constructor(options) {
10559
- this.secret = options.secret;
11036
+ this.signingKey = options.signingKey;
10560
11037
  this.ttlMs = options.ttlMs ?? 8 * 60 * 60 * 1e3;
10561
11038
  this.now = options.now ?? Date.now;
10562
11039
  const cleanupIntervalMs = options.cleanupIntervalMs ?? 6e4;
@@ -10628,13 +11105,13 @@ var DashboardSessionStore = class {
10628
11105
  const id = token.slice(0, dot);
10629
11106
  const signature = token.slice(dot + 1);
10630
11107
  const expected = this.sign(id);
10631
- const actualDigest = createHash3("sha256").update(signature).digest();
10632
- const expectedDigest = createHash3("sha256").update(expected).digest();
11108
+ const actualDigest = createHash4("sha256").update(signature).digest();
11109
+ const expectedDigest = createHash4("sha256").update(expected).digest();
10633
11110
  if (!timingSafeEqual3(actualDigest, expectedDigest)) return void 0;
10634
11111
  return id;
10635
11112
  }
10636
11113
  sign(id) {
10637
- return createHmac3("sha256", this.secret).update(id).digest("base64url");
11114
+ return createHmac3("sha256", this.signingKey).update(id).digest("base64url");
10638
11115
  }
10639
11116
  };
10640
11117
 
@@ -10658,7 +11135,13 @@ var clampedQueryInt = (fallback, min, max) => z8.preprocess(
10658
11135
  );
10659
11136
  var feedQuerySchema = z8.object({
10660
11137
  limit: clampedQueryInt(50, 1, 200),
10661
- offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER)
11138
+ offset: clampedQueryInt(0, 0, Number.MAX_SAFE_INTEGER),
11139
+ // The feed's server-side filters (issues #292, #316): attribution and
11140
+ // session identity source must narrow the fetch window itself, because
11141
+ // slicing an unfiltered newest-N window client-side would miss rare
11142
+ // matches on a busy stream.
11143
+ upstream: optionalQueryString,
11144
+ session_source: optionalQueryString
10662
11145
  });
10663
11146
  var auditExportQuerySchema = z8.object({
10664
11147
  format: z8.preprocess((value) => value === "csv" ? "csv" : "json", z8.enum(["json", "csv"])),
@@ -10677,7 +11160,9 @@ var auditExportQuerySchema = z8.object({
10677
11160
  origin: optionalQueryString,
10678
11161
  record_kind: optionalQueryString,
10679
11162
  channel_id: optionalQueryString,
10680
- sender_id: optionalQueryString
11163
+ sender_id: optionalQueryString,
11164
+ upstream: optionalQueryString,
11165
+ session_source: optionalQueryString
10681
11166
  });
10682
11167
  var auditQuerySchema = z8.object({
10683
11168
  limit: clampedQueryInt(50, 1, LIST_MAX_PAGE_SIZE),
@@ -10697,7 +11182,9 @@ var auditQuerySchema = z8.object({
10697
11182
  origin: optionalQueryString,
10698
11183
  record_kind: optionalQueryString,
10699
11184
  channel_id: optionalQueryString,
10700
- sender_id: optionalQueryString
11185
+ sender_id: optionalQueryString,
11186
+ upstream: optionalQueryString,
11187
+ session_source: optionalQueryString
10701
11188
  });
10702
11189
  var budgetEventsQuerySchema = z8.object({
10703
11190
  limit: clampedQueryInt(50, 1, LIST_MAX_PAGE_SIZE),
@@ -10709,7 +11196,8 @@ var budgetEventsExportQuerySchema = z8.object({
10709
11196
  });
10710
11197
  var analyticsQuerySchema = z8.object({
10711
11198
  from: optionalQueryString,
10712
- to: optionalQueryString
11199
+ to: optionalQueryString,
11200
+ upstream: optionalQueryString
10713
11201
  });
10714
11202
  var authSessionBodySchema = z8.object({
10715
11203
  secret: z8.string()
@@ -10770,6 +11258,8 @@ function isPrivateIpv4(host) {
10770
11258
  if (a > 255 || b > 255 || c > 255 || d > 255) return false;
10771
11259
  return a === 10 || a === 172 && b >= 16 && b <= 31 || a === 192 && b === 168;
10772
11260
  }
11261
+ var MAX_SSE_CONNECTIONS = 256;
11262
+ var REFUSAL_LOG_WINDOW_MS2 = 1e4;
10773
11263
  function createDashboardAppWithLifecycle(deps, options) {
10774
11264
  const {
10775
11265
  auditStore,
@@ -10783,7 +11273,10 @@ function createDashboardAppWithLifecycle(deps, options) {
10783
11273
  budgets
10784
11274
  } = deps;
10785
11275
  const apiSecret = options?.apiSecret;
10786
- const sessionStore = apiSecret ? new DashboardSessionStore({ secret: apiSecret, ttlMs: SESSION_TTL_MS }) : void 0;
11276
+ const sessionStore = apiSecret ? new DashboardSessionStore({
11277
+ signingKey: randomBytes2(32).toString("hex"),
11278
+ ttlMs: SESSION_TTL_MS
11279
+ }) : void 0;
10787
11280
  const app = new Hono8();
10788
11281
  app.onError((err, c) => {
10789
11282
  if (err instanceof HTTPException2) return err.getResponse();
@@ -10906,7 +11399,10 @@ function createDashboardAppWithLifecycle(deps, options) {
10906
11399
  const query = feedQuerySchema.parse(c.req.query());
10907
11400
  const limit = query.limit;
10908
11401
  const offset = query.offset;
10909
- const result = auditStore.list({}, { limit, offset, order: "desc" });
11402
+ const result = auditStore.list(
11403
+ { upstream: query.upstream, session_source: query.session_source },
11404
+ { limit, offset, order: "desc" }
11405
+ );
10910
11406
  return c.json({
10911
11407
  data: result.records,
10912
11408
  total: result.total,
@@ -10933,7 +11429,9 @@ function createDashboardAppWithLifecycle(deps, options) {
10933
11429
  origin: query.origin,
10934
11430
  record_kind: query.record_kind,
10935
11431
  channel_id: query.channel_id,
10936
- sender_id: query.sender_id
11432
+ sender_id: query.sender_id,
11433
+ upstream: query.upstream,
11434
+ session_source: query.session_source
10937
11435
  };
10938
11436
  const result = auditStore.listForExport(filters, limit);
10939
11437
  if (format === "csv") {
@@ -10979,7 +11477,9 @@ function createDashboardAppWithLifecycle(deps, options) {
10979
11477
  origin: query.origin,
10980
11478
  record_kind: query.record_kind,
10981
11479
  channel_id: query.channel_id,
10982
- sender_id: query.sender_id
11480
+ sender_id: query.sender_id,
11481
+ upstream: query.upstream,
11482
+ session_source: query.session_source
10983
11483
  };
10984
11484
  const result = auditStore.list(filters, { limit, offset, order: "desc" });
10985
11485
  return c.json({
@@ -11045,7 +11545,7 @@ function createDashboardAppWithLifecycle(deps, options) {
11045
11545
  const defaultFrom = new Date(now.getTime() - 24 * 60 * 60 * 1e3).toISOString();
11046
11546
  const from = query.from ?? defaultFrom;
11047
11547
  const to = query.to ?? now.toISOString();
11048
- const stats = auditStore.aggregate(from, to);
11548
+ const stats = auditStore.aggregate(from, to, { upstream: query.upstream });
11049
11549
  return c.json(stats);
11050
11550
  });
11051
11551
  app.get("/api/evidence/:session_id", (c) => {
@@ -11057,17 +11557,22 @@ function createDashboardAppWithLifecycle(deps, options) {
11057
11557
  let closed = false;
11058
11558
  const heartbeatMs = Math.max(options?.sseHeartbeatMs ?? 3e4, 1e3);
11059
11559
  const staleThresholdMs = heartbeatMs * 3;
11060
- const sweepIntervalMs = Math.max(heartbeatMs * 2, 1e4);
11061
- const sweepInterval = setInterval(() => {
11062
- const now = Date.now();
11063
- for (const [id, conn] of activeConnections) {
11064
- if (now - conn.lastWrite > staleThresholdMs) {
11065
- conn.cleanup();
11066
- activeConnections.delete(id);
11560
+ const sweepIntervalMs = options?.sweepIntervalMs ?? Math.max(heartbeatMs * 2, 1e4);
11561
+ const maxSseConnections = options?.maxSseConnections ?? MAX_SSE_CONNECTIONS;
11562
+ let sweepInterval;
11563
+ if (sweepIntervalMs > 0) {
11564
+ sweepInterval = setInterval(() => {
11565
+ const now = Date.now();
11566
+ for (const [id, conn] of activeConnections) {
11567
+ if (now - conn.lastWrite > staleThresholdMs) {
11568
+ conn.cleanup();
11569
+ conn.sever();
11570
+ activeConnections.delete(id);
11571
+ }
11067
11572
  }
11068
- }
11069
- }, sweepIntervalMs);
11070
- sweepInterval.unref();
11573
+ }, sweepIntervalMs);
11574
+ sweepInterval.unref();
11575
+ }
11071
11576
  const close = () => {
11072
11577
  if (closed) return;
11073
11578
  closed = true;
@@ -11077,7 +11582,22 @@ function createDashboardAppWithLifecycle(deps, options) {
11077
11582
  }
11078
11583
  activeConnections.clear();
11079
11584
  };
11585
+ let refusalCount = 0;
11586
+ let lastRefusalLogAt = null;
11587
+ const logRefusal = () => {
11588
+ refusalCount += 1;
11589
+ const now = Date.now();
11590
+ if (lastRefusalLogAt !== null && now - lastRefusalLogAt < REFUSAL_LOG_WINDOW_MS2) return;
11591
+ lastRefusalLogAt = now;
11592
+ console.error(
11593
+ `[helio] /api/events at connection cap (${String(maxSseConnections)}); refusing new streams (${String(refusalCount)} refusals so far).`
11594
+ );
11595
+ };
11080
11596
  app.get("/api/events", (c) => {
11597
+ if (activeConnections.size >= maxSseConnections) {
11598
+ logRefusal();
11599
+ return c.json({ error: "connection capacity reached" }, 503);
11600
+ }
11081
11601
  return streamSSE(c, async (stream) => {
11082
11602
  if (closed) return;
11083
11603
  const connId = randomUUID8();
@@ -11098,7 +11618,12 @@ function createDashboardAppWithLifecycle(deps, options) {
11098
11618
  activeConnections.delete(connId);
11099
11619
  releaseStream();
11100
11620
  };
11101
- activeConnections.set(connId, { cleanup, lastWrite: Date.now() });
11621
+ const sever = () => {
11622
+ stream.abort();
11623
+ c.env?.outgoing?.destroy();
11624
+ };
11625
+ if (activeConnections.size >= maxSseConnections) return;
11626
+ activeConnections.set(connId, { cleanup, sever, lastWrite: Date.now() });
11102
11627
  try {
11103
11628
  await stream.writeSSE({ data: "", event: "heartbeat" });
11104
11629
  } catch {
@@ -11237,9 +11762,12 @@ export {
11237
11762
  createApprovalApp,
11238
11763
  createChannels,
11239
11764
  createDashboardApp,
11765
+ createMultiApp,
11240
11766
  createSidebandApp,
11241
11767
  createSlackActionApp,
11242
11768
  evaluatePolicy,
11769
+ isNamedConfig,
11770
+ isSingularConfig,
11243
11771
  loadConfig,
11244
11772
  matchRule,
11245
11773
  startServer,