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

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 (317) 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 +930 -35
  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 +129 -0
  205. package/src/rest-identity.ts +125 -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 +91 -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 +141 -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-stale-open-http.test.ts +290 -0
  303. package/src/task-stale-open.ts +103 -0
  304. package/src/tasks-pagination-http.test.ts +141 -0
  305. package/src/tool-audience-http.test.ts +220 -0
  306. package/src/tool-audience.ts +118 -0
  307. package/src/tools-list-json-schema.test.ts +67 -0
  308. package/src/tools.ts +3927 -323
  309. package/src/upload-early-reject-guard.test.ts +155 -0
  310. package/src/uploads-http.test.ts +97 -15
  311. package/src/uploads.ts +103 -6
  312. package/src/user-avatar-http.test.ts +74 -0
  313. package/src/user-avatar-migration.test.ts +25 -0
  314. package/src/user-inbox-by-agent-http.test.ts +161 -0
  315. package/src/user-inbox-netid-notnull.test.ts +122 -0
  316. package/src/user-inbox-read-http.test.ts +223 -0
  317. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,432 @@
1
+ // #517 — MCP write-path network resolution (utok single-network fallback).
2
+ //
3
+ // Root cause (issue #517): utok_ token rows are created with network_id=null
4
+ // BY DESIGN (auth.ts login), and the MCP write path (tools.ts canWrite) had
5
+ // no single-network fallback — unlike REST POST /api/task (server.ts), which
6
+ // auto-resolves via singleNetworkId(restScope) when the user belongs to
7
+ // exactly one network. Result: a utok node could READ everything
8
+ // (resolveReadScope falls back to all memberships) but could not WRITE
9
+ // anything, and the error mislabeled the failure as permission_denied.
10
+ //
11
+ // Fix under test:
12
+ // - getNetworkId falls back to the user's single membership via the SAME
13
+ // shared singleNetworkId/getUserNetworkIds used by REST (network-scope.ts)
14
+ // - canWrite delegates to the shared canRestWriteNetwork
15
+ // - the 7 write tools that had NO network_id input gain an optional one:
16
+ // send_message / send_reply / ack_inbox / send_ack / retry_task /
17
+ // cancel_task / reassign_task
18
+ // - writeDeniedReply names the REAL cause: network_id_required (0 or ≥2
19
+ // memberships) / access_denied (not a member of the requested network) /
20
+ // permission_denied (viewer role)
21
+ // - ntok_ (network-bound) behavior: ZERO change — pinned below
22
+ //
23
+ // Assertion discipline: error replies are asserted with toEqual (accepted
24
+ // set == spec-allowed set, no not.toBe / toContain wideners).
25
+ //
26
+ // Run: COMMHUB_DB=/tmp/517-net-scope-test.db bun test src/mcp-write-network-resolution.test.ts
27
+
28
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
29
+ import { db, uuidv4 } from "./db.js";
30
+ import { deleteNetwork } from "./auth.js";
31
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
32
+ import { registerTools } from "./tools.js";
33
+
34
+ const NET_A = "net_517_a";
35
+ const NET_B = "net_517_b";
36
+ const U_SINGLE = "u517_single"; // owner of NET_A only → fallback resolves
37
+ const U_MULTI = "u517_multi"; // member of NET_A + NET_B → ambiguous
38
+ const U_VIEWER = "u517_viewer"; // viewer of NET_A only → resolves, then role-denied
39
+ const U_NONE = "u517_none"; // no memberships
40
+ const U_OUTSIDER = "u517_outsider"; // member of NET_B only, targets NET_A
41
+ const A1 = "peer-517-a1";
42
+ const A2 = "peer-517-a2";
43
+ const ALL_USERS = [U_SINGLE, U_MULTI, U_VIEWER, U_NONE, U_OUTSIDER];
44
+
45
+ interface ToolHandler {
46
+ (args: any, extra?: any): Promise<{ content: Array<{ type: "text"; text: string }> }>;
47
+ }
48
+ interface Reply { ok?: boolean; error?: string; message?: string; [k: string]: unknown }
49
+
50
+ function cleanup() {
51
+ for (const t of ["tasks", "inbox", "task_events"]) {
52
+ try { db.run(`DELETE FROM ${t} WHERE network_id IN (?1, ?2)`, [NET_A, NET_B]); } catch {}
53
+ try { db.run(`DELETE FROM ${t} WHERE content LIKE 'x517%'`); } catch {}
54
+ }
55
+ try { db.run("DELETE FROM sessions WHERE network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {}
56
+ try { db.run("DELETE FROM nodes WHERE network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {}
57
+ try { db.run("DELETE FROM network_members WHERE network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {}
58
+ try { db.run("DELETE FROM networks WHERE network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {}
59
+ for (const u of ALL_USERS) {
60
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [u]); } catch {}
61
+ }
62
+ }
63
+
64
+ function seed() {
65
+ for (const u of ALL_USERS) {
66
+ db.run(
67
+ `INSERT INTO users (user_id, username, password_hash, role, created_at)
68
+ VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
69
+ [u, u],
70
+ );
71
+ }
72
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET_A, U_SINGLE]);
73
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET_B, U_MULTI]);
74
+ const member = (u: string, net: string, role: string) =>
75
+ db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, ?3, datetime('now'))`, [u, net, role]);
76
+ member(U_SINGLE, NET_A, "owner");
77
+ member(U_MULTI, NET_A, "member");
78
+ member(U_MULTI, NET_B, "owner");
79
+ member(U_VIEWER, NET_A, "viewer");
80
+ member(U_OUTSIDER, NET_B, "member");
81
+ for (const alias of [A1, A2]) {
82
+ db.run(
83
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state)
84
+ VALUES (?1, ?2, ?2, ?3, ?4, datetime('now'), datetime('now'), 'active')`,
85
+ [`node_${alias}`, alias, NET_A, `host-${alias}`],
86
+ );
87
+ db.run(
88
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
89
+ VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
90
+ [`res_${alias}`, alias, `node_${alias}`, NET_A],
91
+ );
92
+ }
93
+ }
94
+
95
+ beforeEach(() => { cleanup(); seed(); });
96
+ afterAll(cleanup);
97
+
98
+ type HandlerMap = { tools: Record<string, ToolHandler>; schemas: Record<string, Record<string, unknown>> };
99
+
100
+ function buildHandlers(opts: { netId?: string | null; userId?: string | null; alias?: string | null; isNetwork?: boolean }): HandlerMap {
101
+ const server = new McpServer({ name: "test", version: "0" }) as any;
102
+ const tools: Record<string, ToolHandler> = {};
103
+ const schemas: Record<string, Record<string, unknown>> = {};
104
+ const origTool = server.tool.bind(server);
105
+ server.tool = (name: string, _desc: string, schema: any, handler: ToolHandler) => {
106
+ tools[name] = handler;
107
+ schemas[name] = schema;
108
+ return origTool(name, _desc, schema, handler);
109
+ };
110
+ registerTools(
111
+ server,
112
+ undefined,
113
+ opts.netId ?? null,
114
+ opts.userId ?? null,
115
+ opts.alias ?? opts.userId ?? null,
116
+ opts.isNetwork ?? false,
117
+ null,
118
+ );
119
+ return { tools, schemas };
120
+ }
121
+
122
+ async function call(handler: ToolHandler, args: any): Promise<Reply> {
123
+ const r = await handler(args);
124
+ return JSON.parse(r.content[0].text) as Reply;
125
+ }
126
+
127
+ function makeTask(to: string, status: string, content: string, net: string | null = NET_A): string {
128
+ const id = uuidv4();
129
+ db.run(
130
+ `INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, network_id)
131
+ VALUES (?1, 'dispatcher', ?2, 'normal', ?3, ?4, 'reply', datetime('now'), ?5)`,
132
+ [id, to, status, content, net],
133
+ );
134
+ return id;
135
+ }
136
+
137
+ function makeInbox(to: string, content: string, net: string | null = NET_A): string {
138
+ const id = uuidv4();
139
+ db.run(
140
+ `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, network_id)
141
+ VALUES (?1, ?2, ?3, 'message', 'normal', ?4, 'dispatcher', ?5)`,
142
+ [id, to, `node_${to}`, content, net],
143
+ );
144
+ return id;
145
+ }
146
+
147
+ const taskRow = (content: string) =>
148
+ db.get<{ task_id: string; status: string; to_name: string; network_id: string | null }>(
149
+ "SELECT task_id, status, to_name, network_id FROM tasks WHERE content = ?1", content);
150
+ const taskById = (id: string) =>
151
+ db.get<{ status: string; to_name: string; network_id: string | null }>(
152
+ "SELECT status, to_name, network_id FROM tasks WHERE task_id = ?1", id);
153
+ const inboxRow = (content: string) =>
154
+ db.get<{ id: string; acked: number; network_id: string | null }>(
155
+ "SELECT id, acked, network_id FROM inbox WHERE content = ?1", content);
156
+
157
+ // ─────────────────────────────────────────────────────────────────────
158
+ // Group 1 — utok, single network, NO network_id argument anywhere.
159
+ // The hub must auto-resolve to the user's only membership (REST parity
160
+ // with server.ts POST /api/task → singleNetworkId fallback).
161
+ // ─────────────────────────────────────────────────────────────────────
162
+ describe("utok single-network auto-resolve (no network_id argument)", () => {
163
+ test("send_task resolves to the only membership", async () => {
164
+ const { tools } = buildHandlers({ userId: U_SINGLE });
165
+ const r = await call(tools["send_task"], { alias: A1, task: "x517 g1 send_task", priority: "normal" });
166
+ expect(r.ok).toBe(true);
167
+ expect(taskRow("x517 g1 send_task")?.network_id).toBe(NET_A);
168
+ });
169
+
170
+ test("send_message resolves to the only membership", async () => {
171
+ const { tools } = buildHandlers({ userId: U_SINGLE });
172
+ const r = await call(tools["send_message"], { alias: A1, message: "x517 g1 send_message" });
173
+ expect(r.ok).toBe(true);
174
+ expect(inboxRow("x517 g1 send_message")?.network_id).toBe(NET_A);
175
+ });
176
+
177
+ test("send_reply resolves to the only membership", async () => {
178
+ const { tools } = buildHandlers({ userId: U_SINGLE });
179
+ const t = makeTask(A1, "delivered", "x517 g1 send_reply parent");
180
+ const r = await call(tools["send_reply"], { alias: A1, text: "x517 g1 send_reply", in_reply_to: t, status: "replied" });
181
+ expect(r.ok).toBe(true);
182
+ expect(taskById(t)?.status).toBe("replied");
183
+ });
184
+
185
+ test("ack_inbox resolves to the only membership", async () => {
186
+ const { tools } = buildHandlers({ userId: U_SINGLE });
187
+ const ib = makeInbox(A1, "x517 g1 ack_inbox");
188
+ const r = await call(tools["ack_inbox"], { alias: A1, message_id: ib });
189
+ expect(r.ok).toBe(true);
190
+ expect(inboxRow("x517 g1 ack_inbox")?.acked).toBe(1);
191
+ });
192
+
193
+ test("send_ack resolves to the only membership", async () => {
194
+ const { tools } = buildHandlers({ userId: U_SINGLE });
195
+ const t = makeTask(A1, "delivered", "x517 g1 send_ack");
196
+ const r = await call(tools["send_ack"], { task_id: t });
197
+ expect(r.ok).toBe(true);
198
+ expect(taskById(t)?.status).toBe("acked");
199
+ });
200
+
201
+ test("retry_task resolves to the only membership", async () => {
202
+ const { tools } = buildHandlers({ userId: U_SINGLE });
203
+ const t = makeTask(A1, "failed", "x517 g1 retry_task");
204
+ const r = await call(tools["retry_task"], { task_id: t });
205
+ expect(r.ok).toBe(true);
206
+ expect(taskById(t)?.status).toBe("delivered");
207
+ });
208
+
209
+ test("cancel_task resolves to the only membership", async () => {
210
+ const { tools } = buildHandlers({ userId: U_SINGLE });
211
+ const t = makeTask(A1, "delivered", "x517 g1 cancel_task");
212
+ const r = await call(tools["cancel_task"], { task_id: t });
213
+ expect(r.ok).toBe(true);
214
+ expect(taskById(t)?.status).toBe("cancelled");
215
+ });
216
+
217
+ test("reassign_task resolves to the only membership", async () => {
218
+ const { tools } = buildHandlers({ userId: U_SINGLE });
219
+ const t = makeTask(A1, "delivered", "x517 g1 reassign_task");
220
+ const r = await call(tools["reassign_task"], { task_id: t, new_alias: A2 });
221
+ expect(r.ok).toBe(true);
222
+ expect(taskById(t)?.to_name).toBe(A2);
223
+ });
224
+ });
225
+
226
+ // ─────────────────────────────────────────────────────────────────────
227
+ // Group 2 — utok, MULTI network. Without network_id: precise
228
+ // network_id_required error (not permission_denied). With explicit
229
+ // network_id: every formerly param-less tool must thread it through.
230
+ // One case per changed tool (通信龙 acceptance rule: 改了三个只测一个,
231
+ // 剩下两个等于没验).
232
+ // ─────────────────────────────────────────────────────────────────────
233
+ describe("utok multi-network", () => {
234
+ test("send_task without network_id → exact network_id_required error", async () => {
235
+ const { tools } = buildHandlers({ userId: U_MULTI });
236
+ const r = await call(tools["send_task"], { alias: A1, task: "x517 g2 ambiguous", priority: "normal" });
237
+ expect(r).toEqual({
238
+ ok: false,
239
+ error: "network_id_required",
240
+ message: "user token spans 2 networks; pass network_id explicitly (see /api/auth/me networks[].network_id)",
241
+ });
242
+ expect(taskRow("x517 g2 ambiguous")).toBeNull();
243
+ });
244
+
245
+ test("send_message threads explicit network_id", async () => {
246
+ const { tools } = buildHandlers({ userId: U_MULTI });
247
+ const r = await call(tools["send_message"], { alias: A1, message: "x517 g2 send_message", network_id: NET_A });
248
+ expect(r.ok).toBe(true);
249
+ expect(inboxRow("x517 g2 send_message")?.network_id).toBe(NET_A);
250
+ });
251
+
252
+ test("send_reply threads explicit network_id", async () => {
253
+ const { tools } = buildHandlers({ userId: U_MULTI });
254
+ const t = makeTask(A1, "delivered", "x517 g2 send_reply parent");
255
+ const r = await call(tools["send_reply"], { alias: A1, text: "x517 g2 send_reply", in_reply_to: t, status: "replied", network_id: NET_A });
256
+ expect(r.ok).toBe(true);
257
+ expect(taskById(t)?.status).toBe("replied");
258
+ });
259
+
260
+ test("ack_inbox threads explicit network_id", async () => {
261
+ const { tools } = buildHandlers({ userId: U_MULTI });
262
+ // This tests network selection with an authorized recipient. Acting as
263
+ // another owner's recipient is covered by the HTTP identity suite.
264
+ db.run("UPDATE nodes SET owner_user_id = ?1 WHERE alias = ?2 AND network_id = ?3", [U_MULTI, A1, NET_A]);
265
+ const ib = makeInbox(A1, "x517 g2 ack_inbox");
266
+ const r = await call(tools["ack_inbox"], { alias: A1, message_id: ib, network_id: NET_A });
267
+ expect(r.ok).toBe(true);
268
+ expect(inboxRow("x517 g2 ack_inbox")?.acked).toBe(1);
269
+ });
270
+
271
+ test("send_ack threads explicit network_id", async () => {
272
+ const { tools } = buildHandlers({ userId: U_MULTI });
273
+ const t = makeTask(A1, "delivered", "x517 g2 send_ack");
274
+ const r = await call(tools["send_ack"], { task_id: t, network_id: NET_A });
275
+ expect(r.ok).toBe(true);
276
+ expect(taskById(t)?.status).toBe("acked");
277
+ });
278
+
279
+ test("retry_task threads explicit network_id", async () => {
280
+ const { tools } = buildHandlers({ userId: U_MULTI });
281
+ const t = makeTask(A1, "failed", "x517 g2 retry_task");
282
+ const r = await call(tools["retry_task"], { task_id: t, network_id: NET_A });
283
+ expect(r.ok).toBe(true);
284
+ expect(taskById(t)?.status).toBe("delivered");
285
+ });
286
+
287
+ test("cancel_task threads explicit network_id", async () => {
288
+ const { tools } = buildHandlers({ userId: U_MULTI });
289
+ const t = makeTask(A1, "delivered", "x517 g2 cancel_task");
290
+ const r = await call(tools["cancel_task"], { task_id: t, network_id: NET_A });
291
+ expect(r.ok).toBe(true);
292
+ expect(taskById(t)?.status).toBe("cancelled");
293
+ });
294
+
295
+ test("reassign_task threads explicit network_id", async () => {
296
+ const { tools } = buildHandlers({ userId: U_MULTI });
297
+ const t = makeTask(A1, "delivered", "x517 g2 reassign_task");
298
+ const r = await call(tools["reassign_task"], { task_id: t, new_alias: A2, network_id: NET_A });
299
+ expect(r.ok).toBe(true);
300
+ expect(taskById(t)?.to_name).toBe(A2);
301
+ });
302
+
303
+ test("all 7 formerly param-less write tools expose network_id in their input schema", () => {
304
+ const { schemas } = buildHandlers({ userId: U_MULTI });
305
+ for (const name of ["send_message", "send_reply", "ack_inbox", "send_ack", "retry_task", "cancel_task", "reassign_task"]) {
306
+ expect(Object.keys(schemas[name] ?? {})).toContain("network_id");
307
+ }
308
+ });
309
+ });
310
+
311
+ // ─────────────────────────────────────────────────────────────────────
312
+ // Group 3 — precise causes replace the old catch-all message.
313
+ // ─────────────────────────────────────────────────────────────────────
314
+ describe("precise denial causes", () => {
315
+ test("utok with zero memberships → network_id_required naming the real cause", async () => {
316
+ const { tools } = buildHandlers({ userId: U_NONE });
317
+ const r = await call(tools["send_task"], { alias: A1, task: "x517 g3 none", priority: "normal" });
318
+ expect(r).toEqual({
319
+ ok: false,
320
+ error: "network_id_required",
321
+ message: "user token has no network memberships; join or create a network first",
322
+ });
323
+ });
324
+
325
+ test("utok explicitly targeting a network it is not a member of → access_denied", async () => {
326
+ const { tools } = buildHandlers({ userId: U_OUTSIDER });
327
+ const r = await call(tools["send_task"], { alias: A1, task: "x517 g3 outsider", priority: "normal", network_id: NET_A });
328
+ expect(r).toEqual({
329
+ ok: false,
330
+ error: "access_denied",
331
+ message: "access denied to requested network (not a member)",
332
+ });
333
+ expect(taskRow("x517 g3 outsider")).toBeNull();
334
+ });
335
+
336
+ test("viewer in a single network → permission_denied (send_task wording)", async () => {
337
+ const { tools } = buildHandlers({ userId: U_VIEWER });
338
+ const r = await call(tools["send_task"], { alias: A1, task: "x517 g3 viewer task", priority: "normal" });
339
+ expect(r).toEqual({
340
+ ok: false,
341
+ error: "permission_denied",
342
+ message: "Viewer role cannot send tasks",
343
+ });
344
+ });
345
+
346
+ test("viewer in a single network → permission_denied (generic write wording)", async () => {
347
+ const { tools } = buildHandlers({ userId: U_VIEWER });
348
+ const r = await call(tools["send_message"], { alias: A1, message: "x517 g3 viewer msg" });
349
+ expect(r).toEqual({
350
+ ok: false,
351
+ error: "permission_denied",
352
+ message: "Viewer role cannot write to this network",
353
+ });
354
+ });
355
+ });
356
+
357
+ // ─────────────────────────────────────────────────────────────────────
358
+ // Group 3b — ghost memberships (PR #519 codex P2): deleteNetwork used to
359
+ // leave network_members rows behind, so a stale membership to a deleted
360
+ // network could (a) make a single-network user look multi-network, or
361
+ // (b) auto-resolve a write INTO the deleted network, creating orphaned
362
+ // rows. getUserNetworkIds now joins networks; deleteNetwork now removes
363
+ // its membership rows.
364
+ // ─────────────────────────────────────────────────────────────────────
365
+ describe("ghost memberships (deleted networks)", () => {
366
+ const GHOST = "net_517_ghost";
367
+
368
+ test("membership to a deleted network does not break single-network auto-resolve", async () => {
369
+ // stale row: membership exists, networks row does not
370
+ db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [U_SINGLE, GHOST]);
371
+ const { tools } = buildHandlers({ userId: U_SINGLE });
372
+ const r = await call(tools["send_task"], { alias: A1, task: "x517 ghost still-single", priority: "normal" });
373
+ expect(r.ok).toBe(true);
374
+ expect(taskRow("x517 ghost still-single")?.network_id).toBe(NET_A);
375
+ db.run("DELETE FROM network_members WHERE network_id = ?1", [GHOST]);
376
+ });
377
+
378
+ test("sole membership pointing at a deleted network → no-membership error, no orphaned write", async () => {
379
+ db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [U_NONE, GHOST]);
380
+ const { tools } = buildHandlers({ userId: U_NONE });
381
+ const r = await call(tools["send_task"], { alias: A1, task: "x517 ghost orphan", priority: "normal" });
382
+ expect(r).toEqual({
383
+ ok: false,
384
+ error: "network_id_required",
385
+ message: "user token has no network memberships; join or create a network first",
386
+ });
387
+ expect(taskRow("x517 ghost orphan")).toBeNull();
388
+ db.run("DELETE FROM network_members WHERE network_id = ?1", [GHOST]);
389
+ });
390
+
391
+ test("deleteNetwork removes the network's membership rows (root cause)", () => {
392
+ const NET_DEL = "net_517_del";
393
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET_DEL, U_SINGLE]);
394
+ db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [U_SINGLE, NET_DEL]);
395
+ const res = deleteNetwork(U_SINGLE, NET_DEL);
396
+ expect(res.ok).toBe(true);
397
+ const left = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM network_members WHERE network_id = ?1", NET_DEL);
398
+ expect(left?.cnt).toBe(0);
399
+ });
400
+ });
401
+
402
+ // ─────────────────────────────────────────────────────────────────────
403
+ // Group 4 — ntok_ pins: network-bound tokens must see ZERO change.
404
+ // ─────────────────────────────────────────────────────────────────────
405
+ describe("ntok behavior pinned (zero change)", () => {
406
+ test("ntok send_task without network_id stays bound to token network", async () => {
407
+ const { tools } = buildHandlers({ netId: NET_A, userId: U_SINGLE, alias: A1, isNetwork: true });
408
+ const r = await call(tools["send_task"], { alias: A2, task: "x517 g4 ntok plain", priority: "normal" });
409
+ expect(r.ok).toBe(true);
410
+ expect(taskRow("x517 g4 ntok plain")?.network_id).toBe(NET_A);
411
+ });
412
+
413
+ test("ntok send_task ignores a client-supplied foreign network_id (enforced binding wins)", async () => {
414
+ const { tools } = buildHandlers({ netId: NET_A, userId: U_MULTI, alias: A1, isNetwork: true });
415
+ const r = await call(tools["send_task"], { alias: A2, task: "x517 g4 ntok override", priority: "normal", network_id: NET_B });
416
+ expect(r.ok).toBe(true);
417
+ expect(taskRow("x517 g4 ntok override")?.network_id).toBe(NET_A);
418
+ });
419
+ });
420
+
421
+ // ─────────────────────────────────────────────────────────────────────
422
+ // Group 5 — legacy global-token mode (no enforceUserId): the fallback
423
+ // must NOT kick in; writes stay unscoped exactly as before.
424
+ // ─────────────────────────────────────────────────────────────────────
425
+ describe("legacy global-token mode pinned", () => {
426
+ test("send_message without auth context stays network-unscoped", async () => {
427
+ const { tools } = buildHandlers({ userId: null });
428
+ const r = await call(tools["send_message"], { alias: A1, message: "x517 g5 legacy" });
429
+ expect(r.ok).toBe(true);
430
+ expect(inboxRow("x517 g5 legacy")?.network_id).toBeNull();
431
+ });
432
+ });
@@ -0,0 +1,93 @@
1
+ // 成员增 / 改角色 / 移出 的审计行必须带 network_id —— HTTP 集成测试(真实 Bun.serve,私有端口,临时库)。
2
+ //
3
+ // 之前这三条 logAudit 没传 networkId,audit_log.network_id 恒为 NULL:
4
+ // 按网络归属审计时它们整批丢失,而同一组路由里的 member_agent_grants_changed 一直带着。
5
+
6
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
7
+ import { mkdtempSync, rmSync } from "fs";
8
+ import { join } from "path";
9
+ import { tmpdir } from "os";
10
+ import { register } from "./auth.js";
11
+ import { db } from "./db.js";
12
+
13
+ const DIR = mkdtempSync(join(tmpdir(), "anet-member-audit-"));
14
+ let BASE = "";
15
+ let hub: any = null;
16
+
17
+ const PW = "AuditTestPassw0rd!x";
18
+ let ownerToken = "";
19
+ let NET = "";
20
+ let bobId = "";
21
+
22
+ async function send(token: string, method: string, path: string, payload?: unknown): Promise<{ status: number; body: any }> {
23
+ const res = await fetch(`${BASE}${path}`, {
24
+ method,
25
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
26
+ body: payload === undefined ? undefined : JSON.stringify(payload),
27
+ });
28
+ const text = await res.text();
29
+ let body: any = null;
30
+ try { body = JSON.parse(text); } catch {}
31
+ return { status: res.status, body };
32
+ }
33
+
34
+ const auditRow = (action: string) =>
35
+ db.get("SELECT network_id, target_id FROM audit_log WHERE action = ?1 ORDER BY id DESC LIMIT 1", action) as
36
+ { network_id: string | null; target_id: string | null } | null;
37
+
38
+ beforeAll(async () => {
39
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
40
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
41
+ process.env.HOST = "127.0.0.1";
42
+
43
+ const owner = register(`audit_owner_${Date.now()}`, PW, undefined, "Owner");
44
+ expect(owner.ok).toBe(true);
45
+ ownerToken = owner.token!;
46
+ NET = owner.network_id!;
47
+ const bob = register(`audit_bob_${Date.now()}`, PW, undefined, "Bob");
48
+ expect(bob.ok).toBe(true);
49
+ bobId = bob.user!.user_id;
50
+
51
+ const mod: any = await import("./server.js");
52
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
53
+ BASE = `http://127.0.0.1:${hub.port}`;
54
+ }, 30_000);
55
+
56
+ afterAll(() => {
57
+ try { hub?.stop?.(true); } catch {}
58
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
59
+ });
60
+
61
+ describe("成员审计行带 network_id", () => {
62
+ test("member_added", async () => {
63
+ const r = await send(ownerToken, "POST", `/api/networks/${NET}/members`, { user_id: bobId, role: "member" });
64
+ expect(r.status).toBe(200);
65
+ const row = auditRow("member_added");
66
+ expect(row).toBeTruthy();
67
+ expect(row!.network_id).toBe(NET);
68
+ });
69
+
70
+ test("member_role_changed", async () => {
71
+ const r = await send(ownerToken, "PUT", `/api/networks/${NET}/members/${bobId}`, { role: "viewer" });
72
+ expect(r.status).toBe(200);
73
+ const row = auditRow("member_role_changed");
74
+ expect(row).toBeTruthy();
75
+ expect(row!.network_id).toBe(NET);
76
+ });
77
+
78
+ test("member_removed", async () => {
79
+ const r = await send(ownerToken, "DELETE", `/api/networks/${NET}/members/${bobId}`);
80
+ expect(r.status).toBe(200);
81
+ const row = auditRow("member_removed");
82
+ expect(row).toBeTruthy();
83
+ expect(row!.network_id).toBe(NET);
84
+ });
85
+
86
+ test("GET /api/audit-log(本人的审计)里这三条的 network_id 都是该网络", async () => {
87
+ const r = await send(ownerToken, "GET", "/api/audit-log?limit=200");
88
+ expect(r.status).toBe(200);
89
+ const mine = (r.body.logs as any[]).filter(e => ["member_added", "member_role_changed", "member_removed"].includes(e.action));
90
+ expect(mine.map(e => e.action).sort()).toEqual(["member_added", "member_removed", "member_role_changed"]);
91
+ for (const e of mine) expect(e.network_id).toBe(NET);
92
+ });
93
+ });