@sleep2agi/commhub-server 0.9.0-preview.0 → 0.9.0-preview.100

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (273) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +92 -11
  3. package/package.json +8 -7
  4. package/src/ack-create-request-transport.test.ts +159 -0
  5. package/src/ack-create-request.test.ts +331 -0
  6. package/src/admin-delete-network-http.test.ts +228 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/agent-access-batch.test.ts +210 -0
  9. package/src/agent-access.ts +487 -0
  10. package/src/agent-acl-groups-http.test.ts +247 -0
  11. package/src/agent-acl-http.test.ts +520 -0
  12. package/src/agent-acl-slice3-http.test.ts +267 -0
  13. package/src/agent-groups-perf.test.ts +190 -0
  14. package/src/agent-notice.ts +48 -0
  15. package/src/alias-filter.test.ts +72 -0
  16. package/src/alias-filter.ts +42 -0
  17. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  18. package/src/api-host-supervisors-fallback.test.ts +271 -0
  19. package/src/api-nodes-shape.test.ts +188 -0
  20. package/src/auth-kdf-migration.test.ts +378 -0
  21. package/src/auth-sessions-http.test.ts +250 -0
  22. package/src/auth-tokens.test.ts +18 -9
  23. package/src/auth-validate.test.ts +5 -1
  24. package/src/auth.ts +554 -38
  25. package/src/avatar-validate.test.ts +163 -0
  26. package/src/avatar-validate.ts +103 -0
  27. package/src/blocked-exit-on-reply.test.ts +122 -0
  28. package/src/boot-side-effect.test.ts +124 -0
  29. package/src/commhub-bin-args.test.ts +85 -0
  30. package/src/config-apply-sec1.test.ts +830 -0
  31. package/src/config-apply-validate.test.ts +352 -0
  32. package/src/config-apply-validate.ts +249 -0
  33. package/src/create-node-blocked-gate.test.ts +356 -0
  34. package/src/create-node-tool-schema.test.ts +39 -0
  35. package/src/create-node-validate.test.ts +275 -0
  36. package/src/create-node-validate.ts +288 -0
  37. package/src/create-node-workdir.test.ts +255 -0
  38. package/src/create-node.test.ts +354 -0
  39. package/src/create-node.ts +349 -0
  40. package/src/cross-tenant-injection.test.ts +110 -0
  41. package/src/dashboard-slash-routing-http.test.ts +107 -0
  42. package/src/db-adapter-guard.test.ts +148 -0
  43. package/src/db-adapter-translate.test.ts +128 -0
  44. package/src/db-adapter.ts +323 -87
  45. package/src/db-timestamp.test.ts +38 -0
  46. package/src/db-timestamp.ts +15 -0
  47. package/src/db.ts +1268 -42
  48. package/src/delivered-without-push.test.ts +140 -0
  49. package/src/department-groups-http.test.ts +209 -0
  50. package/src/department-groups-sync-http.test.ts +328 -0
  51. package/src/department-groups.ts +434 -0
  52. package/src/department-heads-http.test.ts +329 -0
  53. package/src/department-heads.ts +226 -0
  54. package/src/departments-http.test.ts +185 -0
  55. package/src/departments.ts +260 -0
  56. package/src/desktop-message-seam.test.ts +150 -0
  57. package/src/desktop-message.test.ts +368 -0
  58. package/src/dm-file-scope-http.test.ts +178 -0
  59. package/src/ext-token-shared.test.ts +60 -0
  60. package/src/ext-validation-regression.test.ts +131 -0
  61. package/src/external-schedule-edits-http.test.ts +321 -0
  62. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  63. package/src/external-schedule-edits.ts +327 -0
  64. package/src/file-download-authz-dev-open.test.ts +135 -0
  65. package/src/file-download-authz.test.ts +404 -0
  66. package/src/file-download-date-bucket.test.ts +436 -0
  67. package/src/file-network-scope-dev-open.test.ts +97 -0
  68. package/src/file-network-scope.test.ts +732 -0
  69. package/src/group-membership-events-http.test.ts +264 -0
  70. package/src/group-messages-http.test.ts +482 -0
  71. package/src/group-messages.ts +297 -0
  72. package/src/health-redaction.test.ts +642 -0
  73. package/src/http-gzip.test.ts +94 -0
  74. package/src/http-gzip.ts +112 -0
  75. package/src/hub-timestamp.test.ts +38 -0
  76. package/src/hub-timestamp.ts +27 -0
  77. package/src/human-dm.ts +215 -0
  78. package/src/inbox-count-sse.test.ts +310 -0
  79. package/src/inbox-count.ts +29 -0
  80. package/src/index.ts +15 -2128
  81. package/src/lifecycle-guard.ts +50 -0
  82. package/src/list-host-supervisors.test.ts +314 -0
  83. package/src/mcp-context-cost-http.test.ts +119 -0
  84. package/src/mcp-projects-events-http.test.ts +162 -0
  85. package/src/mcp-route-label-http.test.ts +120 -0
  86. package/src/mcp-write-network-resolution.test.ts +429 -0
  87. package/src/member-audit-network-id-http.test.ts +93 -0
  88. package/src/member-presence-http.test.ts +206 -0
  89. package/src/messages-ack-by-agent-http.test.ts +142 -0
  90. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  91. package/src/migrate-sqlite-to-pg.ts +263 -0
  92. package/src/model-auth-notify-http.test.ts +181 -0
  93. package/src/model-auth-notify.ts +116 -0
  94. package/src/network-name-http.test.ts +75 -0
  95. package/src/network-scope.ts +224 -0
  96. package/src/network-token-membership-http.test.ts +139 -0
  97. package/src/node-attrs-validate.ts +88 -0
  98. package/src/node-attrs.test.ts +217 -0
  99. package/src/node-create-request-status-http.test.ts +92 -0
  100. package/src/node-files-transport.test.ts +344 -0
  101. package/src/node-health-dispatch-http.test.ts +279 -0
  102. package/src/node-health-guard.ts +105 -0
  103. package/src/node-health-passthrough.test.ts +115 -0
  104. package/src/node-health-store.ts +72 -0
  105. package/src/node-id-alias.test.ts +188 -0
  106. package/src/node-identity-conflict-http.test.ts +269 -0
  107. package/src/node-identity-conflict.ts +142 -0
  108. package/src/node-identity-mismatch.test.ts +74 -0
  109. package/src/node-lifecycle-controllable.test.ts +64 -0
  110. package/src/node-lifecycle-controllable.ts +51 -0
  111. package/src/node-logs-transport.test.ts +263 -0
  112. package/src/node-permissions-http.test.ts +276 -0
  113. package/src/node-permissions.ts +242 -0
  114. package/src/node-request-retention.test.ts +281 -0
  115. package/src/node-request-retention.ts +120 -0
  116. package/src/node-skills-transport.test.ts +209 -0
  117. package/src/node-token-privilege-http.test.ts +149 -0
  118. package/src/observer-avatar-http.test.ts +431 -0
  119. package/src/observer-push.test.ts +252 -0
  120. package/src/package-version-consistency.test.ts +25 -0
  121. package/src/peer-reply-atomic.test.ts +455 -0
  122. package/src/peer-reply-capability.test.ts +70 -0
  123. package/src/pg-worker.ts +87 -0
  124. package/src/probe-validate.test.ts +221 -0
  125. package/src/probe-validate.ts +104 -0
  126. package/src/probe.test.ts +80 -0
  127. package/src/probe.ts +200 -0
  128. package/src/project-viewer-can-http.test.ts +114 -0
  129. package/src/push.ts +429 -10
  130. package/src/redact-tokens.test.ts +65 -0
  131. package/src/redact-tokens.ts +40 -0
  132. package/src/rename.ts +7 -2
  133. package/src/reply-target-derivation.test.ts +96 -0
  134. package/src/report-status-host-schema.test.ts +211 -0
  135. package/src/report-status-resume-id-handover.test.ts +150 -0
  136. package/src/require-explicit-test-db.ts +9 -0
  137. package/src/requirement-due-reminders-http.test.ts +390 -0
  138. package/src/requirement-due-reminders.ts +310 -0
  139. package/src/requirement-events-http.test.ts +257 -0
  140. package/src/requirement-events.test.ts +45 -0
  141. package/src/requirement-events.ts +144 -0
  142. package/src/requirement-notify.ts +165 -0
  143. package/src/requirement-participant-notify-http.test.ts +309 -0
  144. package/src/requirement-tag-ops-http.test.ts +266 -0
  145. package/src/requirement-tags.ts +89 -0
  146. package/src/requirements-comment-http.test.ts +165 -0
  147. package/src/requirements-completed-at-migration.test.ts +70 -0
  148. package/src/requirements-errors-mcp-http.test.ts +164 -0
  149. package/src/requirements-errors.ts +143 -0
  150. package/src/requirements-etag-http.test.ts +91 -0
  151. package/src/requirements-http.test.ts +709 -0
  152. package/src/requirements-last-event-http.test.ts +344 -0
  153. package/src/requirements-list-mcp-slim-http.test.ts +155 -0
  154. package/src/requirements-list-slim-http.test.ts +319 -0
  155. package/src/requirements-mcp.test.ts +163 -0
  156. package/src/requirements-migrate.ts +225 -0
  157. package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
  158. package/src/requirements-people-http.test.ts +114 -0
  159. package/src/requirements-people-mcp-http.test.ts +225 -0
  160. package/src/requirements-people.ts +127 -0
  161. package/src/requirements-priority-lowest.test.ts +114 -0
  162. package/src/requirements-search-http.test.ts +182 -0
  163. package/src/requirements-search.test.ts +32 -0
  164. package/src/requirements-search.ts +145 -0
  165. package/src/requirements-seq-http.test.ts +193 -0
  166. package/src/requirements-seq-migration.test.ts +63 -0
  167. package/src/requirements-start-date.test.ts +125 -0
  168. package/src/requirements-stats-http.test.ts +288 -0
  169. package/src/requirements-stats.ts +164 -0
  170. package/src/requirements-status-participants-http.test.ts +220 -0
  171. package/src/requirements-upgrade-e2e.test.ts +126 -0
  172. package/src/requirements-upgrade-migration.test.ts +98 -0
  173. package/src/requirements.ts +1761 -0
  174. package/src/resolve-port.test.ts +57 -0
  175. package/src/resolve-port.ts +44 -0
  176. package/src/response-charset.test.ts +130 -0
  177. package/src/rest-explicit-columns-http.test.ts +222 -0
  178. package/src/rest-identity.test.ts +129 -0
  179. package/src/rest-identity.ts +100 -0
  180. package/src/rest-projections.ts +68 -0
  181. package/src/rest-write-network-resolution.test.ts +133 -0
  182. package/src/restricted-files.ts +73 -0
  183. package/src/retention.test.ts +450 -0
  184. package/src/retention.ts +281 -0
  185. package/src/route-callers-http.test.ts +120 -0
  186. package/src/route-timing.test.ts +211 -0
  187. package/src/route-timing.ts +258 -0
  188. package/src/rules-file-duplicate-alias.test.ts +234 -0
  189. package/src/rules-file-session-target.test.ts +207 -0
  190. package/src/rules-file-transport.test.ts +237 -0
  191. package/src/runtime-label.test.ts +22 -0
  192. package/src/runtime-label.ts +21 -0
  193. package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
  194. package/src/scheduled-reply-unread-http.test.ts +212 -0
  195. package/src/scheduled-run-terminal.test.ts +268 -0
  196. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  197. package/src/scheduled-stuck-http.test.ts +238 -0
  198. package/src/scheduled-stuck.ts +153 -0
  199. package/src/scheduled-tasks-http.test.ts +638 -0
  200. package/src/scheduled-tasks.ts +641 -0
  201. package/src/send-dedup-request-id.test.ts +229 -0
  202. package/src/send-reply-agent-warning.test.ts +202 -0
  203. package/src/send-reply-alias-optional.test.ts +131 -0
  204. package/src/send-reply-attachments.test.ts +458 -0
  205. package/src/send_dedup.test.ts +54 -0
  206. package/src/send_dedup.ts +52 -6
  207. package/src/serve-error.test.ts +38 -0
  208. package/src/serve-error.ts +27 -0
  209. package/src/server.ts +5038 -0
  210. package/src/shared/external-schedule-contract.ts +60 -0
  211. package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
  212. package/src/shared/probe-host-allowlist.ts +128 -0
  213. package/src/shared/reserved-env-drift.test.ts +43 -0
  214. package/src/shared/reserved-env.test.ts +35 -0
  215. package/src/shared/reserved-env.ts +31 -0
  216. package/src/side-thread-command-transport.test.ts +312 -0
  217. package/src/side-thread-command-transport.ts +318 -0
  218. package/src/side-thread-http-integration.test.ts +233 -0
  219. package/src/side-thread-http.ts +504 -0
  220. package/src/side-thread-production.test.ts +72 -0
  221. package/src/side-thread-production.ts +118 -0
  222. package/src/side-thread.test.ts +1067 -0
  223. package/src/side-thread.ts +2499 -0
  224. package/src/skillhub-http.test.ts +108 -0
  225. package/src/sqlite-close-checkpoint.test.ts +78 -0
  226. package/src/stale-sweeper.test.ts +181 -0
  227. package/src/stale-sweeper.ts +114 -0
  228. package/src/start-node.test.ts +109 -0
  229. package/src/status-alias-filter-http.test.ts +106 -0
  230. package/src/status-alias-resolver-http.test.ts +163 -0
  231. package/src/status-alias-resolver.ts +19 -0
  232. package/src/status-node-id-filter-http.test.ts +161 -0
  233. package/src/status-read-cache-http.test.ts +229 -0
  234. package/src/status-read-cache.ts +122 -0
  235. package/src/stop-delete-node.test.ts +1164 -0
  236. package/src/task-access-http.test.ts +379 -0
  237. package/src/task-access.ts +413 -0
  238. package/src/task-auth-origin-http.test.ts +91 -0
  239. package/src/task-auth-origin.test.ts +24 -0
  240. package/src/task-auth-origin.ts +7 -0
  241. package/src/task-consumption.test.ts +482 -0
  242. package/src/task-diagnostic-http.test.ts +149 -0
  243. package/src/task-diagnostic.test.ts +115 -0
  244. package/src/task-diagnostic.ts +118 -0
  245. package/src/task-expiry-notice-http.test.ts +287 -0
  246. package/src/task-expiry-notice.ts +313 -0
  247. package/src/task-idempotency-mcp.test.ts +142 -0
  248. package/src/task-idempotency.test.ts +60 -0
  249. package/src/task-idempotency.ts +75 -0
  250. package/src/task-lifecycle-watcher.test.ts +204 -0
  251. package/src/task-lifecycle-watcher.ts +49 -0
  252. package/src/task-network-resolution-http.test.ts +141 -0
  253. package/src/task-queue-ahead-http.test.ts +309 -0
  254. package/src/task-queue-ahead-scale.test.ts +211 -0
  255. package/src/task-queue-ahead.ts +186 -0
  256. package/src/task-stale-open-http.test.ts +290 -0
  257. package/src/task-stale-open.ts +103 -0
  258. package/src/tasks-pagination-http.test.ts +141 -0
  259. package/src/tool-audience-http.test.ts +195 -0
  260. package/src/tool-audience.ts +109 -0
  261. package/src/tools-list-json-schema.test.ts +67 -0
  262. package/src/tools.ts +5426 -275
  263. package/src/update-provider.test.ts +234 -0
  264. package/src/upload-early-reject-guard.test.ts +155 -0
  265. package/src/uploads-http.test.ts +97 -15
  266. package/src/uploads.test.ts +66 -0
  267. package/src/uploads.ts +138 -5
  268. package/src/user-inbox-by-agent-http.test.ts +161 -0
  269. package/src/user-inbox-netid-notnull.test.ts +122 -0
  270. package/src/user-inbox-read-http.test.ts +223 -0
  271. package/src/user-inbox-schema.test.ts +54 -0
  272. package/src/vault.test.ts +200 -0
  273. package/src/vault.ts +156 -0
@@ -0,0 +1,281 @@
1
+ // Round-2/4 review ② — retention sweep + incremental VACUUM.
2
+ //
3
+ // Background maintenance for the commhub database. Multi-tenant
4
+ // public-facing hubs (e.g. <hub-domain>) write to high-frequency tables
5
+ // on every heartbeat (agent_telemetry, task_events, completions) — left
6
+ // unswept these grow unbounded, slow down indexed scans, and bloat the
7
+ // on-disk footprint. SQLite never reclaims freed pages without VACUUM,
8
+ // so a row delete by itself doesn't shrink the file.
9
+ //
10
+ // This module exposes:
11
+ // - sweepRetention(cfg): one-shot sweep across high-growth tables
12
+ // - startRetentionSweeper(): wires a 1-hour setInterval, fires once
13
+ // at startup with an immediate (non-blocking) sweep
14
+ //
15
+ // Defaults are conservative — long-tail debugging traces survive a
16
+ // week. Operators can override per env (COMMHUB_RETENTION_*_DAYS) for
17
+ // hot hubs.
18
+ //
19
+ // Run isolated tests against retention.test.ts:
20
+ // COMMHUB_DB=/tmp/test-retention.db bun test src/retention.test.ts
21
+
22
+ import { db } from "./db.js";
23
+ import { sweepNodeRequestContent, type NodeRequestSweepResult } from "./node-request-retention.js";
24
+
25
+ export type RetentionConfig = {
26
+ // High-frequency telemetry; default = 7 days.
27
+ // Heartbeat is ~30s → 50 agents × 86400 s/day ÷ 30 s = 144000 rows/day.
28
+ // At 7d that's ~1M rows. Indexed scans stay fast; raw disk = a few MB.
29
+ telemetryDays: number;
30
+ // Task state-change audit; default = 30 days.
31
+ // One row per status transition. Useful for incident forensics.
32
+ taskEventsDays: number;
33
+ // Acked inbox; default = 7 days post-ack.
34
+ // Once acked the row is no longer hot — keep a week for debugging.
35
+ // Unacked rows are NEVER swept (could be in-flight delivery).
36
+ ackedInboxDays: number;
37
+ // Completed/cancelled/failed tasks; default = 30 days.
38
+ // Active tasks (created/delivered/acked/running/replied less than
39
+ // 30 days old) are NEVER swept.
40
+ terminalTasksDays: number;
41
+ // Audit log; default = 90 days.
42
+ // Longer retention because audit_log is a security artifact.
43
+ auditLogDays: number;
44
+ };
45
+
46
+ export type SweepResult = {
47
+ startedAt: string;
48
+ durationMs: number;
49
+ deletes: {
50
+ agent_telemetry: number;
51
+ task_events: number;
52
+ inbox: number;
53
+ tasks: number;
54
+ audit_log: number;
55
+ };
56
+ // node_rules_requests: file bytes purged by age / after read, rows deleted
57
+ // after 30 days. Fixed policy (privacy, not disk) — no env opt-out.
58
+ nodeRequests: NodeRequestSweepResult;
59
+ vacuum: {
60
+ walCheckpointPagesMoved: number | null;
61
+ incrementalFreedPages: number | null;
62
+ errored: boolean;
63
+ };
64
+ };
65
+
66
+ const DEFAULTS: RetentionConfig = {
67
+ telemetryDays: 7,
68
+ taskEventsDays: 30,
69
+ ackedInboxDays: 7,
70
+ terminalTasksDays: 30,
71
+ auditLogDays: 90,
72
+ };
73
+
74
+ function readEnvDays(name: string, fallback: number): number {
75
+ const raw = process.env[name];
76
+ if (raw === undefined || raw === "") return fallback;
77
+ const n = Number(raw);
78
+ // Negative = disable that table's sweep entirely (operator opt-out).
79
+ // NaN / non-finite falls back to default — guard against fat-fingered env.
80
+ if (!Number.isFinite(n)) return fallback;
81
+ return n;
82
+ }
83
+
84
+ export function readRetentionConfig(): RetentionConfig {
85
+ return {
86
+ telemetryDays: readEnvDays("COMMHUB_RETENTION_TELEMETRY_DAYS", DEFAULTS.telemetryDays),
87
+ taskEventsDays: readEnvDays("COMMHUB_RETENTION_TASK_EVENTS_DAYS", DEFAULTS.taskEventsDays),
88
+ ackedInboxDays: readEnvDays("COMMHUB_RETENTION_INBOX_ACKED_DAYS", DEFAULTS.ackedInboxDays),
89
+ terminalTasksDays: readEnvDays("COMMHUB_RETENTION_TASKS_TERMINAL_DAYS", DEFAULTS.terminalTasksDays),
90
+ auditLogDays: readEnvDays("COMMHUB_RETENTION_AUDIT_DAYS", DEFAULTS.auditLogDays),
91
+ };
92
+ }
93
+
94
+ function sweepOne(
95
+ sql: string,
96
+ params: (string | number)[],
97
+ days: number,
98
+ ): number {
99
+ // days < 0 → operator opt-out for this table; skip the DELETE.
100
+ // days === 0 is a legitimate "sweep everything older than now"
101
+ // (effectively a TRUNCATE); we honour it.
102
+ if (days < 0) return 0;
103
+ try {
104
+ const res = db.run(sql, params);
105
+ return res.changes ?? 0;
106
+ } catch (e: any) {
107
+ console.log(`[commhub retention] sweep failed: ${e?.message ?? e}`);
108
+ return 0;
109
+ }
110
+ }
111
+
112
+ export function sweepRetention(cfg: RetentionConfig = readRetentionConfig()): SweepResult {
113
+ const startedAt = new Date().toISOString();
114
+ const t0 = Date.now();
115
+
116
+ const telemetry = sweepOne(
117
+ `DELETE FROM agent_telemetry WHERE created_at < datetime('now', ?1)`,
118
+ [`-${cfg.telemetryDays} days`],
119
+ cfg.telemetryDays,
120
+ );
121
+ const taskEvents = sweepOne(
122
+ `DELETE FROM task_events WHERE created_at < datetime('now', ?1)`,
123
+ [`-${cfg.taskEventsDays} days`],
124
+ cfg.taskEventsDays,
125
+ );
126
+ // Only sweep ACKED inbox rows — unacked may be in-flight delivery
127
+ // for an agent that's offline but coming back. Acked rows have
128
+ // served their purpose and exist only for debugging.
129
+ const inbox = sweepOne(
130
+ `DELETE FROM inbox WHERE acked = 1 AND created_at < datetime('now', ?1)`,
131
+ [`-${cfg.ackedInboxDays} days`],
132
+ cfg.ackedInboxDays,
133
+ );
134
+ // Terminal-task sweep — split into two DELETEs:
135
+ //
136
+ // (a) "safe terminals": cancelled / failed / expired / (theoretical)
137
+ // completed have no chain-ancestor risk and are safe to reap
138
+ // unconditionally past the horizon. NOTE: as of this writing
139
+ // 'completed' is dead code — no code path sets it (every reply
140
+ // uses 'replied'/'failed'/'cancelled' via send_reply or
141
+ // chainReplyToParent). Kept in the IN-list as a forward guard:
142
+ // if someone introduces a 'completed' write later, the sweep
143
+ // picks it up without code changes.
144
+ //
145
+ // (b) "replied" — was excluded entirely in the original PR, which
146
+ // missed the goal: every fulfilled task lives at 'replied'
147
+ // status, so the tasks table's MAIN GROWTH SOURCE would never
148
+ // be reclaimed. Reviewer caught this. Fix: child-ref-aware
149
+ // delete — reap an old replied row ONLY IF no other task
150
+ // references it as parent_task_id. A row with no children is
151
+ // not a chain ancestor and is safe to drop.
152
+ //
153
+ // **Time field** (both DELETEs): use COALESCE(completed_at, created_at).
154
+ // Terminal status means the server set `completed_at` at the
155
+ // reply/fail/cancel moment. Sweeping on `created_at` alone would
156
+ // reap a task created 60d ago but completed today the instant it
157
+ // entered terminal state — zero post-terminal retention. The
158
+ // COALESCE falls back to created_at for legacy rows that pre-date
159
+ // the completed_at column (back-compat).
160
+ const tasksSafeTerminal = sweepOne(
161
+ `DELETE FROM tasks
162
+ WHERE status IN ('completed', 'cancelled', 'failed', 'expired')
163
+ AND COALESCE(completed_at, created_at) < datetime('now', ?1)`,
164
+ [`-${cfg.terminalTasksDays} days`],
165
+ cfg.terminalTasksDays,
166
+ );
167
+ const tasksRepliedChildSafe = sweepOne(
168
+ `DELETE FROM tasks
169
+ WHERE status = 'replied'
170
+ AND COALESCE(completed_at, created_at) < datetime('now', ?1)
171
+ AND NOT EXISTS (
172
+ SELECT 1 FROM tasks AS child
173
+ WHERE child.parent_task_id = tasks.task_id
174
+ )`,
175
+ [`-${cfg.terminalTasksDays} days`],
176
+ cfg.terminalTasksDays,
177
+ );
178
+ const tasks = tasksSafeTerminal + tasksRepliedChildSafe;
179
+ const auditLog = sweepOne(
180
+ `DELETE FROM audit_log WHERE created_at < datetime('now', ?1)`,
181
+ [`-${cfg.auditLogDays} days`],
182
+ cfg.auditLogDays,
183
+ );
184
+
185
+ const nodeRequests = sweepNodeRequestContent();
186
+
187
+ // VACUUM:
188
+ // - wal_checkpoint(TRUNCATE): trims the WAL file. Works on every
189
+ // SQLite DB regardless of auto_vacuum setting. Useful after
190
+ // big DELETEs so the WAL doesn't stay bloated until the next
191
+ // natural checkpoint.
192
+ // - incremental_vacuum: ONLY reclaims main-DB pages on databases
193
+ // created with `PRAGMA auto_vacuum = INCREMENTAL`. On legacy
194
+ // DBs (the default mode 0) this is a no-op and the main file
195
+ // does NOT shrink — freed pages stay in the freelist for
196
+ // future inserts. Operators who want disk reclamation on old
197
+ // DBs must run a one-time blocking `VACUUM` themselves (out of
198
+ // scope for the sweeper, which deliberately stays non-
199
+ // blocking).
200
+ // Both are SQLite PRAGMAs; on PostgreSQL autovacuum does this job and
201
+ // the two counters stay null (RFC-039 S3).
202
+ let walCheckpointPagesMoved: number | null = null;
203
+ let incrementalFreedPages: number | null = null;
204
+ let errored = false;
205
+ const sqliteVacuum = db.dialect === "sqlite";
206
+ if (sqliteVacuum) try {
207
+ // wal_checkpoint(TRUNCATE) returns a row { busy, log, checkpointed }.
208
+ // We expose `log` (page count moved into the main DB) for ops visibility.
209
+ const checkpoint = db.get<{ busy: number; log: number; checkpointed: number }>(
210
+ "PRAGMA wal_checkpoint(TRUNCATE)",
211
+ );
212
+ walCheckpointPagesMoved = checkpoint?.log ?? null;
213
+ } catch (e: any) {
214
+ console.log(`[commhub retention] wal_checkpoint failed: ${e?.message ?? e}`);
215
+ errored = true;
216
+ }
217
+ if (sqliteVacuum) try {
218
+ // incremental_vacuum is a no-op on databases not created with
219
+ // PRAGMA auto_vacuum = INCREMENTAL. For existing prod DBs that
220
+ // weren't, the operator would have to run a full one-time VACUUM
221
+ // to migrate — out of scope for the sweeper.
222
+ const freelistBefore = db.get<{ freelist_count: number }>("PRAGMA freelist_count");
223
+ db.exec("PRAGMA incremental_vacuum");
224
+ const freelistAfter = db.get<{ freelist_count: number }>("PRAGMA freelist_count");
225
+ incrementalFreedPages =
226
+ (freelistBefore?.freelist_count ?? 0) - (freelistAfter?.freelist_count ?? 0);
227
+ } catch (e: any) {
228
+ console.log(`[commhub retention] incremental_vacuum failed: ${e?.message ?? e}`);
229
+ errored = true;
230
+ }
231
+
232
+ return {
233
+ startedAt,
234
+ durationMs: Date.now() - t0,
235
+ deletes: {
236
+ agent_telemetry: telemetry,
237
+ task_events: taskEvents,
238
+ inbox,
239
+ tasks,
240
+ audit_log: auditLog,
241
+ },
242
+ nodeRequests,
243
+ vacuum: { walCheckpointPagesMoved, incrementalFreedPages, errored },
244
+ };
245
+ }
246
+
247
+ // Used by index.ts to wire the periodic sweeper. Returns the timer so
248
+ // callers can stop it in tests / shutdown.
249
+ export function startRetentionSweeper(
250
+ intervalMs = 60 * 60 * 1000, // 1 hour
251
+ cfg?: RetentionConfig,
252
+ ): ReturnType<typeof setInterval> {
253
+ const sweep = () => {
254
+ try {
255
+ const r = sweepRetention(cfg);
256
+ const totalDeletes =
257
+ r.deletes.agent_telemetry +
258
+ r.deletes.task_events +
259
+ r.deletes.inbox +
260
+ r.deletes.tasks +
261
+ r.deletes.audit_log;
262
+ const nr = r.nodeRequests;
263
+ if (totalDeletes > 0 || nr.purged > 0 || nr.deleted > 0 || nr.timedOut > 0 || r.vacuum.errored) {
264
+ console.log(
265
+ `[commhub retention] swept in ${r.durationMs}ms — ` +
266
+ `telemetry=${r.deletes.agent_telemetry} events=${r.deletes.task_events} ` +
267
+ `inbox=${r.deletes.inbox} tasks=${r.deletes.tasks} audit=${r.deletes.audit_log} ` +
268
+ `nodeReqPurged=${nr.purged} nodeReqDeleted=${nr.deleted} nodeReqTimedOut=${nr.timedOut} ` +
269
+ `walPages=${r.vacuum.walCheckpointPagesMoved} freedPages=${r.vacuum.incrementalFreedPages}`,
270
+ );
271
+ }
272
+ } catch (e: any) {
273
+ console.log(`[commhub retention] sweep top-level failed: ${e?.message ?? e}`);
274
+ }
275
+ };
276
+ // Fire once on startup so a long-running hub eventually catches up
277
+ // even if it's restarted just after the previous sweep. setImmediate
278
+ // (vs synchronous) keeps server startup from blocking on the sweep.
279
+ setImmediate(sweep);
280
+ return setInterval(sweep, intervalMs);
281
+ }
@@ -0,0 +1,120 @@
1
+ // GET /api/stats/routes?by=caller — per-route caller classes (token kind + User-Agent family).
2
+ // Production serves the full GET /api/status ~2.8/s and every caller arrives as 127.0.0.1 through the
3
+ // frp tunnel, with no access log; this breakdown is how we tell who it is. Pinned over real HTTP:
4
+ // - without by=caller the body is byte-for-byte what routeStats() produced before this change;
5
+ // - with it, each route carries callers [{class, count, bytes}] counted per class;
6
+ // - no token, user id, node id, username or raw User-Agent text ever reaches the output;
7
+ // - distinct classes per route are capped, the rest folded into "(other)";
8
+ // - still admin-only.
9
+
10
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
11
+ import { mkdtempSync, rmSync } from "node:fs";
12
+ import { tmpdir } from "node:os";
13
+ import { join } from "node:path";
14
+ import { ROUTE_MAX_CALLERS_PER_ROUTE, ROUTE_OVERFLOW_KEY, routeStats } from "./route-timing.js";
15
+
16
+ const DIR = mkdtempSync(join(tmpdir(), "anet-route-callers-"));
17
+ process.env.HOME = DIR;
18
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
19
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
20
+
21
+ const PW = "RouteCallersPassw0rd!";
22
+ const NODE_ID = "node_route_callers_probe";
23
+ const NODE_NAME = "rc-probe-node";
24
+ const UA_MARKER = "zz-ua-marker-41c9";
25
+ let BASE = "";
26
+ let hub: any = null;
27
+ let admin = "";
28
+ let adminId = "";
29
+ let adminName = "";
30
+ let member = "";
31
+ let ntok = "";
32
+
33
+ const get = (path: string, headers: Record<string, string> = {}) => fetch(`${BASE}${path}`, { headers });
34
+ const auth = (token: string, ua?: string) => ({ Authorization: `Bearer ${token}`, ...(ua === undefined ? {} : { "User-Agent": ua }) });
35
+ async function byCaller(): Promise<{ text: string; rows: any[] }> {
36
+ const res = await get("/api/stats/routes?minutes=5&by=caller", auth(admin));
37
+ expect(res.status).toBe(200);
38
+ const text = await res.text();
39
+ return { text, rows: JSON.parse(text).routes };
40
+ }
41
+
42
+ beforeAll(async () => {
43
+ process.env.HOST = "127.0.0.1";
44
+ const { register, createNetworkTokenForNode } = await import("./auth.js");
45
+ const { db } = await import("./db.js");
46
+ adminName = `rc_admin_${Date.now()}`;
47
+ const a = register(adminName, PW);
48
+ admin = a.token!;
49
+ adminId = a.user!.user_id;
50
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [adminId]);
51
+ member = register(`rc_member_${Date.now()}`, PW).token!;
52
+ const minted = createNetworkTokenForNode(adminId, a.network_id!, NODE_NAME, NODE_ID);
53
+ expect(minted.ok).toBe(true);
54
+ ntok = minted.token!;
55
+ const mod: any = await import("./server.js");
56
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
57
+ BASE = `http://127.0.0.1:${hub.port}`;
58
+ }, 30_000);
59
+
60
+ afterAll(() => {
61
+ try { hub?.stop?.(true); } catch {}
62
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
63
+ });
64
+
65
+ describe("GET /api/stats/routes?by=caller", () => {
66
+ test("without by=caller the body is exactly the pre-existing routeStats() output", async () => {
67
+ await get("/api/status", auth(admin, "node"));
68
+ await get("/api/status?light=1", auth(ntok, "agent-node/2.5.0-preview.88"));
69
+ // The stats request is recorded only after its body is built, so nothing lands between these two.
70
+ const expected = JSON.stringify({ ok: true, ...routeStats(5 * 60_000) });
71
+ const res = await get("/api/stats/routes?minutes=5", auth(admin));
72
+ expect(await res.text()).toBe(expected);
73
+ const body = JSON.parse(expected);
74
+ expect(Object.keys(body)).toEqual(["ok", "window_ms", "samples", "oldest_at", "routes"]);
75
+ for (const r of body.routes) expect(Object.keys(r)).toEqual(["route", "count", "total_ms", "avg_ms", "p95_ms", "max_ms", "bytes", "errors"]);
76
+ });
77
+
78
+ test("callers are counted per class: token kind + UA family", async () => {
79
+ const before = (await byCaller()).rows.find(r => r.route === "GET /api/status")?.callers ?? [];
80
+ const count = (cs: any[], cls: string) => cs.find(c => c.class === cls)?.count ?? 0;
81
+ for (let i = 0; i < 3; i++) expect((await get("/api/status", auth(member, "node"))).status).toBe(200);
82
+ await get("/api/status", auth(ntok, `agent-node/2.5.0-preview.88 (${UA_MARKER})`));
83
+ await get("/api/status", auth(member, `Mozilla/5.0 (X11; ${UA_MARKER}) Chrome/130.0`));
84
+ await get("/api/status", { "User-Agent": "curl/8.5.0" }); // anonymous → 401, still counted
85
+ const row = (await byCaller()).rows.find(r => r.route === "GET /api/status")!;
86
+ const callers = row.callers as any[];
87
+ expect(count(callers, "user node") - count(before, "user node")).toBe(3);
88
+ expect(count(callers, "node agent-node/2.5.0-preview.88") - count(before, "node agent-node/2.5.0-preview.88")).toBe(1);
89
+ expect(count(callers, "user browser") - count(before, "user browser")).toBe(1);
90
+ expect(count(callers, "anon curl/8.5.0") - count(before, "anon curl/8.5.0")).toBe(1);
91
+ expect(callers.reduce((n: number, c: any) => n + c.count, 0)).toBe(row.count);
92
+ expect(callers.reduce((n: number, c: any) => n + c.bytes, 0)).toBe(row.bytes);
93
+ for (const c of callers) expect(Object.keys(c)).toEqual(["class", "count", "bytes"]);
94
+ });
95
+
96
+ test("no token, id, username or raw User-Agent text in the output", async () => {
97
+ await get(`/api/status?token=${ntok}`, { "User-Agent": `${UA_MARKER}/1.0` });
98
+ const { text, rows } = await byCaller();
99
+ for (const secret of [admin, member, ntok, adminId, adminName, NODE_ID, NODE_NAME, UA_MARKER, "Mozilla", "X11", "127.0.0.1", "ntok_", "utok_"]) {
100
+ expect(text).not.toContain(secret);
101
+ }
102
+ const classes = rows.flatMap(r => r.callers.map((c: any) => c.class));
103
+ expect(classes).toContain("node other");
104
+ for (const cls of classes) expect(cls).toMatch(/^(\(other\)|(node|user|master|token|anon) [a-z0-9._-]+(\/[0-9A-Za-z.-]+)?)$/);
105
+ });
106
+
107
+ test("distinct classes per route are capped, the rest folded into (other)", async () => {
108
+ const n = ROUTE_MAX_CALLERS_PER_ROUTE + 5;
109
+ for (let i = 0; i < n; i++) await get("/api/networks", auth(member, `curl/${i}.0.0`));
110
+ const row = (await byCaller()).rows.find(r => r.route === "GET /api/networks")!;
111
+ expect(row.callers.length).toBe(ROUTE_MAX_CALLERS_PER_ROUTE);
112
+ expect(row.callers.at(-1).class).toBe(ROUTE_OVERFLOW_KEY);
113
+ expect(row.callers.reduce((s: number, c: any) => s + c.count, 0)).toBe(row.count);
114
+ });
115
+
116
+ test("still admin-only", async () => {
117
+ expect((await get("/api/stats/routes?by=caller", auth(member))).status).toBe(403);
118
+ expect((await get("/api/stats/routes?by=caller", auth(ntok))).status).toBe(403);
119
+ });
120
+ });
@@ -0,0 +1,211 @@
1
+ import { expect, test } from "bun:test";
2
+ import {
3
+ __resetRouteTimingForTest, callerTokenKind, labelRoute, mcpRouteLabel, recordRouteTiming, routeKey, routeStats,
4
+ setRouteCallerMasterToken, setRouteCallerTokenReader, userAgentFamily, ROUTE_MAX_CALLERS_PER_ROUTE,
5
+ ROUTE_BUCKET_MS, ROUTE_BUCKET_RETENTION, ROUTE_MAX_KEYS_PER_BUCKET, ROUTE_OVERFLOW_KEY,
6
+ } from "./route-timing";
7
+
8
+ const u = (s: string) => new URL(`http://hub${s}`);
9
+ const req = (path: string, method = "GET") => new Request(`http://hub${path}`, { method });
10
+ const res = (status = 200, len?: number) => new Response("x", { status, headers: len === undefined ? {} : { "Content-Length": String(len) } });
11
+ const at = (ms: number) => performance.now() - ms;
12
+
13
+ test("routeKey collapses ids, aliases and file names, keeps payload-shaping params", () => {
14
+ expect(routeKey("GET", u("/api/requirements?network_id=net_1&limit=500"))).toBe("GET /api/requirements");
15
+ expect(routeKey("GET", u("/api/requirements?view=summary&network_id=n"))).toBe("GET /api/requirements?view=summary");
16
+ expect(routeKey("PATCH", u("/api/requirements/req_3f9a/checklist/c1"))).toBe("PATCH /api/requirements/:id/checklist/:id");
17
+ expect(routeKey("GET", u("/api/requirements/stats"))).toBe("GET /api/requirements/stats");
18
+ expect(routeKey("GET", u("/api/status?light=1"))).toBe("GET /api/status?light=1");
19
+ expect(routeKey("GET", u(`/api/nodes/${encodeURIComponent("通信牛")}/config`))).toBe("GET /api/nodes/:id/config");
20
+ expect(routeKey("GET", u("/api/messages?scope=user&limit=50"))).toBe("GET /api/messages?scope=user");
21
+ });
22
+
23
+ test("routeStats groups the window, sorts by total time, and counts every request (no sample cap)", () => {
24
+ __resetRouteTimingForTest();
25
+ const now = 1_000_000_000;
26
+ recordRouteTiming(req("/api/requirements"), at(30), res(200, 1000), now);
27
+ recordRouteTiming(req("/api/requirements"), at(10), res(200, 500), now);
28
+ recordRouteTiming(req("/api/status?light=1"), at(5), res(503), now);
29
+ recordRouteTiming(req("/api/status?light=1"), at(100), res(), now - 10 * 60_000); // 窗口外
30
+ expect(recordRouteTiming(req("/ws"), 0, undefined, now)).toBeUndefined();
31
+ const stats = routeStats(60_000, now);
32
+ expect(stats.samples).toBe(3);
33
+ expect(stats.routes.map(r => r.route)).toEqual(["GET /api/requirements", "GET /api/status?light=1"]);
34
+ const r0 = stats.routes[0];
35
+ expect(r0.count).toBe(2);
36
+ expect(r0.bytes).toBe(1500);
37
+ expect(r0.max_ms).toBeGreaterThanOrEqual(29);
38
+ expect(r0.total_ms).toBeGreaterThanOrEqual(39);
39
+ expect(r0.p95_ms).toBeLessThanOrEqual(r0.max_ms);
40
+ expect(stats.routes[1].errors).toBe(1);
41
+ // The old 4096-sample ring dropped everything past its size; the buckets count all of it.
42
+ for (let i = 0; i < 10_000; i++) recordRouteTiming(req("/api/tasks"), at(1), res(), now);
43
+ const after = routeStats(60_000, now);
44
+ expect(after.routes.find(r => r.route === "GET /api/tasks")!.count).toBe(10_000);
45
+ expect(after.samples).toBe(10_003);
46
+ });
47
+
48
+ test("?minutes=10 really covers 10 minutes: a steady 15 req/s stream is fully counted, older traffic is not", () => {
49
+ __resetRouteTimingForTest();
50
+ const t0 = 2_000_000_000 - (2_000_000_000 % ROUTE_BUCKET_MS); // bucket-aligned
51
+ // 20 minutes at 15 req/s — the old ring filled in ~4.5 minutes of this.
52
+ for (let s = 0; s < 20 * 60; s++) {
53
+ for (let k = 0; k < 15; k++) recordRouteTiming(req("/mcp", "POST"), at(1), res(), t0 + s * 1000);
54
+ }
55
+ const now = t0 + 20 * 60_000;
56
+ const ten = routeStats(10 * 60_000, now);
57
+ // window starts at t0+10min → the ten minute buckets t0+10 … t0+19
58
+ expect(ten.samples).toBe(10 * 60 * 15);
59
+ expect(ten.oldest_at).toBe(new Date(t0 + 10 * 60_000).toISOString());
60
+ expect(routeStats(20 * 60_000, now).samples).toBe(20 * 60 * 15);
61
+ expect(routeStats(60 * 60_000, now).samples).toBe(20 * 60 * 15);
62
+ });
63
+
64
+ test("buckets older than the retention are dropped", () => {
65
+ __resetRouteTimingForTest();
66
+ const now = 3_000_000_000;
67
+ const old = now - (ROUTE_BUCKET_RETENTION + 5) * ROUTE_BUCKET_MS;
68
+ recordRouteTiming(req("/api/old"), at(1), res(), old);
69
+ expect(routeStats(ROUTE_BUCKET_RETENTION * 2 * ROUTE_BUCKET_MS, old).samples).toBe(1);
70
+ recordRouteTiming(req("/api/new"), at(1), res(), now);
71
+ const s = routeStats(ROUTE_BUCKET_RETENTION * 2 * ROUTE_BUCKET_MS, now);
72
+ expect(s.routes.map(r => r.route)).toEqual(["GET /api/new"]);
73
+ });
74
+
75
+ test("p95 comes from the histogram: close to the true value and never above max", () => {
76
+ __resetRouteTimingForTest();
77
+ const now = 4_000_000_000;
78
+ // 96 fast (~1 ms) and 4 slow (~200 ms): p95 (the 96th of 100, same rank as the old ring's
79
+ // sorted[floor(n·0.95)]) must be in the fast band, max in the slow band.
80
+ for (let i = 0; i < 96; i++) recordRouteTiming(req("/api/p"), at(1), res(), now);
81
+ for (let i = 0; i < 4; i++) recordRouteTiming(req("/api/p"), at(200), res(), now);
82
+ const r = routeStats(60_000, now).routes[0];
83
+ expect(r.p95_ms).toBeGreaterThanOrEqual(1);
84
+ expect(r.p95_ms).toBeLessThan(5);
85
+ expect(r.max_ms).toBeGreaterThanOrEqual(200);
86
+ __resetRouteTimingForTest();
87
+ recordRouteTiming(req("/api/q"), at(3), res(), now);
88
+ const one = routeStats(60_000, now).routes[0];
89
+ expect(one.p95_ms).toBe(one.max_ms);
90
+ });
91
+
92
+ test("distinct routes per bucket are capped; the rest land in the overflow key", () => {
93
+ __resetRouteTimingForTest();
94
+ const now = 5_000_000_000;
95
+ for (let i = 0; i < ROUTE_MAX_KEYS_PER_BUCKET + 50; i++) {
96
+ const r = req("/mcp", "POST");
97
+ labelRoute(r, `tools/call t${String.fromCharCode(97 + (i % 26))}${Math.floor(i / 26)}x`);
98
+ recordRouteTiming(r, at(1), res(), now);
99
+ }
100
+ const s = routeStats(60_000, now);
101
+ expect(s.routes.length).toBe(ROUTE_MAX_KEYS_PER_BUCKET + 1);
102
+ expect(s.routes.find(r => r.route === ROUTE_OVERFLOW_KEY)!.count).toBe(50);
103
+ expect(s.samples).toBe(ROUTE_MAX_KEYS_PER_BUCKET + 50);
104
+ });
105
+
106
+ test("a label is appended to the route shape of that request only", () => {
107
+ __resetRouteTimingForTest();
108
+ const now = 6_000_000_000;
109
+ const a = req("/mcp", "POST");
110
+ labelRoute(a, "tools/call report_status");
111
+ recordRouteTiming(a, at(1), res(), now);
112
+ recordRouteTiming(req("/mcp", "POST"), at(1), res(), now);
113
+ expect(routeStats(60_000, now).routes.map(r => r.route).sort()).toEqual(["POST /mcp", "POST /mcp tools/call report_status"]);
114
+ });
115
+
116
+ test("mcpRouteLabel: method, tool name, and nothing else", () => {
117
+ expect(mcpRouteLabel({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: "report_status", arguments: { task: "secret" } } })).toBe("tools/call report_status");
118
+ expect(mcpRouteLabel({ jsonrpc: "2.0", id: 1, method: "tools/list" })).toBe("tools/list");
119
+ expect(mcpRouteLabel({ jsonrpc: "2.0", method: "notifications/initialized" })).toBe("notifications/initialized");
120
+ expect(mcpRouteLabel({ jsonrpc: "2.0", id: 1, method: "initialize", params: { name: "x" } })).toBe("initialize");
121
+ // odd shapes collapse to "?"
122
+ expect(mcpRouteLabel([{ method: "tools/list" }])).toBe("batch");
123
+ expect(mcpRouteLabel(null)).toBe("?");
124
+ expect(mcpRouteLabel("tools/list")).toBe("?");
125
+ expect(mcpRouteLabel(42)).toBe("?");
126
+ expect(mcpRouteLabel({})).toBe("?");
127
+ expect(mcpRouteLabel({ method: 7 })).toBe("?");
128
+ expect(mcpRouteLabel({ method: "x".repeat(65) })).toBe("?");
129
+ expect(mcpRouteLabel({ method: "tools/call x" })).toBe("?");
130
+ expect(mcpRouteLabel({ method: "通信" })).toBe("?");
131
+ expect(mcpRouteLabel({ method: "tools/call" })).toBe("tools/call ?");
132
+ expect(mcpRouteLabel({ method: "tools/call", params: "report_status" })).toBe("tools/call ?");
133
+ expect(mcpRouteLabel({ method: "tools/call", params: { name: { toString: () => "x" } } })).toBe("tools/call ?");
134
+ expect(mcpRouteLabel({ method: "tools/call", params: { name: "a b" } })).toBe("tools/call ?");
135
+ expect(mcpRouteLabel({ method: "tools/call", params: { name: "../../etc/passwd" } })).toBe("tools/call ?");
136
+ expect(mcpRouteLabel({ method: "tools/call", params: { name: "n".repeat(65) } })).toBe("tools/call ?");
137
+ expect(mcpRouteLabel({ method: "tools/call", params: { name: "" } })).toBe("tools/call ?");
138
+ });
139
+
140
+ // ── caller classes (?by=caller) ──
141
+ const reqAs = (path: string, headers: Record<string, string>) => new Request(`http://hub${path}`, { headers });
142
+
143
+ test("userAgentFamily keeps only allow-listed product names and a sanitized version", () => {
144
+ expect(userAgentFamily("agent-node/2.5.0-preview.88")).toBe("agent-node/2.5.0-preview.88");
145
+ expect(userAgentFamily("node")).toBe("node");
146
+ expect(userAgentFamily("Bun/1.2.19")).toBe("bun/1.2.19");
147
+ expect(userAgentFamily("okhttp/4.12.0")).toBe("okhttp/4.12.0");
148
+ expect(userAgentFamily("curl/8.5.0")).toBe("curl/8.5.0");
149
+ expect(userAgentFamily("agent-network-desktop/0.2.170")).toBe("agent-network-desktop/0.2.170");
150
+ expect(userAgentFamily("tauri-plugin-http/2.5.9")).toBe("tauri-plugin-http/2.5.9");
151
+ expect(userAgentFamily("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 Version/18.0 Safari/605.1.15")).toBe("browser");
152
+ expect(userAgentFamily("SomeApp/42 CFNetwork/1568.100.1 Darwin/24.0.0")).toBe("cfnetwork/1568.100.1");
153
+ expect(userAgentFamily(null)).toBe("none");
154
+ expect(userAgentFamily(" ")).toBe("none");
155
+ // Free text never comes through: unknown product names, and anything after a version.
156
+ expect(userAgentFamily("my-laptop-hostname/1.0")).toBe("other");
157
+ expect(userAgentFamily("curl/8.5.0;user=alice@example.com")).toBe("curl/8.5.0");
158
+ expect(userAgentFamily("node/abc-secret")).toBe("node");
159
+ });
160
+
161
+ test("callerTokenKind recognises token kinds without keeping the token", () => {
162
+ setRouteCallerMasterToken("master-secret-xyz");
163
+ // Header-only reader, as installed by default; another test file in the same process may have booted server.ts.
164
+ const previous = setRouteCallerTokenReader(r => r.headers.get("authorization")?.replace("Bearer ", "") || "");
165
+ try {
166
+ const kind = (headers: Record<string, string>) => callerTokenKind(reqAs("/api/status", headers));
167
+ expect(kind({ Authorization: "Bearer ntok_abc" })).toBe("node");
168
+ expect(kind({ Authorization: "Bearer utok_abc" })).toBe("user");
169
+ expect(kind({ Authorization: "Bearer master-secret-xyz" })).toBe("master");
170
+ expect(kind({ Authorization: "Bearer something-else" })).toBe("token");
171
+ expect(kind({})).toBe("anon");
172
+ // ?token= is read only through the reader server.ts installs (requestToken); a header-only reader ignores it.
173
+ expect(callerTokenKind(reqAs("/events/x?token=ntok_q", {}))).toBe("anon");
174
+ setRouteCallerTokenReader(r => new URL(r.url).searchParams.get("t") || "");
175
+ expect(callerTokenKind(reqAs("/events/x?t=ntok_q", {}))).toBe("node");
176
+ } finally {
177
+ setRouteCallerMasterToken(undefined);
178
+ setRouteCallerTokenReader(previous);
179
+ }
180
+ });
181
+
182
+ test("routeStats({byCaller}) counts per class; without it the rows have no callers field", () => {
183
+ __resetRouteTimingForTest();
184
+ const now = 3_000_000_000;
185
+ const status = (headers: Record<string, string>, len: number) => recordRouteTiming(reqAs("/api/status", headers), at(1), res(200, len), now);
186
+ for (let i = 0; i < 3; i++) status({ Authorization: "Bearer utok_secret1", "User-Agent": "node" }, 100);
187
+ status({ Authorization: "Bearer ntok_secret2", "User-Agent": "agent-node/2.5.0-preview.88" }, 10);
188
+ status({ "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) Chrome/130.0" }, 1);
189
+ const plain = routeStats(60_000, now);
190
+ expect(plain.routes[0]).not.toHaveProperty("callers");
191
+ const by = routeStats(60_000, now, { byCaller: true });
192
+ expect(by.routes[0].callers).toEqual([
193
+ { class: "user node", count: 3, bytes: 300 },
194
+ { class: "node agent-node/2.5.0-preview.88", count: 1, bytes: 10 },
195
+ { class: "anon browser", count: 1, bytes: 1 },
196
+ ]);
197
+ expect(JSON.stringify(by)).not.toMatch(/secret|Mozilla|Linux|Chrome/);
198
+ });
199
+
200
+ test("caller classes are capped per route: per bucket and again after merging buckets", () => {
201
+ __resetRouteTimingForTest();
202
+ const now = 4_000_000_000 - (4_000_000_000 % ROUTE_BUCKET_MS);
203
+ // Minute 1: 30 distinct versions → 20 stored, 10 into (other). Minute 2: 25 more distinct ones.
204
+ for (let i = 0; i < 30; i++) recordRouteTiming(reqAs("/api/status", { "User-Agent": `curl/${i}.0` }), at(1), res(200, 1), now - ROUTE_BUCKET_MS);
205
+ for (let i = 100; i < 125; i++) recordRouteTiming(reqAs("/api/status", { "User-Agent": `curl/${i}.0` }), at(1), res(200, 1), now);
206
+ const callers = routeStats(5 * 60_000, now, { byCaller: true }).routes[0].callers!;
207
+ expect(callers.length).toBe(ROUTE_MAX_CALLERS_PER_ROUTE);
208
+ expect(callers.at(-1)!.class).toBe(ROUTE_OVERFLOW_KEY);
209
+ expect(callers.reduce((n, c) => n + c.count, 0)).toBe(55);
210
+ expect(callers.reduce((n, c) => n + c.bytes, 0)).toBe(55);
211
+ });