@sleep2agi/commhub-server 0.9.0-preview.13 → 0.9.0-preview.131

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 (318) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +99 -11
  3. package/package.json +7 -7
  4. package/src/ack-create-request-transport.test.ts +182 -0
  5. package/src/ack-create-request.test.ts +50 -7
  6. package/src/admin-delete-network-http.test.ts +228 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/adoption-candidates-http.test.ts +137 -0
  9. package/src/adoption-candidates.ts +158 -0
  10. package/src/agent-access-batch.test.ts +210 -0
  11. package/src/agent-access.ts +487 -0
  12. package/src/agent-acl-groups-http.test.ts +247 -0
  13. package/src/agent-acl-http.test.ts +659 -0
  14. package/src/agent-acl-slice3-http.test.ts +267 -0
  15. package/src/agent-groups-perf.test.ts +223 -0
  16. package/src/agent-notice.ts +48 -0
  17. package/src/agent-team-whoami-http.test.ts +98 -0
  18. package/src/agent-teams-http.test.ts +323 -0
  19. package/src/agent-teams.ts +244 -0
  20. package/src/alias-filter.test.ts +72 -0
  21. package/src/alias-filter.ts +42 -0
  22. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  23. package/src/api-host-supervisors-fallback.test.ts +271 -0
  24. package/src/api-host-supervisors-runtime-readiness.test.ts +195 -0
  25. package/src/api-nodes-shape.test.ts +26 -10
  26. package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
  27. package/src/auth-sessions-http.test.ts +250 -0
  28. package/src/auth-validate.test.ts +5 -1
  29. package/src/auth-weak-password-login.test.ts +176 -0
  30. package/src/auth.ts +598 -49
  31. package/src/avatar-validate.test.ts +163 -0
  32. package/src/avatar-validate.ts +103 -0
  33. package/src/binding-generation-http.test.ts +121 -0
  34. package/src/blocked-exit-on-reply.test.ts +122 -0
  35. package/src/boot-side-effect.test.ts +124 -0
  36. package/src/clone-node.test.ts +248 -0
  37. package/src/clone-node.ts +289 -0
  38. package/src/codex-config-flags.ts +77 -0
  39. package/src/codex-fork-contract.ts +23 -0
  40. package/src/codex-fork-migration.test.ts +38 -0
  41. package/src/commhub-bin-args.test.ts +85 -0
  42. package/src/config-apply-sec1.test.ts +107 -0
  43. package/src/config-apply-validate.test.ts +134 -1
  44. package/src/config-apply-validate.ts +111 -5
  45. package/src/create-node-blocked-gate.test.ts +356 -0
  46. package/src/create-node-network-id.test.ts +79 -0
  47. package/src/create-node-tool-schema.test.ts +48 -0
  48. package/src/create-node-unicode-name.test.ts +120 -0
  49. package/src/create-node-validate.test.ts +193 -7
  50. package/src/create-node-validate.ts +227 -15
  51. package/src/create-node-workdir.test.ts +289 -0
  52. package/src/create-node.ts +77 -23
  53. package/src/daemon-provider-snapshots.test.ts +421 -0
  54. package/src/daemon-provider-snapshots.ts +327 -0
  55. package/src/daemon-token-owner-http.test.ts +236 -0
  56. package/src/dashboard-slash-routing-http.test.ts +107 -0
  57. package/src/db-adapter-guard.test.ts +148 -0
  58. package/src/db-adapter-translate.test.ts +128 -0
  59. package/src/db-adapter.ts +323 -87
  60. package/src/db-timestamp.test.ts +38 -0
  61. package/src/db-timestamp.ts +15 -0
  62. package/src/db.ts +933 -36
  63. package/src/delivered-without-push.test.ts +140 -0
  64. package/src/department-groups-http.test.ts +209 -0
  65. package/src/department-groups-sync-http.test.ts +328 -0
  66. package/src/department-groups.ts +434 -0
  67. package/src/department-heads-http.test.ts +330 -0
  68. package/src/department-heads.ts +226 -0
  69. package/src/departments-http.test.ts +185 -0
  70. package/src/departments.ts +260 -0
  71. package/src/desktop-message-seam.test.ts +150 -0
  72. package/src/desktop-message.test.ts +368 -0
  73. package/src/dm-file-scope-http.test.ts +178 -0
  74. package/src/ext-token-shared.test.ts +60 -0
  75. package/src/ext-validation-regression.test.ts +131 -0
  76. package/src/external-schedule-edits-http.test.ts +321 -0
  77. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  78. package/src/external-schedule-edits.ts +327 -0
  79. package/src/file-download-authz-dev-open.test.ts +135 -0
  80. package/src/file-download-authz.test.ts +404 -0
  81. package/src/file-download-date-bucket.test.ts +436 -0
  82. package/src/file-network-scope-dev-open.test.ts +97 -0
  83. package/src/file-network-scope.test.ts +732 -0
  84. package/src/group-membership-events-http.test.ts +264 -0
  85. package/src/group-messages-http.test.ts +482 -0
  86. package/src/group-messages.ts +297 -0
  87. package/src/health-redaction.test.ts +642 -0
  88. package/src/http-gzip.test.ts +94 -0
  89. package/src/http-gzip.ts +112 -0
  90. package/src/hub-provider-config.test.ts +139 -0
  91. package/src/hub-provider-config.ts +105 -0
  92. package/src/hub-timestamp.test.ts +38 -0
  93. package/src/hub-timestamp.ts +27 -0
  94. package/src/human-dm.ts +215 -0
  95. package/src/inbox-caller.ts +49 -0
  96. package/src/inbox-count-sse.test.ts +310 -0
  97. package/src/inbox-count.ts +29 -0
  98. package/src/index.ts +15 -2362
  99. package/src/lifecycle-guard.ts +1 -1
  100. package/src/mcp-context-cost-http.test.ts +123 -0
  101. package/src/mcp-projects-events-http.test.ts +162 -0
  102. package/src/mcp-route-label-http.test.ts +120 -0
  103. package/src/mcp-write-network-resolution.test.ts +432 -0
  104. package/src/member-audit-network-id-http.test.ts +93 -0
  105. package/src/member-presence-http.test.ts +210 -0
  106. package/src/messages-ack-by-agent-http.test.ts +142 -0
  107. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  108. package/src/migrate-sqlite-to-pg.ts +263 -0
  109. package/src/model-auth-notify-http.test.ts +181 -0
  110. package/src/model-auth-notify.ts +116 -0
  111. package/src/network-name-http.test.ts +75 -0
  112. package/src/network-scope.ts +276 -0
  113. package/src/network-token-membership-http.test.ts +139 -0
  114. package/src/node-alias-control.ts +12 -0
  115. package/src/node-attrs-validate.ts +88 -0
  116. package/src/node-attrs.test.ts +217 -0
  117. package/src/node-caller-identity-http.test.ts +781 -0
  118. package/src/node-create-request-status-http.test.ts +105 -0
  119. package/src/node-daemon-bindings.test.ts +132 -0
  120. package/src/node-daemon-bindings.ts +130 -0
  121. package/src/node-files-transport.test.ts +344 -0
  122. package/src/node-health-codex-login.test.ts +120 -0
  123. package/src/node-health-dispatch-http.test.ts +279 -0
  124. package/src/node-health-guard.ts +105 -0
  125. package/src/node-health-passthrough.test.ts +115 -0
  126. package/src/node-health-store.ts +101 -0
  127. package/src/node-id-alias.test.ts +190 -0
  128. package/src/node-identity-conflict-http.test.ts +269 -0
  129. package/src/node-identity-conflict.ts +142 -0
  130. package/src/node-identity-mismatch.test.ts +74 -0
  131. package/src/node-lifecycle-controllable.test.ts +64 -0
  132. package/src/node-lifecycle-controllable.ts +53 -0
  133. package/src/node-lifecycle-read-http.test.ts +245 -0
  134. package/src/node-lifecycle-read.ts +121 -0
  135. package/src/node-logs-transport.test.ts +263 -0
  136. package/src/node-permissions-http.test.ts +276 -0
  137. package/src/node-permissions.ts +285 -0
  138. package/src/node-request-retention.test.ts +281 -0
  139. package/src/node-request-retention.ts +120 -0
  140. package/src/node-skills-transport.test.ts +209 -0
  141. package/src/node-token-ownership.ts +51 -0
  142. package/src/node-token-privilege-http.test.ts +158 -0
  143. package/src/observer-avatar-http.test.ts +431 -0
  144. package/src/observer-push.test.ts +252 -0
  145. package/src/opencode-v2-catalog.test.ts +268 -0
  146. package/src/opencode-v2-catalog.ts +352 -0
  147. package/src/package-version-consistency.test.ts +25 -0
  148. package/src/peer-reply-atomic.test.ts +455 -0
  149. package/src/peer-reply-capability.test.ts +70 -0
  150. package/src/pg-worker.ts +87 -0
  151. package/src/project-dir-warn-clear-http.test.ts +132 -0
  152. package/src/project-viewer-can-http.test.ts +114 -0
  153. package/src/push.ts +429 -10
  154. package/src/redact-tokens.test.ts +65 -0
  155. package/src/redact-tokens.ts +40 -0
  156. package/src/rename.ts +22 -2
  157. package/src/reply-target-derivation.test.ts +96 -0
  158. package/src/report-status-handover-capabilities-http.test.ts +124 -0
  159. package/src/report-status-host-schema.test.ts +235 -0
  160. package/src/report-status-resume-id-handover.test.ts +150 -0
  161. package/src/require-explicit-test-db.ts +9 -0
  162. package/src/requirement-due-reminders-http.test.ts +620 -0
  163. package/src/requirement-due-reminders.ts +416 -0
  164. package/src/requirement-events-http.test.ts +268 -0
  165. package/src/requirement-events.test.ts +45 -0
  166. package/src/requirement-events.ts +144 -0
  167. package/src/requirement-notify.ts +165 -0
  168. package/src/requirement-participant-notify-http.test.ts +309 -0
  169. package/src/requirement-tag-ops-http.test.ts +266 -0
  170. package/src/requirement-tags.ts +89 -0
  171. package/src/requirements-abandoned-http.test.ts +236 -0
  172. package/src/requirements-comment-http.test.ts +167 -0
  173. package/src/requirements-completed-at-migration.test.ts +70 -0
  174. package/src/requirements-errors-mcp-http.test.ts +164 -0
  175. package/src/requirements-errors.ts +143 -0
  176. package/src/requirements-etag-http.test.ts +91 -0
  177. package/src/requirements-http.test.ts +709 -0
  178. package/src/requirements-last-event-http.test.ts +349 -0
  179. package/src/requirements-list-mcp-slim-http.test.ts +155 -0
  180. package/src/requirements-list-slim-http.test.ts +319 -0
  181. package/src/requirements-mcp.test.ts +163 -0
  182. package/src/requirements-migrate.ts +259 -0
  183. package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
  184. package/src/requirements-people-http.test.ts +114 -0
  185. package/src/requirements-people-mcp-http.test.ts +225 -0
  186. package/src/requirements-people.ts +127 -0
  187. package/src/requirements-priority-lowest.test.ts +114 -0
  188. package/src/requirements-search-http.test.ts +182 -0
  189. package/src/requirements-search.test.ts +32 -0
  190. package/src/requirements-search.ts +145 -0
  191. package/src/requirements-seq-http.test.ts +193 -0
  192. package/src/requirements-seq-migration.test.ts +63 -0
  193. package/src/requirements-start-date.test.ts +125 -0
  194. package/src/requirements-stats-http.test.ts +288 -0
  195. package/src/requirements-stats.ts +167 -0
  196. package/src/requirements-status-participants-http.test.ts +222 -0
  197. package/src/requirements-upgrade-e2e.test.ts +126 -0
  198. package/src/requirements-upgrade-migration.test.ts +98 -0
  199. package/src/requirements.ts +1806 -0
  200. package/src/resolve-port.test.ts +57 -0
  201. package/src/resolve-port.ts +44 -0
  202. package/src/response-charset.test.ts +130 -0
  203. package/src/rest-explicit-columns-http.test.ts +222 -0
  204. package/src/rest-identity.test.ts +130 -0
  205. package/src/rest-identity.ts +139 -0
  206. package/src/rest-projections.ts +68 -0
  207. package/src/rest-write-network-resolution.test.ts +133 -0
  208. package/src/restricted-files.ts +76 -0
  209. package/src/retention.ts +15 -3
  210. package/src/route-callers-http.test.ts +120 -0
  211. package/src/route-timing.test.ts +211 -0
  212. package/src/route-timing.ts +258 -0
  213. package/src/rules-file-duplicate-alias.test.ts +234 -0
  214. package/src/rules-file-session-target.test.ts +207 -0
  215. package/src/rules-file-transport.test.ts +237 -0
  216. package/src/runtime-label.test.ts +22 -0
  217. package/src/runtime-label.ts +21 -0
  218. package/src/runtime-readiness.ts +90 -0
  219. package/src/schedule-agent-mcp-http.test.ts +381 -0
  220. package/src/schedule-agent.ts +192 -0
  221. package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
  222. package/src/scheduled-failures-http.test.ts +233 -0
  223. package/src/scheduled-failures.ts +179 -0
  224. package/src/scheduled-offline.test.ts +131 -0
  225. package/src/scheduled-offline.ts +66 -0
  226. package/src/scheduled-reply-unread-http.test.ts +212 -0
  227. package/src/scheduled-run-terminal.test.ts +268 -0
  228. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  229. package/src/scheduled-stuck-http.test.ts +238 -0
  230. package/src/scheduled-stuck.ts +153 -0
  231. package/src/scheduled-tasks-http.test.ts +638 -0
  232. package/src/scheduled-tasks.ts +766 -0
  233. package/src/scoped-secrets.ts +124 -0
  234. package/src/send-dedup-request-id.test.ts +229 -0
  235. package/src/send-reply-agent-warning.test.ts +202 -0
  236. package/src/send-reply-alias-optional.test.ts +131 -0
  237. package/src/send-reply-attachments.test.ts +458 -0
  238. package/src/send_dedup.test.ts +54 -0
  239. package/src/send_dedup.ts +52 -6
  240. package/src/serve-error.test.ts +38 -0
  241. package/src/serve-error.ts +27 -0
  242. package/src/server.ts +5257 -0
  243. package/src/session-task-on-dispatch.ts +14 -0
  244. package/src/session-token-ownership-http.test.ts +122 -0
  245. package/src/shared/codex-inventory.ts +41 -0
  246. package/src/shared/daemon-provider-login.ts +31 -0
  247. package/src/shared/daemon-provider-snapshot.ts +52 -0
  248. package/src/shared/external-schedule-contract.ts +60 -0
  249. package/src/shared/node-name.test.ts +62 -0
  250. package/src/shared/node-name.ts +161 -0
  251. package/src/shared/provider-application.ts +29 -0
  252. package/src/shared/provider-collection.ts +14 -0
  253. package/src/shared/provider-initialization.ts +8 -0
  254. package/src/shared/reserved-env-drift.test.ts +10 -0
  255. package/src/shared/scoped-secret-contract.ts +5 -0
  256. package/src/shared/task-content-limit-drift.test.ts +28 -0
  257. package/src/shared/task-content-limit.ts +25 -0
  258. package/src/side-thread-command-transport.test.ts +312 -0
  259. package/src/side-thread-command-transport.ts +318 -0
  260. package/src/side-thread-http-integration.test.ts +233 -0
  261. package/src/side-thread-http.ts +504 -0
  262. package/src/side-thread-production.test.ts +72 -0
  263. package/src/side-thread-production.ts +118 -0
  264. package/src/side-thread.test.ts +1067 -0
  265. package/src/side-thread.ts +2499 -0
  266. package/src/skillhub-http.test.ts +108 -0
  267. package/src/sqlite-close-checkpoint.test.ts +78 -0
  268. package/src/start-node.test.ts +264 -0
  269. package/src/status-alias-filter-http.test.ts +106 -0
  270. package/src/status-alias-resolver-http.test.ts +163 -0
  271. package/src/status-alias-resolver.ts +19 -0
  272. package/src/status-node-id-filter-http.test.ts +161 -0
  273. package/src/status-read-cache-http.test.ts +229 -0
  274. package/src/status-read-cache.ts +122 -0
  275. package/src/stop-delete-node.test.ts +509 -1
  276. package/src/task-access-http.test.ts +402 -0
  277. package/src/task-access.ts +420 -0
  278. package/src/task-auth-origin-http.test.ts +93 -0
  279. package/src/task-auth-origin.test.ts +24 -0
  280. package/src/task-auth-origin.ts +7 -0
  281. package/src/task-consumption.test.ts +482 -0
  282. package/src/task-diagnostic-http.test.ts +149 -0
  283. package/src/task-diagnostic.test.ts +115 -0
  284. package/src/task-diagnostic.ts +118 -0
  285. package/src/task-expiry-notice-http.test.ts +287 -0
  286. package/src/task-expiry-notice.ts +315 -0
  287. package/src/task-idempotency-mcp.test.ts +142 -0
  288. package/src/task-idempotency.test.ts +60 -0
  289. package/src/task-idempotency.ts +75 -0
  290. package/src/task-late-replies.ts +54 -0
  291. package/src/task-late-reply-http.test.ts +152 -0
  292. package/src/task-lifecycle-watcher.test.ts +204 -0
  293. package/src/task-lifecycle-watcher.ts +49 -0
  294. package/src/task-network-resolution-http.test.ts +140 -0
  295. package/src/task-orphan-http.test.ts +287 -0
  296. package/src/task-orphan.ts +119 -0
  297. package/src/task-queue-ahead-http.test.ts +309 -0
  298. package/src/task-queue-ahead-scale.test.ts +236 -0
  299. package/src/task-queue-ahead.ts +186 -0
  300. package/src/task-rejection-log-http.test.ts +160 -0
  301. package/src/task-rejection-log.ts +49 -0
  302. package/src/task-sender-http.test.ts +143 -0
  303. package/src/task-stale-open-http.test.ts +290 -0
  304. package/src/task-stale-open.ts +103 -0
  305. package/src/tasks-pagination-http.test.ts +141 -0
  306. package/src/tool-audience-http.test.ts +220 -0
  307. package/src/tool-audience.ts +118 -0
  308. package/src/tools-list-json-schema.test.ts +67 -0
  309. package/src/tools.ts +3940 -325
  310. package/src/upload-early-reject-guard.test.ts +155 -0
  311. package/src/uploads-http.test.ts +97 -15
  312. package/src/uploads.ts +103 -6
  313. package/src/user-avatar-http.test.ts +74 -0
  314. package/src/user-avatar-migration.test.ts +25 -0
  315. package/src/user-inbox-by-agent-http.test.ts +161 -0
  316. package/src/user-inbox-netid-notnull.test.ts +122 -0
  317. package/src/user-inbox-read-http.test.ts +223 -0
  318. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,139 @@
1
+ // REST from_session identity binding.
2
+ //
3
+ // Both transports resolve the same `api_tokens` row through resolveToken, and
4
+ // auth.ts already states the invariant:
5
+ //
6
+ // "Network-bound node tokens are an identity boundary: they must not spoof
7
+ // another node via from_session."
8
+ //
9
+ // tools.ts enforced it for MCP (defaultFrom + fromIdentityMismatchReply).
10
+ // POST /api/task did not — it took `body.from` verbatim, so any node holding
11
+ // its own ntok_ could claim another node's alias. That value is stored on the
12
+ // task row and, once sender labels reach the copresence TUIs, rendered to a
13
+ // human as an attribution.
14
+ //
15
+ // Production network tokens pass tokenId and resolve through resolveNodeCaller.
16
+ // A token whose name is not `node:` (not_a_node_token, the registration token
17
+ // before report_status) falls back to nodeAliasForToken, same as main.
18
+ // tokenId omitted keeps the name-only shape used by the unit cases below.
19
+
20
+ import { resolveNodeCaller } from "./create-node.js";
21
+
22
+ export interface RestIdentityInput {
23
+ /** Raw bearer token, only its prefix matters (`ntok_` = network-bound). */
24
+ token: string;
25
+ /** `api_tokens.name`; for node tokens it is `node:<alias>`. */
26
+ tokenName: string | null | undefined;
27
+ /** Set on the production path. Network tokens then ignore tokenName as authority. */
28
+ tokenId?: string | null;
29
+ /** Username resolved from the authenticated token. */
30
+ authenticatedUsername?: string | null;
31
+ /** Caller-supplied `body.from`. */
32
+ requestedFrom: unknown;
33
+ }
34
+
35
+ export type RestIdentityResult =
36
+ | { ok: true; fromSession: string }
37
+ | {
38
+ ok: false;
39
+ error: "from_session_identity_mismatch";
40
+ message: string;
41
+ tokenAlias: string;
42
+ requestedFromSession: string;
43
+ };
44
+
45
+ /** Alias a token is bound to, or null when it is not node-scoped. */
46
+ export function nodeAliasForToken(token: string, tokenName: string | null | undefined): string | null {
47
+ if (!token.startsWith("ntok_")) return null;
48
+ if (typeof tokenName !== "string" || !tokenName.startsWith("node:")) return null;
49
+ const alias = tokenName.slice("node:".length).trim();
50
+ return alias || null;
51
+ }
52
+
53
+ /** Human dispatches use the authenticated username; node credentials represent nodes. */
54
+ export function resolveUserTaskFromSession(username: string | null | undefined, requestedFrom: unknown): RestIdentityResult {
55
+ const fromSession = username?.trim() || "api";
56
+ const requested = typeof requestedFrom === "string" ? requestedFrom.trim() : "";
57
+ if (requested && requested !== fromSession) {
58
+ return {
59
+ ok: false,
60
+ error: "from_session_identity_mismatch",
61
+ message: "user token task sender must match the authenticated username",
62
+ tokenAlias: fromSession,
63
+ requestedFromSession: requested,
64
+ };
65
+ }
66
+ return { ok: true, fromSession };
67
+ }
68
+
69
+ export function resolveRestFromSession(input: RestIdentityInput): RestIdentityResult {
70
+ const requested = typeof input.requestedFrom === "string" ? input.requestedFrom.trim() : "";
71
+ let alias: string | null;
72
+ if (input.token.startsWith("ntok_") && input.tokenId) {
73
+ const resolved = resolveNodeCaller(input.tokenId);
74
+ if (resolved.ok) {
75
+ alias = resolved.alias;
76
+ } else if (resolved.reason === "not_a_node_token") { // board679-plain-ntok-rest
77
+ // Name is not `node:` yet. Main signs that as api, and refuses a claim.
78
+ // not_owner / bound_node_missing / ambiguous stay the hard reject.
79
+ alias = nodeAliasForToken(input.token, input.tokenName);
80
+ } else {
81
+ return {
82
+ ok: false,
83
+ error: "from_session_identity_mismatch",
84
+ message: "network token from_session does not match token-bound node alias",
85
+ tokenAlias: "",
86
+ requestedFromSession: requested,
87
+ };
88
+ }
89
+ } else {
90
+ alias = nodeAliasForToken(input.token, input.tokenName);
91
+ }
92
+
93
+ // A network-bound token whose name we cannot resolve to an alias (`node:`,
94
+ // `node: `, or a name that was never node-scoped) is the degenerate case.
95
+ // We know it IS a node credential; we just cannot tell WHICH node. Letting it
96
+ // claim anything is the worst available option, so refuse the claim outright
97
+ // rather than falling through to the unbound path.
98
+ //
99
+ // Production always writes `node:${nodeName}`, so this costs nothing real —
100
+ // but "we couldn't identify you, so you may call yourself anything" is
101
+ // exactly the fail-open shape this change exists to remove.
102
+ if (!alias && input.token.startsWith("ntok_")) {
103
+ if (requested) {
104
+ return {
105
+ ok: false,
106
+ error: "from_session_identity_mismatch",
107
+ message: "network token has no resolvable node alias and may not claim a from_session",
108
+ tokenAlias: "",
109
+ requestedFromSession: requested,
110
+ };
111
+ }
112
+ return { ok: true, fromSession: "api" };
113
+ }
114
+
115
+ // Authenticated human callers are always represented by their username.
116
+ // Keep the unscoped development / legacy transport path separate.
117
+ if (!alias) {
118
+ const authenticatedUsername = typeof input.authenticatedUsername === "string"
119
+ ? input.authenticatedUsername.trim()
120
+ : "";
121
+ if (authenticatedUsername) return resolveUserTaskFromSession(authenticatedUsername, input.requestedFrom);
122
+ return { ok: true, fromSession: requested || "api" };
123
+ }
124
+
125
+ // Node token claiming somebody else — refuse rather than silently rewrite,
126
+ // so a misconfigured caller is visible instead of quietly relabelled.
127
+ if (requested && requested !== alias) {
128
+ return {
129
+ ok: false,
130
+ error: "from_session_identity_mismatch",
131
+ message: "network token from_session does not match token-bound node alias",
132
+ tokenAlias: alias,
133
+ requestedFromSession: requested,
134
+ };
135
+ }
136
+
137
+ // Node token, no claim or a matching one: bind to the token's own alias.
138
+ return { ok: true, fromSession: alias };
139
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Stable REST row projections.
3
+ *
4
+ * These lists deliberately mirror the fields already exposed by the public
5
+ * endpoints before #311. Keeping them in one place makes the wire contract
6
+ * independent from future ALTER TABLE migrations: adding a storage-only
7
+ * column must never silently add a REST response key.
8
+ */
9
+ export const NETWORK_REST_COLUMNS = [
10
+ "network_id", "network_name", "owner_id", "description", "settings",
11
+ "created_at", "updated_at", "visibility", "max_members",
12
+ ] as const;
13
+
14
+ export const SESSION_REST_COLUMNS = [
15
+ "resume_id", "alias", "tmux_name", "server", "ip", "hostname", "agent",
16
+ "project_dir", "version", "status", "task", "output", "progress", "score",
17
+ "cpu_load_1min", "cpu_cores", "mem_total_gb", "mem_used_gb", "mem_avail_gb",
18
+ "disk_total_gb", "disk_used_gb", "disk_avail_gb", "process_rss_bytes",
19
+ "process_rss_mb", "process_cpu_pct", "process_uptime_seconds",
20
+ "process_in_flight_count", "network_id", "registered_at", "updated_at",
21
+ "node_id", "session_id", "config_path", "channels", "last_seen_at", "model",
22
+ "external_schedules", "rules_file_capable", "skills_capable", "files_capable",
23
+ "logs_capable",
24
+ ] as const;
25
+
26
+ export const AUDIT_LOG_REST_COLUMNS = [
27
+ "id", "user_id", "username", "action", "target_type", "target_id", "detail",
28
+ "ip", "network_id", "created_at",
29
+ ] as const;
30
+
31
+ export const TASK_EVENT_REST_COLUMNS = [
32
+ "id", "task_id", "from_status", "to_status", "event_type", "actor", "detail",
33
+ "created_at", "network_id",
34
+ ] as const;
35
+
36
+ export const TASK_REST_COLUMNS = [
37
+ "task_id", "from_node_id", "from_name", "to_node_id", "to_name", "priority",
38
+ "status", "content", "result", "in_reply_to", "requires_response", "scope",
39
+ "created_at", "delivered_at", "started_at", "runtime_submitted_at", "consumed_at", "completed_at", "expires_at",
40
+ "network_id", "parent_task_id", "meta_json", "thread_id", "turn_id",
41
+ ] as const;
42
+
43
+ export const COMPLETION_REST_COLUMNS = [
44
+ "id", "session_name", "task", "result", "artifacts", "score",
45
+ "duration_minutes", "network_id", "completed_at",
46
+ ] as const;
47
+
48
+ // Storage projection for scheduled-task rows. `created_by` and
49
+ // `schedule_json` are intentionally selected for server-side auth/decoding,
50
+ // then removed by decodeRow before the REST response is serialized.
51
+ export const SCHEDULED_TASK_STORAGE_COLUMNS = [
52
+ "schedule_id", "network_id", "created_by", "created_by_node_id", "name", "target_node_id",
53
+ "target_alias", "task_content", "priority", "schedule_type", "schedule_json",
54
+ "timezone", "overlap_policy", "misfire_policy", "status", "next_run_at",
55
+ "last_run_at", "revision", "created_at", "updated_at",
56
+ ] as const;
57
+
58
+ export function sqlColumns(columns: readonly string[], qualifier?: string): string {
59
+ return columns.map((column) => qualifier ? `${qualifier}.${column}` : column).join(", ");
60
+ }
61
+
62
+ export const NETWORK_REST_SELECT = sqlColumns(NETWORK_REST_COLUMNS);
63
+ export const SESSION_REST_SELECT = sqlColumns(SESSION_REST_COLUMNS);
64
+ export const AUDIT_LOG_REST_SELECT = sqlColumns(AUDIT_LOG_REST_COLUMNS);
65
+ export const TASK_EVENT_REST_SELECT = sqlColumns(TASK_EVENT_REST_COLUMNS);
66
+ export const TASK_REST_SELECT = sqlColumns(TASK_REST_COLUMNS);
67
+ export const COMPLETION_REST_SELECT = sqlColumns(COMPLETION_REST_COLUMNS);
68
+ export const SCHEDULED_TASK_STORAGE_SELECT = sqlColumns(SCHEDULED_TASK_STORAGE_COLUMNS);
@@ -0,0 +1,133 @@
1
+ // #819 — `resolveRestWriteNetworkId` 是 network-scope.ts 里唯一没有任何测试点名的
2
+ // 导出函数。同文件的五个兄弟各有 1-2 个测试文件点名它们:
3
+ //
4
+ // resolveRestNetworkScope 1 canRestWriteNetwork 1
5
+ // singleNetworkId 2 addNetworkScope 1
6
+ // getUserNetworkIds 1 resolveRestWriteNetworkId 🔴 0
7
+ //
8
+ // 而它决定的是**一次 REST 写入落到哪个网络**。它的 docstring 说明了一条不显然的
9
+ // 规则:管理员的**读**作用域按设计是全局的(networkIds=null),所以它本身表达不了
10
+ // 「这个管理员恰好只属于一个网络」;而**写**不能继承这个歧义 —— 只有在确实只有
11
+ // 一个成员关系时才用它,0 个或 ≥2 个都必须显式指定网络。
12
+ //
13
+ // 🔴 这条规则的两半都要钉:
14
+ // - **放行那半**(admin + 恰好 1 个成员关系 → 用它)如果坏了,会退化成「管理员
15
+ // 写任何东西都要显式带 network_id」——很吵,但**安全**,所以没人会急着修;
16
+ // - **收紧那半**(admin + 0 或 ≥2 → null)如果坏了,一次写入会**落到一个没被
17
+ // 指定的网络里**,而调用方看到的是成功。
18
+ //
19
+ // 只写反向断言是不够的:一个「永远返回 null」的实现能通过所有「收紧」用例。
20
+ // 所以每一条收紧断言都配了正控。
21
+ //
22
+ // Run: COMMHUB_DB=/tmp/819-rest-write-scope.db bun test src/rest-write-network-resolution.test.ts
23
+
24
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
25
+ import { db } from "./db.js";
26
+ import { resolveRestNetworkScope, resolveRestWriteNetworkId, type RestNetworkScope } from "./network-scope.js";
27
+
28
+ const NET_A = "net_819_a";
29
+ const NET_B = "net_819_b";
30
+ const U_SINGLE = "u819_single"; // 只属于 NET_A
31
+ const U_MULTI = "u819_multi"; // 属于 NET_A + NET_B
32
+ const U_NONE = "u819_none"; // 一个都不属于
33
+ const ALL_USERS = [U_SINGLE, U_MULTI, U_NONE];
34
+
35
+ function cleanup() {
36
+ try { db.run("DELETE FROM network_members WHERE network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {}
37
+ try { db.run("DELETE FROM networks WHERE network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {}
38
+ for (const u of ALL_USERS) {
39
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [u]); } catch {}
40
+ }
41
+ }
42
+
43
+ function seed() {
44
+ for (const u of ALL_USERS) {
45
+ db.run(
46
+ `INSERT INTO users (user_id, username, password_hash, role, created_at)
47
+ VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
48
+ [u, u],
49
+ );
50
+ }
51
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET_A, U_SINGLE]);
52
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET_B, U_MULTI]);
53
+ const member = (u: string, net: string, role: string) =>
54
+ db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, ?3, datetime('now'))`, [u, net, role]);
55
+ member(U_SINGLE, NET_A, "owner");
56
+ member(U_MULTI, NET_A, "member");
57
+ member(U_MULTI, NET_B, "owner");
58
+ }
59
+
60
+ beforeEach(() => { cleanup(); seed(); });
61
+ afterAll(() => { cleanup(); });
62
+
63
+ /** 管理员的读作用域:全局(networkIds=null),这正是歧义的来源。 */
64
+ const ADMIN_SCOPE: RestNetworkScope = { networkIds: null };
65
+ const scopeOf = (...ids: string[]): RestNetworkScope => ({ networkIds: ids });
66
+ const ctx = (userId: string) => ({ userId, networkId: null });
67
+
68
+ describe("#819 resolveRestWriteNetworkId — 作用域里就能确定时,直接用它", () => {
69
+ test("作用域恰好一个网络 → 用那一个(与是不是 admin 无关)", () => {
70
+ expect(resolveRestWriteNetworkId(scopeOf(NET_A), ctx(U_MULTI), false)).toBe(NET_A);
71
+ expect(resolveRestWriteNetworkId(scopeOf(NET_A), ctx(U_MULTI), true)).toBe(NET_A);
72
+ });
73
+
74
+ test("作用域两个网络 → 歧义,null(即使调用者是 admin)", () => {
75
+ expect(resolveRestWriteNetworkId(scopeOf(NET_A, NET_B), ctx(U_MULTI), true)).toBeNull();
76
+ });
77
+
78
+ // 🔴 这一条我一开始断错了,留下来当记录:我以为「作用域为空数组 → null」,
79
+ // 实测是 NET_A。原因是 `[]` 过不了 singleNetworkId,于是落到 admin 的成员关系
80
+ // 回退分支,而 U_SINGLE 恰好只有一个成员关系。
81
+ //
82
+ // 而 `networkIds: []` 在 resolveRestNetworkScope 里有确切含义:`:46` 那一行,
83
+ // **非 admin 请求了一个自己没有角色的网络** —— 它带着 `denied: "access denied
84
+ // to requested network"`。也就是说 `[]` 不是「没指定」,是「明确被拒」。
85
+ //
86
+ // 现在**没有**问题,因为那一行只对非 admin 产生(admin 在 `:42` 就提前返回
87
+ // networkIds:null 了),而非 admin 走到这里必然 null —— 见下一条。
88
+ //
89
+ // 但它是一个潜伏的形状:如果将来有任何路径让 admin 拿到 networkIds: [],
90
+ // 这次写入会**忽略那条明确的拒绝**,回退到他的单一成员关系。所以两条都钉住:
91
+ // 当前行为,以及那条让它安全的前提。
92
+ test("作用域为空数组 + admin + 恰好一个成员关系 → 回退到成员关系(当前行为,记录)", () => {
93
+ expect(resolveRestWriteNetworkId(scopeOf(), ctx(U_SINGLE), true)).toBe(NET_A);
94
+ });
95
+
96
+ test("🔴 让上一条安全的前提:空数组作用域只由非 admin 产生,而非 admin → null", () => {
97
+ expect(resolveRestWriteNetworkId(scopeOf(), ctx(U_SINGLE), false)).toBeNull();
98
+ // 前提本身也断一次:resolveRestNetworkScope 对 admin 从不产出空数组。
99
+ const adminScope = resolveRestNetworkScope(NET_B, ctx(U_NONE), true);
100
+ expect(adminScope.networkIds).toBeNull();
101
+ });
102
+ });
103
+
104
+ describe("#819 admin 的全局读作用域不能替写入决定网络", () => {
105
+ test("🔴 admin + 恰好 1 个成员关系 → 用那一个(放行那半)", () => {
106
+ expect(resolveRestWriteNetworkId(ADMIN_SCOPE, ctx(U_SINGLE), true)).toBe(NET_A);
107
+ });
108
+
109
+ test("🔴 admin + 2 个成员关系 → null,必须显式指定(收紧那半)", () => {
110
+ expect(resolveRestWriteNetworkId(ADMIN_SCOPE, ctx(U_MULTI), true)).toBeNull();
111
+ });
112
+
113
+ test("🔴 admin + 0 个成员关系 → null(不能因为读是全局的就随便挑一个)", () => {
114
+ expect(resolveRestWriteNetworkId(ADMIN_SCOPE, ctx(U_NONE), true)).toBeNull();
115
+ });
116
+
117
+ test("非 admin + 全局作用域 → null(这种组合本身就不该发生,fail closed)", () => {
118
+ // 正控在上面那条「admin + 单成员 → NET_A」:如果实现改成永远 null,那条会红。
119
+ expect(resolveRestWriteNetworkId(ADMIN_SCOPE, ctx(U_SINGLE), false)).toBeNull();
120
+ });
121
+
122
+ test("没有 authCtx + 全局作用域 → null", () => {
123
+ expect(resolveRestWriteNetworkId(ADMIN_SCOPE, null, true)).toBeNull();
124
+ });
125
+ });
126
+
127
+ describe("#819 作用域优先于成员关系回退", () => {
128
+ test("作用域说 NET_B,而该用户唯一的成员关系是 NET_A → 结果是 NET_B", () => {
129
+ // 归属由作用域决定,不由「他碰巧属于哪个网络」决定。
130
+ // 如果实现把两者的优先级搞反,这条会返回 NET_A。
131
+ expect(resolveRestWriteNetworkId(scopeOf(NET_B), ctx(U_SINGLE), true)).toBe(NET_B);
132
+ });
133
+ });
@@ -0,0 +1,76 @@
1
+ // 多用户 Agent 权限 —— 受限成员与上传文件。
2
+ // 文件按网络归属;受限成员(只看授权 Agent 的成员)在受限网络里不能凭 file_id 读网络里任意文件,
3
+ // 也不能把自己看不见的 file_id 塞进发给授权 Agent 的任务(那等于借 Agent 的网络令牌去读)。
4
+ import { existsSync, readFileSync } from "fs";
5
+ import { db } from "./db.js";
6
+ import { addAgentTimelineScope, addOwnTrafficScope, type RestNetworkScope } from "./network-scope.js";
7
+ import { FILE_ID_REGEX, indexEntryPath, validateIndexEntry } from "./uploads.js";
8
+
9
+ /**
10
+ * 受限成员能不能看见这个文件:只认**对方(授权 Agent / 给他发私信的人)放进来**的附件 ——
11
+ * user_inbox 里发给自己的私信附件;
12
+ * 授权 Agent 时间线上(#563,与 /api/tasks 同一判据 addAgentTimelineScope)**别人**发出的任务的
13
+ * attachments —— 时间线上看得见那条消息,就得看得见它带的图;发送方是受限成员时发送路径已校验过
14
+ * 他看得见这些文件,不受限的发送方本来就看得见全网文件;
15
+ * 时间线上任务的 reply_attachments(Agent 回复写进的);
16
+ * 授权 Agent 发给自己的 inbox 行的 attachments。
17
+ * 自己随任务带出去的 attachments **不算**:否则把任意 file_id 塞进自己的任务就能「解锁」别人的文件。
18
+ */
19
+ export function restrictedMemberSeesFile(userId: string, username: string, networkId: string, fileId: string): boolean {
20
+ if (!FILE_ID_REGEX.test(fileId)) return false;
21
+ const listed = (metaJson: string | null | undefined, key: "attachments" | "reply_attachments"): boolean => {
22
+ if (!metaJson) return false;
23
+ try {
24
+ const list = JSON.parse(metaJson)?.[key];
25
+ return Array.isArray(list) && list.some((a: any) => a && typeof a === "object" && a.file_id === fileId);
26
+ } catch { return false; }
27
+ };
28
+ // LIKE 只做预筛(file_id 只含 [A-Za-z0-9_-],`_` 转义后逐字匹配),真正的判定在 listed() 里按键解析。
29
+ const needle = `%"${fileId.replace(/_/g, "\\_")}"%`;
30
+ for (const row of db.all<{ meta_json: string | null }>(
31
+ "SELECT meta_json FROM user_inbox WHERE user_id = ?1 AND network_id = ?2 AND meta_json LIKE ?3 ESCAPE '\\' LIMIT 50",
32
+ userId, networkId, needle,
33
+ )) {
34
+ if (listed(row.meta_json, "attachments")) return true;
35
+ }
36
+ const scope: RestNetworkScope = { networkId, networkIds: null, agentRestriction: { userId, username, networkIds: [networkId] } };
37
+ const taskParams: unknown[] = [needle];
38
+ const taskSql = addAgentTimelineScope("SELECT from_name, to_name, meta_json FROM tasks WHERE meta_json LIKE ?1 ESCAPE '\\'", taskParams, scope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
39
+ for (const row of db.all<{ from_name: string; to_name: string; meta_json: string | null }>(taskSql + " LIMIT 50", ...taskParams)) {
40
+ if (row.from_name !== username && listed(row.meta_json, "attachments")) return true;
41
+ if (listed(row.meta_json, "reply_attachments")) return true;
42
+ }
43
+ const inboxParams: unknown[] = [needle];
44
+ const inboxSql = addOwnTrafficScope("SELECT session_name, meta_json FROM inbox WHERE meta_json LIKE ?1 ESCAPE '\\'", inboxParams, scope, { from: "from_session", to: "session_name" });
45
+ for (const row of db.all<{ session_name: string; meta_json: string | null }>(inboxSql + " LIMIT 50", ...inboxParams)) {
46
+ if (row.session_name === username && listed(row.meta_json, "attachments")) return true;
47
+ }
48
+ return false;
49
+ }
50
+
51
+ /** 受限成员能不能用这个文件(下载 / 当附件转给 Agent):自己传的,或 restrictedMemberSeesFile。 */
52
+ export function restrictedMemberCanUseFile(userId: string, username: string, networkId: string, fileId: string): boolean {
53
+ if (!FILE_ID_REGEX.test(fileId)) return false;
54
+ const path = indexEntryPath(fileId);
55
+ if (!path || !existsSync(path)) return false;
56
+ let entry: any;
57
+ try { entry = JSON.parse(readFileSync(path, "utf8")); } catch { return false; }
58
+ if (!validateIndexEntry(entry) || entry.file_id !== fileId) return false;
59
+ if (entry.network_id !== networkId) return false;
60
+ if (entry.owner_id === userId) return true;
61
+ return restrictedMemberSeesFile(userId, username, networkId, fileId);
62
+ }
63
+
64
+ /**
65
+ * 受限成员发给 Agent 的任务 / 消息里的附件:每个 file_id 都必须是他自己能用的文件。
66
+ * 返回第一个不合格的 file_id(调用方回 403),全部合格返回 null。
67
+ */
68
+ export function restrictedMemberAttachmentsDenied(userId: string, username: string, networkId: string, raw: unknown): string | null {
69
+ if (!Array.isArray(raw)) return null;
70
+ for (const item of raw) {
71
+ const fileId = item && typeof item === "object" ? (item as { file_id?: unknown }).file_id : undefined;
72
+ if (fileId === undefined) continue;
73
+ if (typeof fileId !== "string" || !restrictedMemberCanUseFile(userId, username, networkId, fileId)) return String(fileId);
74
+ }
75
+ return null;
76
+ }
package/src/retention.ts CHANGED
@@ -20,6 +20,7 @@
20
20
  // COMMHUB_DB=/tmp/test-retention.db bun test src/retention.test.ts
21
21
 
22
22
  import { db } from "./db.js";
23
+ import { sweepNodeRequestContent, type NodeRequestSweepResult } from "./node-request-retention.js";
23
24
 
24
25
  export type RetentionConfig = {
25
26
  // High-frequency telemetry; default = 7 days.
@@ -52,6 +53,9 @@ export type SweepResult = {
52
53
  tasks: number;
53
54
  audit_log: number;
54
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;
55
59
  vacuum: {
56
60
  walCheckpointPagesMoved: number | null;
57
61
  incrementalFreedPages: number | null;
@@ -178,6 +182,8 @@ export function sweepRetention(cfg: RetentionConfig = readRetentionConfig()): Sw
178
182
  cfg.auditLogDays,
179
183
  );
180
184
 
185
+ const nodeRequests = sweepNodeRequestContent();
186
+
181
187
  // VACUUM:
182
188
  // - wal_checkpoint(TRUNCATE): trims the WAL file. Works on every
183
189
  // SQLite DB regardless of auto_vacuum setting. Useful after
@@ -191,10 +197,13 @@ export function sweepRetention(cfg: RetentionConfig = readRetentionConfig()): Sw
191
197
  // DBs must run a one-time blocking `VACUUM` themselves (out of
192
198
  // scope for the sweeper, which deliberately stays non-
193
199
  // blocking).
200
+ // Both are SQLite PRAGMAs; on PostgreSQL autovacuum does this job and
201
+ // the two counters stay null (RFC-039 S3).
194
202
  let walCheckpointPagesMoved: number | null = null;
195
203
  let incrementalFreedPages: number | null = null;
196
204
  let errored = false;
197
- try {
205
+ const sqliteVacuum = db.dialect === "sqlite";
206
+ if (sqliteVacuum) try {
198
207
  // wal_checkpoint(TRUNCATE) returns a row { busy, log, checkpointed }.
199
208
  // We expose `log` (page count moved into the main DB) for ops visibility.
200
209
  const checkpoint = db.get<{ busy: number; log: number; checkpointed: number }>(
@@ -205,7 +214,7 @@ export function sweepRetention(cfg: RetentionConfig = readRetentionConfig()): Sw
205
214
  console.log(`[commhub retention] wal_checkpoint failed: ${e?.message ?? e}`);
206
215
  errored = true;
207
216
  }
208
- try {
217
+ if (sqliteVacuum) try {
209
218
  // incremental_vacuum is a no-op on databases not created with
210
219
  // PRAGMA auto_vacuum = INCREMENTAL. For existing prod DBs that
211
220
  // weren't, the operator would have to run a full one-time VACUUM
@@ -230,6 +239,7 @@ export function sweepRetention(cfg: RetentionConfig = readRetentionConfig()): Sw
230
239
  tasks,
231
240
  audit_log: auditLog,
232
241
  },
242
+ nodeRequests,
233
243
  vacuum: { walCheckpointPagesMoved, incrementalFreedPages, errored },
234
244
  };
235
245
  }
@@ -249,11 +259,13 @@ export function startRetentionSweeper(
249
259
  r.deletes.inbox +
250
260
  r.deletes.tasks +
251
261
  r.deletes.audit_log;
252
- if (totalDeletes > 0 || r.vacuum.errored) {
262
+ const nr = r.nodeRequests;
263
+ if (totalDeletes > 0 || nr.purged > 0 || nr.deleted > 0 || nr.timedOut > 0 || r.vacuum.errored) {
253
264
  console.log(
254
265
  `[commhub retention] swept in ${r.durationMs}ms — ` +
255
266
  `telemetry=${r.deletes.agent_telemetry} events=${r.deletes.task_events} ` +
256
267
  `inbox=${r.deletes.inbox} tasks=${r.deletes.tasks} audit=${r.deletes.audit_log} ` +
268
+ `nodeReqPurged=${nr.purged} nodeReqDeleted=${nr.deleted} nodeReqTimedOut=${nr.timedOut} ` +
257
269
  `walPages=${r.vacuum.walCheckpointPagesMoved} freedPages=${r.vacuum.incrementalFreedPages}`,
258
270
  );
259
271
  }
@@ -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
+ });