@sleep2agi/commhub-server 0.9.0-preview.1 → 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 (267) 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-sessions-http.test.ts +250 -0
  21. package/src/auth-validate.test.ts +5 -1
  22. package/src/auth.ts +493 -35
  23. package/src/avatar-validate.test.ts +163 -0
  24. package/src/avatar-validate.ts +103 -0
  25. package/src/blocked-exit-on-reply.test.ts +122 -0
  26. package/src/boot-side-effect.test.ts +124 -0
  27. package/src/commhub-bin-args.test.ts +85 -0
  28. package/src/config-apply-sec1.test.ts +270 -1
  29. package/src/config-apply-validate.test.ts +92 -0
  30. package/src/config-apply-validate.ts +81 -3
  31. package/src/create-node-blocked-gate.test.ts +356 -0
  32. package/src/create-node-tool-schema.test.ts +39 -0
  33. package/src/create-node-validate.test.ts +275 -0
  34. package/src/create-node-validate.ts +288 -0
  35. package/src/create-node-workdir.test.ts +255 -0
  36. package/src/create-node.test.ts +354 -0
  37. package/src/create-node.ts +349 -0
  38. package/src/dashboard-slash-routing-http.test.ts +107 -0
  39. package/src/db-adapter-guard.test.ts +148 -0
  40. package/src/db-adapter-translate.test.ts +128 -0
  41. package/src/db-adapter.ts +323 -87
  42. package/src/db-timestamp.test.ts +38 -0
  43. package/src/db-timestamp.ts +15 -0
  44. package/src/db.ts +965 -34
  45. package/src/delivered-without-push.test.ts +140 -0
  46. package/src/department-groups-http.test.ts +209 -0
  47. package/src/department-groups-sync-http.test.ts +328 -0
  48. package/src/department-groups.ts +434 -0
  49. package/src/department-heads-http.test.ts +329 -0
  50. package/src/department-heads.ts +226 -0
  51. package/src/departments-http.test.ts +185 -0
  52. package/src/departments.ts +260 -0
  53. package/src/desktop-message-seam.test.ts +150 -0
  54. package/src/desktop-message.test.ts +368 -0
  55. package/src/dm-file-scope-http.test.ts +178 -0
  56. package/src/ext-token-shared.test.ts +60 -0
  57. package/src/ext-validation-regression.test.ts +131 -0
  58. package/src/external-schedule-edits-http.test.ts +321 -0
  59. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  60. package/src/external-schedule-edits.ts +327 -0
  61. package/src/file-download-authz-dev-open.test.ts +135 -0
  62. package/src/file-download-authz.test.ts +404 -0
  63. package/src/file-download-date-bucket.test.ts +436 -0
  64. package/src/file-network-scope-dev-open.test.ts +97 -0
  65. package/src/file-network-scope.test.ts +732 -0
  66. package/src/group-membership-events-http.test.ts +264 -0
  67. package/src/group-messages-http.test.ts +482 -0
  68. package/src/group-messages.ts +297 -0
  69. package/src/health-redaction.test.ts +642 -0
  70. package/src/http-gzip.test.ts +94 -0
  71. package/src/http-gzip.ts +112 -0
  72. package/src/hub-timestamp.test.ts +38 -0
  73. package/src/hub-timestamp.ts +27 -0
  74. package/src/human-dm.ts +215 -0
  75. package/src/inbox-count-sse.test.ts +310 -0
  76. package/src/inbox-count.ts +29 -0
  77. package/src/index.ts +15 -2172
  78. package/src/lifecycle-guard.ts +50 -0
  79. package/src/list-host-supervisors.test.ts +314 -0
  80. package/src/mcp-context-cost-http.test.ts +119 -0
  81. package/src/mcp-projects-events-http.test.ts +162 -0
  82. package/src/mcp-route-label-http.test.ts +120 -0
  83. package/src/mcp-write-network-resolution.test.ts +429 -0
  84. package/src/member-audit-network-id-http.test.ts +93 -0
  85. package/src/member-presence-http.test.ts +206 -0
  86. package/src/messages-ack-by-agent-http.test.ts +142 -0
  87. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  88. package/src/migrate-sqlite-to-pg.ts +263 -0
  89. package/src/model-auth-notify-http.test.ts +181 -0
  90. package/src/model-auth-notify.ts +116 -0
  91. package/src/network-name-http.test.ts +75 -0
  92. package/src/network-scope.ts +224 -0
  93. package/src/network-token-membership-http.test.ts +139 -0
  94. package/src/node-attrs-validate.ts +88 -0
  95. package/src/node-attrs.test.ts +217 -0
  96. package/src/node-create-request-status-http.test.ts +92 -0
  97. package/src/node-files-transport.test.ts +344 -0
  98. package/src/node-health-dispatch-http.test.ts +279 -0
  99. package/src/node-health-guard.ts +105 -0
  100. package/src/node-health-passthrough.test.ts +115 -0
  101. package/src/node-health-store.ts +72 -0
  102. package/src/node-id-alias.test.ts +188 -0
  103. package/src/node-identity-conflict-http.test.ts +269 -0
  104. package/src/node-identity-conflict.ts +142 -0
  105. package/src/node-identity-mismatch.test.ts +74 -0
  106. package/src/node-lifecycle-controllable.test.ts +64 -0
  107. package/src/node-lifecycle-controllable.ts +51 -0
  108. package/src/node-logs-transport.test.ts +263 -0
  109. package/src/node-permissions-http.test.ts +276 -0
  110. package/src/node-permissions.ts +242 -0
  111. package/src/node-request-retention.test.ts +281 -0
  112. package/src/node-request-retention.ts +120 -0
  113. package/src/node-skills-transport.test.ts +209 -0
  114. package/src/node-token-privilege-http.test.ts +149 -0
  115. package/src/observer-avatar-http.test.ts +431 -0
  116. package/src/observer-push.test.ts +252 -0
  117. package/src/package-version-consistency.test.ts +25 -0
  118. package/src/peer-reply-atomic.test.ts +455 -0
  119. package/src/peer-reply-capability.test.ts +70 -0
  120. package/src/pg-worker.ts +87 -0
  121. package/src/probe-validate.test.ts +221 -0
  122. package/src/probe-validate.ts +104 -0
  123. package/src/probe.test.ts +80 -0
  124. package/src/probe.ts +200 -0
  125. package/src/project-viewer-can-http.test.ts +114 -0
  126. package/src/push.ts +429 -10
  127. package/src/redact-tokens.test.ts +65 -0
  128. package/src/redact-tokens.ts +40 -0
  129. package/src/rename.ts +7 -2
  130. package/src/reply-target-derivation.test.ts +96 -0
  131. package/src/report-status-host-schema.test.ts +211 -0
  132. package/src/report-status-resume-id-handover.test.ts +150 -0
  133. package/src/require-explicit-test-db.ts +9 -0
  134. package/src/requirement-due-reminders-http.test.ts +390 -0
  135. package/src/requirement-due-reminders.ts +310 -0
  136. package/src/requirement-events-http.test.ts +257 -0
  137. package/src/requirement-events.test.ts +45 -0
  138. package/src/requirement-events.ts +144 -0
  139. package/src/requirement-notify.ts +165 -0
  140. package/src/requirement-participant-notify-http.test.ts +309 -0
  141. package/src/requirement-tag-ops-http.test.ts +266 -0
  142. package/src/requirement-tags.ts +89 -0
  143. package/src/requirements-comment-http.test.ts +165 -0
  144. package/src/requirements-completed-at-migration.test.ts +70 -0
  145. package/src/requirements-errors-mcp-http.test.ts +164 -0
  146. package/src/requirements-errors.ts +143 -0
  147. package/src/requirements-etag-http.test.ts +91 -0
  148. package/src/requirements-http.test.ts +709 -0
  149. package/src/requirements-last-event-http.test.ts +344 -0
  150. package/src/requirements-list-mcp-slim-http.test.ts +155 -0
  151. package/src/requirements-list-slim-http.test.ts +319 -0
  152. package/src/requirements-mcp.test.ts +163 -0
  153. package/src/requirements-migrate.ts +225 -0
  154. package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
  155. package/src/requirements-people-http.test.ts +114 -0
  156. package/src/requirements-people-mcp-http.test.ts +225 -0
  157. package/src/requirements-people.ts +127 -0
  158. package/src/requirements-priority-lowest.test.ts +114 -0
  159. package/src/requirements-search-http.test.ts +182 -0
  160. package/src/requirements-search.test.ts +32 -0
  161. package/src/requirements-search.ts +145 -0
  162. package/src/requirements-seq-http.test.ts +193 -0
  163. package/src/requirements-seq-migration.test.ts +63 -0
  164. package/src/requirements-start-date.test.ts +125 -0
  165. package/src/requirements-stats-http.test.ts +288 -0
  166. package/src/requirements-stats.ts +164 -0
  167. package/src/requirements-status-participants-http.test.ts +220 -0
  168. package/src/requirements-upgrade-e2e.test.ts +126 -0
  169. package/src/requirements-upgrade-migration.test.ts +98 -0
  170. package/src/requirements.ts +1761 -0
  171. package/src/resolve-port.test.ts +57 -0
  172. package/src/resolve-port.ts +44 -0
  173. package/src/response-charset.test.ts +130 -0
  174. package/src/rest-explicit-columns-http.test.ts +222 -0
  175. package/src/rest-identity.test.ts +129 -0
  176. package/src/rest-identity.ts +100 -0
  177. package/src/rest-projections.ts +68 -0
  178. package/src/rest-write-network-resolution.test.ts +133 -0
  179. package/src/restricted-files.ts +73 -0
  180. package/src/retention.ts +15 -3
  181. package/src/route-callers-http.test.ts +120 -0
  182. package/src/route-timing.test.ts +211 -0
  183. package/src/route-timing.ts +258 -0
  184. package/src/rules-file-duplicate-alias.test.ts +234 -0
  185. package/src/rules-file-session-target.test.ts +207 -0
  186. package/src/rules-file-transport.test.ts +237 -0
  187. package/src/runtime-label.test.ts +22 -0
  188. package/src/runtime-label.ts +21 -0
  189. package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
  190. package/src/scheduled-reply-unread-http.test.ts +212 -0
  191. package/src/scheduled-run-terminal.test.ts +268 -0
  192. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  193. package/src/scheduled-stuck-http.test.ts +238 -0
  194. package/src/scheduled-stuck.ts +153 -0
  195. package/src/scheduled-tasks-http.test.ts +638 -0
  196. package/src/scheduled-tasks.ts +641 -0
  197. package/src/send-dedup-request-id.test.ts +229 -0
  198. package/src/send-reply-agent-warning.test.ts +202 -0
  199. package/src/send-reply-alias-optional.test.ts +131 -0
  200. package/src/send-reply-attachments.test.ts +458 -0
  201. package/src/send_dedup.test.ts +54 -0
  202. package/src/send_dedup.ts +52 -6
  203. package/src/serve-error.test.ts +38 -0
  204. package/src/serve-error.ts +27 -0
  205. package/src/server.ts +5038 -0
  206. package/src/shared/external-schedule-contract.ts +60 -0
  207. package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
  208. package/src/shared/probe-host-allowlist.ts +128 -0
  209. package/src/shared/reserved-env-drift.test.ts +43 -0
  210. package/src/shared/reserved-env.test.ts +35 -0
  211. package/src/shared/reserved-env.ts +31 -0
  212. package/src/side-thread-command-transport.test.ts +312 -0
  213. package/src/side-thread-command-transport.ts +318 -0
  214. package/src/side-thread-http-integration.test.ts +233 -0
  215. package/src/side-thread-http.ts +504 -0
  216. package/src/side-thread-production.test.ts +72 -0
  217. package/src/side-thread-production.ts +118 -0
  218. package/src/side-thread.test.ts +1067 -0
  219. package/src/side-thread.ts +2499 -0
  220. package/src/skillhub-http.test.ts +108 -0
  221. package/src/sqlite-close-checkpoint.test.ts +78 -0
  222. package/src/start-node.test.ts +109 -0
  223. package/src/status-alias-filter-http.test.ts +106 -0
  224. package/src/status-alias-resolver-http.test.ts +163 -0
  225. package/src/status-alias-resolver.ts +19 -0
  226. package/src/status-node-id-filter-http.test.ts +161 -0
  227. package/src/status-read-cache-http.test.ts +229 -0
  228. package/src/status-read-cache.ts +122 -0
  229. package/src/stop-delete-node.test.ts +1164 -0
  230. package/src/task-access-http.test.ts +379 -0
  231. package/src/task-access.ts +413 -0
  232. package/src/task-auth-origin-http.test.ts +91 -0
  233. package/src/task-auth-origin.test.ts +24 -0
  234. package/src/task-auth-origin.ts +7 -0
  235. package/src/task-consumption.test.ts +482 -0
  236. package/src/task-diagnostic-http.test.ts +149 -0
  237. package/src/task-diagnostic.test.ts +115 -0
  238. package/src/task-diagnostic.ts +118 -0
  239. package/src/task-expiry-notice-http.test.ts +287 -0
  240. package/src/task-expiry-notice.ts +313 -0
  241. package/src/task-idempotency-mcp.test.ts +142 -0
  242. package/src/task-idempotency.test.ts +60 -0
  243. package/src/task-idempotency.ts +75 -0
  244. package/src/task-lifecycle-watcher.test.ts +204 -0
  245. package/src/task-lifecycle-watcher.ts +49 -0
  246. package/src/task-network-resolution-http.test.ts +141 -0
  247. package/src/task-queue-ahead-http.test.ts +309 -0
  248. package/src/task-queue-ahead-scale.test.ts +211 -0
  249. package/src/task-queue-ahead.ts +186 -0
  250. package/src/task-stale-open-http.test.ts +290 -0
  251. package/src/task-stale-open.ts +103 -0
  252. package/src/tasks-pagination-http.test.ts +141 -0
  253. package/src/tool-audience-http.test.ts +195 -0
  254. package/src/tool-audience.ts +109 -0
  255. package/src/tools-list-json-schema.test.ts +67 -0
  256. package/src/tools.ts +4955 -300
  257. package/src/update-provider.test.ts +234 -0
  258. package/src/upload-early-reject-guard.test.ts +155 -0
  259. package/src/uploads-http.test.ts +97 -15
  260. package/src/uploads.test.ts +66 -0
  261. package/src/uploads.ts +138 -5
  262. package/src/user-inbox-by-agent-http.test.ts +161 -0
  263. package/src/user-inbox-netid-notnull.test.ts +122 -0
  264. package/src/user-inbox-read-http.test.ts +223 -0
  265. package/src/user-inbox-schema.test.ts +54 -0
  266. package/src/vault.test.ts +200 -0
  267. package/src/vault.ts +156 -0
@@ -0,0 +1,209 @@
1
+ // Node skills view — list_node_skills / read_node_skill ride the rules-file
2
+ // queue + doorbell (ops skills_list / skill_read).
3
+ //
4
+ // Hub-side contract verified here:
5
+ // 1. report_status { skills_capable: true } is sticky and set only by the
6
+ // session's own bound node token.
7
+ // 2. list_node_skills / read_node_skill reach a session with no nodes row by
8
+ // alias; the node pulls with its own token; read carries the skill NAME
9
+ // (never a path) in the pulled request; results come back through
10
+ // get_rules_file_result for both ops.
11
+ // 3. Bad skill names are refused before any request row exists.
12
+ // 4. A session that can serve rules files but did not report skills_capable is
13
+ // refused up front for skills ops (skills_target_not_found).
14
+ // 5. Separate single-flight lanes: a pending rules read does not block a skills
15
+ // list and vice versa; a second skills request while one is pending is refused.
16
+ // 6. Network isolation and foreign acks are ignored.
17
+ //
18
+ // 跑法:cd server && COMMHUB_DB=/tmp/skills-session.db bun test src/node-skills-transport.test.ts
19
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
20
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
21
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
22
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
23
+ import { db } from "./db.js";
24
+ import { registerTools } from "./tools.js";
25
+
26
+ const NET = "net_skills_session";
27
+ const OTHER_NET = "net_skills_session_other";
28
+ const USER = "u_skills_session_owner";
29
+ const SESSION_ALIAS = "skills-session-cc";
30
+ const SESSION_RESUME = "cc-skills-session-1";
31
+ const OTHER_ALIAS = "skills-session-peer";
32
+ const NODE_ROW_ALIAS = "skills-session-noderow";
33
+ const NODE_ROW_ID = "node_skills_session_noderow";
34
+ const T_SESSION = "tok_skills_session_cc";
35
+ const T_PEER = "tok_skills_session_peer";
36
+ const T_OTHER_NET_SAME_ALIAS = "tok_skills_session_other_net";
37
+ const T_NODE_ROW = "tok_skills_session_noderow";
38
+
39
+ function cleanup() {
40
+ for (const net of [NET, OTHER_NET]) {
41
+ try { db.run("DELETE FROM node_rules_requests WHERE network_id = ?1", [net]); } catch {}
42
+ try { db.run("DELETE FROM sessions WHERE network_id = ?1", [net]); } catch {}
43
+ try { db.run("DELETE FROM nodes WHERE network_id = ?1", [net]); } catch {}
44
+ try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [net]); } catch {}
45
+ try { db.run("DELETE FROM network_members WHERE network_id = ?1", [net]); } catch {}
46
+ try { db.run("DELETE FROM networks WHERE network_id = ?1", [net]); } catch {}
47
+ }
48
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
49
+ }
50
+
51
+ function seedWorld() {
52
+ db.run(`INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`, [USER, USER]);
53
+ for (const net of [NET, OTHER_NET]) {
54
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))`, [net, net, USER]);
55
+ db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [USER, net]);
56
+ }
57
+ const tok = (id: string, net: string, alias: string) => db.run(
58
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at) VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
59
+ [id, USER, net, `node:${alias}`, `hash_${id}`],
60
+ );
61
+ tok(T_SESSION, NET, SESSION_ALIAS);
62
+ tok(T_PEER, NET, OTHER_ALIAS);
63
+ tok(T_OTHER_NET_SAME_ALIAS, OTHER_NET, SESSION_ALIAS);
64
+ tok(T_NODE_ROW, NET, NODE_ROW_ALIAS);
65
+ db.run(
66
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, datetime('now'), datetime('now'))`,
67
+ [NODE_ROW_ID, NODE_ROW_ALIAS, NODE_ROW_ALIAS, NET],
68
+ );
69
+ }
70
+
71
+ type Identity = { net: string; alias: string; isNetworkToken: boolean; tokenId: string };
72
+ const asUser: Identity = { net: NET, alias: USER, isNetworkToken: false, tokenId: "tok_skills_session_user" };
73
+ const asOtherNetUser: Identity = { net: OTHER_NET, alias: USER, isNetworkToken: false, tokenId: "tok_skills_session_user2" };
74
+ const asSession: Identity = { net: NET, alias: SESSION_ALIAS, isNetworkToken: true, tokenId: T_SESSION };
75
+ const asPeer: Identity = { net: NET, alias: OTHER_ALIAS, isNetworkToken: true, tokenId: T_PEER };
76
+ const asOtherNetSameAlias: Identity = { net: OTHER_NET, alias: SESSION_ALIAS, isNetworkToken: true, tokenId: T_OTHER_NET_SAME_ALIAS };
77
+ const asNodeRow: Identity = { net: NET, alias: NODE_ROW_ALIAS, isNetworkToken: true, tokenId: T_NODE_ROW };
78
+
79
+ async function connect(id: Identity) {
80
+ const server = new McpServer({ name: "skills-session-test", version: "1" });
81
+ registerTools(server, undefined, id.net, USER, id.alias, id.isNetworkToken, id.tokenId);
82
+ const client = new Client({ name: "skills-session-client", version: "1" });
83
+ const [ct, st] = InMemoryTransport.createLinkedPair();
84
+ await server.connect(st);
85
+ await client.connect(ct);
86
+ const call = async (name: string, args: Record<string, unknown>) => {
87
+ const r: any = await client.callTool({ name, arguments: args });
88
+ const first = r.content?.[0];
89
+ if (!first || first.type !== "text") throw new Error(`no text result from ${name}: ${JSON.stringify(r).slice(0, 300)}`);
90
+ return JSON.parse(first.text);
91
+ };
92
+ const close = async () => { await client.close(); await server.close(); };
93
+ return { call, close };
94
+ }
95
+
96
+ const capable = (alias: string, net = NET) =>
97
+ db.get<{ c: number }>("SELECT skills_capable AS c FROM sessions WHERE alias = ?1 AND network_id = ?2", alias, net)?.c;
98
+ const rowCount = () => db.get<{ n: number }>("SELECT COUNT(*) AS n FROM node_rules_requests WHERE network_id = ?1", NET)!.n;
99
+
100
+ async function registerSession(id: Identity, alias: string, resume: string, flag: boolean) {
101
+ const c = await connect(id);
102
+ try {
103
+ return await c.call("report_status", { resume_id: resume, alias, status: "idle", agent: "claude-code", ...(flag ? { rules_file_capable: true, skills_capable: true } : {}) });
104
+ } finally { await c.close(); }
105
+ }
106
+
107
+ beforeEach(() => { cleanup(); seedWorld(); });
108
+ afterAll(cleanup);
109
+
110
+ async function reportRulesOnly(id: Identity, alias: string, resume: string) {
111
+ const c = await connect(id);
112
+ try { return await c.call("report_status", { resume_id: resume, alias, status: "idle", agent: "claude-code", rules_file_capable: true }); }
113
+ finally { await c.close(); }
114
+ }
115
+
116
+ describe("skills_capable is set only by the session's own node token", () => {
117
+ test("bound token sets it; a report without it does not clear it; a peer cannot set it", async () => {
118
+ await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, true);
119
+ expect(capable(SESSION_ALIAS)).toBe(1);
120
+ await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, false);
121
+ expect(capable(SESSION_ALIAS)).toBe(1);
122
+ const r = await registerSession(asPeer, SESSION_ALIAS, SESSION_RESUME, true);
123
+ expect(r).toMatchObject({ ok: false });
124
+ }, 20_000);
125
+ });
126
+
127
+ describe("list / read by alias reach a session with no nodes row", () => {
128
+ test("list round trip: enqueue → pull (no content) → ack JSON → result carries it", async () => {
129
+ await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, true);
130
+ const u = await connect(asUser);
131
+ const s = await connect(asSession);
132
+ try {
133
+ const enq = await u.call("list_node_skills", { alias: SESSION_ALIAS });
134
+ expect(enq).toMatchObject({ ok: true, op: "skills_list" });
135
+ const pulled = await s.call("get_rules_file_request", {});
136
+ expect(pulled.request).toEqual({ request_id: enq.request_id, op: "skills_list" });
137
+ const payload = JSON.stringify({ skills: [{ name: "ship", scope: "project", path_rel: ".claude/skills/ship/SKILL.md", description: "d" }] });
138
+ expect(await s.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", file_name: "skills", exists: true, content: payload })).toMatchObject({ ok: true, status: "done" });
139
+ const res = await u.call("get_rules_file_result", { request_id: enq.request_id });
140
+ expect(res).toMatchObject({ ok: true, op: "skills_list", status: "done", content: payload });
141
+ } finally { await u.close(); await s.close(); }
142
+ }, 20_000);
143
+
144
+ test("read round trip: the pulled request carries the skill name only", async () => {
145
+ await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, true);
146
+ const u = await connect(asUser);
147
+ const s = await connect(asSession);
148
+ try {
149
+ const enq = await u.call("read_node_skill", { alias: SESSION_ALIAS, name: "ship" });
150
+ expect(enq).toMatchObject({ ok: true, op: "skill_read" });
151
+ const pulled = await s.call("get_rules_file_request", {});
152
+ expect(pulled.request).toEqual({ request_id: enq.request_id, op: "skill_read", content: "ship" });
153
+ const payload = JSON.stringify({ name: "ship", scope: "project", path_rel: ".claude/skills/ship/SKILL.md", description: "d", content: "# ship" });
154
+ await s.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", file_name: "ship", exists: true, content: payload });
155
+ expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ status: "done", content: payload });
156
+ } finally { await u.close(); await s.close(); }
157
+ }, 20_000);
158
+
159
+ test("bad names are refused before any row is written", async () => {
160
+ await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, true);
161
+ const u = await connect(asUser);
162
+ try {
163
+ for (const name of ["..", ".", "../CLAUDE.md", "a/b", "a b"]) {
164
+ expect(await u.call("read_node_skill", { alias: SESSION_ALIAS, name })).toMatchObject({ ok: false, error: "invalid_skill_name" });
165
+ }
166
+ expect(rowCount()).toBe(0);
167
+ } finally { await u.close(); }
168
+ }, 20_000);
169
+
170
+ test("rules-capable but not skills-capable → skills_target_not_found, no row", async () => {
171
+ await reportRulesOnly(asSession, SESSION_ALIAS, SESSION_RESUME);
172
+ const u = await connect(asUser);
173
+ try {
174
+ expect(await u.call("list_node_skills", { alias: SESSION_ALIAS })).toMatchObject({ ok: false, error: "skills_target_not_found" });
175
+ expect(rowCount()).toBe(0);
176
+ expect(await u.call("read_node_rules_file", { alias: SESSION_ALIAS })).toMatchObject({ ok: true });
177
+ } finally { await u.close(); }
178
+ }, 20_000);
179
+ });
180
+
181
+ describe("single-flight lanes", () => {
182
+ test("a pending rules read does not block a skills list; a second skills request is refused", async () => {
183
+ await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, true);
184
+ const u = await connect(asUser);
185
+ try {
186
+ expect(await u.call("read_node_rules_file", { alias: SESSION_ALIAS })).toMatchObject({ ok: true });
187
+ const list = await u.call("list_node_skills", { alias: SESSION_ALIAS });
188
+ expect(list).toMatchObject({ ok: true });
189
+ expect(await u.call("read_node_skill", { alias: SESSION_ALIAS, name: "ship" })).toMatchObject({ ok: false, error: "request_in_flight", existing_request_id: list.request_id });
190
+ expect(await u.call("read_node_rules_file", { alias: SESSION_ALIAS })).toMatchObject({ ok: false, error: "request_in_flight" });
191
+ } finally { await u.close(); }
192
+ }, 20_000);
193
+ });
194
+
195
+ describe("isolation", () => {
196
+ test("same alias in another network neither pulls nor acks; a peer's ack is ignored", async () => {
197
+ await registerSession(asSession, SESSION_ALIAS, SESSION_RESUME, true);
198
+ const u = await connect(asUser);
199
+ const other = await connect(asOtherNetSameAlias);
200
+ const peer = await connect(asPeer);
201
+ try {
202
+ const enq = await u.call("list_node_skills", { alias: SESSION_ALIAS });
203
+ expect((await other.call("get_rules_file_request", {})).request).toBeNull();
204
+ expect(await other.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: "{}" })).toMatchObject({ ok: true, ignored: "unknown_or_foreign_request" });
205
+ expect(await peer.call("ack_rules_file_request", { request_id: enq.request_id, status: "done", content: "{}" })).toMatchObject({ ok: true, ignored: "unknown_or_foreign_request" });
206
+ expect(await u.call("get_rules_file_result", { request_id: enq.request_id })).toMatchObject({ status: "pending" });
207
+ } finally { await u.close(); await other.close(); await peer.close(); }
208
+ }, 20_000);
209
+ });
@@ -0,0 +1,149 @@
1
+ // 节点令牌不继承签发者的账号权限与 Hub 管理员身份。
2
+ //
3
+ // 生产上几乎所有节点令牌(ntok_)都由管理员签发,resolveToken 把它解析成那个管理员用户。
4
+ // 以前一部分端点直接看 `resolved.user.role === "admin"` 或把令牌当成用户本人,于是任何一个节点
5
+ // 都能:列全部用户、读服务端日志与审计日志、给签发者再签用户令牌 / 节点令牌、改签发者资料与密码、
6
+ // 建 / 改 / 删网络、管成员与邀请、读别的网络详情、看全部网络的 SSE 拓扑。
7
+ // 这里每一条在修复前(main)都是红的;管理员自己的用户令牌作为正控,照常可用。
8
+
9
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
10
+ import { mkdtempSync, rmSync } from "fs";
11
+ import { join } from "path";
12
+ import { tmpdir } from "os";
13
+ import { register, createNetwork, createNetworkTokenForNode } from "./auth.js";
14
+ import { db } from "./db.js";
15
+
16
+ const DIR = mkdtempSync(join(tmpdir(), "anet-ntok-priv-"));
17
+ let BASE = "";
18
+ let hub: any = null;
19
+ const PW = "NtokPrivPassw0rd!";
20
+ let adminToken = "", adminId = "", NET = "", OTHER_NET = "", ntok = "";
21
+
22
+ const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
23
+ async function call(token: string, method: string, path: string, body?: unknown) {
24
+ const res = await fetch(`${BASE}${path}`, {
25
+ method,
26
+ headers: { ...auth(token), ...(body === undefined ? {} : { "Content-Type": "application/json" }) },
27
+ body: body === undefined ? undefined : JSON.stringify(body),
28
+ });
29
+ return { status: res.status, body: await res.json().catch(() => null) as any };
30
+ }
31
+
32
+ beforeAll(async () => {
33
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
34
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
35
+ process.env.HOST = "127.0.0.1";
36
+ const admin = register(`ntok_admin_${Date.now()}`, PW);
37
+ adminToken = admin.token!; adminId = admin.user!.user_id; NET = admin.network_id!;
38
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [adminId]);
39
+ const other = createNetwork(adminId, "ntok-other");
40
+ OTHER_NET = (other as any).network_id;
41
+ const minted = createNetworkTokenForNode(adminId, NET, "ntok-node", "node_ntok_priv");
42
+ expect(minted.ok).toBe(true);
43
+ ntok = minted.token!;
44
+ register(`ntok_someone_${Date.now()}`, PW);
45
+ const mod: any = await import("./server.js");
46
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
47
+ BASE = `http://127.0.0.1:${hub.port}`;
48
+ }, 30_000);
49
+
50
+ afterAll(() => {
51
+ try { hub?.stop?.(true); } catch {}
52
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
53
+ });
54
+
55
+ describe("hub-admin powers need a user token", () => {
56
+ test("GET /api/users", async () => {
57
+ expect((await call(ntok, "GET", "/api/users")).status).toBe(403);
58
+ expect((await call(adminToken, "GET", "/api/users")).status).toBe(200);
59
+ });
60
+ test("GET /api/server-logs", async () => {
61
+ expect((await call(ntok, "GET", "/api/server-logs")).status).toBe(403);
62
+ expect((await call(adminToken, "GET", "/api/server-logs")).status).toBe(200);
63
+ });
64
+ test("GET /api/audit-log", async () => {
65
+ expect((await call(ntok, "GET", "/api/audit-log")).status).toBe(403);
66
+ expect((await call(adminToken, "GET", "/api/audit-log")).status).toBe(200);
67
+ });
68
+ test("GET /api/networks/:id of a network the token is not bound to", async () => {
69
+ expect((await call(ntok, "GET", `/api/networks/${OTHER_NET}`)).status).toBe(403);
70
+ expect((await call(ntok, "GET", `/api/networks/${NET}`)).status).toBe(200);
71
+ });
72
+ test("GET /api/stats/sse is limited to the bound network", async () => {
73
+ const ctrl = new AbortController();
74
+ const sub = await fetch(`${BASE}/events/some-agent?network_id=${OTHER_NET}`, { headers: auth(adminToken), signal: ctrl.signal }).catch(() => null);
75
+ const r = await call(ntok, "GET", "/api/stats/sse");
76
+ ctrl.abort();
77
+ expect(r.status).toBe(200);
78
+ expect(Object.keys(r.body.sessions).every((k) => k.split(":").includes(NET))).toBe(true);
79
+ expect(JSON.stringify(r.body)).not.toContain(OTHER_NET);
80
+ void sub;
81
+ });
82
+ test("POST /api/admin/users and agent-grants", async () => {
83
+ expect((await call(ntok, "POST", "/api/admin/users", { username: "ntok_made", password: PW })).status).toBe(403);
84
+ expect((await call(ntok, "GET", `/api/networks/${NET}/members/${adminId}/agent-grants`)).status).toBe(403);
85
+ });
86
+ });
87
+
88
+ describe("account and network management need a user token", () => {
89
+ test("token management: cannot list, mint user tokens, mint node tokens or revoke", async () => {
90
+ expect((await call(ntok, "GET", "/api/auth/tokens")).status).toBe(403);
91
+ const minted = await call(ntok, "POST", "/api/auth/tokens", { name: "escalate" });
92
+ expect(minted.status).toBe(403);
93
+ expect(minted.body.token).toBeUndefined();
94
+ expect((await call(ntok, "POST", "/api/auth/node-token", { network_id: OTHER_NET, node_name: "x" })).status).toBe(403);
95
+ expect((await call(ntok, "DELETE", "/api/auth/tokens/tok_whatever")).status).toBe(403);
96
+ expect((await call(adminToken, "GET", "/api/auth/tokens")).status).toBe(200);
97
+ });
98
+ test("cannot edit the owner's profile or password", async () => {
99
+ expect((await call(ntok, "PUT", "/api/auth/me", { display_name: "pwned" })).status).toBe(403);
100
+ expect(db.get<{ display_name: string }>("SELECT display_name FROM users WHERE user_id = ?1", adminId)?.display_name).not.toBe("pwned");
101
+ expect((await call(ntok, "POST", "/api/auth/password", { old_password: PW, new_password: "AnotherPassw0rd!" })).status).toBe(403);
102
+ });
103
+ test("cannot create, rename or delete networks", async () => {
104
+ expect((await call(ntok, "POST", "/api/networks", { name: "ntok-made" })).status).toBe(403);
105
+ expect((await call(ntok, "PUT", `/api/networks/${OTHER_NET}`, { name: "renamed" })).status).toBe(403);
106
+ expect((await call(ntok, "DELETE", `/api/networks/${OTHER_NET}`)).status).toBe(403);
107
+ expect(db.get("SELECT 1 FROM networks WHERE network_id = ?1", OTHER_NET)).toBeTruthy();
108
+ });
109
+ test("cannot manage members, invites or join", async () => {
110
+ expect((await call(ntok, "GET", `/api/networks/${NET}/members`)).status).toBe(403);
111
+ expect((await call(ntok, "POST", `/api/networks/${NET}/members`, { user_id: "u_x" })).status).toBe(403);
112
+ expect((await call(ntok, "POST", `/api/networks/${NET}/invite`, { role: "admin" })).status).toBe(403);
113
+ expect((await call(ntok, "POST", "/api/networks/join", { invite_code: "inv_x" })).status).toBe(403);
114
+ expect((await call(adminToken, "GET", `/api/networks/${NET}/members`)).status).toBe(200);
115
+ });
116
+ test("renames: a node token may only rename ITSELF in its bound network", async () => {
117
+ // 别的网络 / 别的节点 → user_token_required
118
+ expect((await call(ntok, "POST", "/api/node-rename/prepare", { network_id: OTHER_NET, old_alias: "ntok-node", new_alias: "b" })).status).toBe(403);
119
+ db.run(`INSERT INTO sessions (resume_id, alias, status, network_id) VALUES ('r_other_node', 'other-node', 'idle', ?1)`, [NET]);
120
+ expect((await call(ntok, "POST", "/api/node-rename/prepare", { network_id: NET, old_alias: "other-node", new_alias: "b" })).status).toBe(403);
121
+ // 另一个节点(管理员用户令牌)开的事务,本节点不能提交
122
+ const foreign = await call(adminToken, "POST", "/api/node-rename/prepare", { network_id: NET, old_alias: "other-node", new_alias: "other-node-2" });
123
+ expect(foreign.body.ok).toBe(true);
124
+ expect((await call(ntok, "POST", "/api/node-rename/commit", { txn_id: foreign.body.txn_id })).status).toBe(403);
125
+ expect((await call(adminToken, "POST", "/api/node-rename/abort", { txn_id: foreign.body.txn_id })).body.ok).toBe(true);
126
+ // 自己:照常(anet node rename 就是这么用的)
127
+ db.run(`INSERT INTO sessions (resume_id, alias, status, network_id) VALUES ('r_self_node', 'ntok-node', 'idle', ?1)`, [NET]);
128
+ const mine = await call(ntok, "POST", "/api/node-rename/prepare", { network_id: NET, old_alias: "ntok-node", new_alias: "ntok-node-2" });
129
+ expect(mine.body.ok).toBe(true);
130
+ expect((await call(ntok, "POST", "/api/node-rename/abort", { txn_id: mine.body.txn_id })).body.ok).toBe(true);
131
+ });
132
+ });
133
+
134
+ describe("/api/auth/me says what the credential is", () => {
135
+ test("node token: owner user for compatibility, but credential.kind=node and only the bound network", async () => {
136
+ const r = await call(ntok, "GET", "/api/auth/me");
137
+ expect(r.status).toBe(200);
138
+ expect(r.body.credential).toMatchObject({ kind: "node", network_id: NET, node_alias: "ntok-node", acts_as_owner: false });
139
+ expect(r.body.current_network).toBe(NET);
140
+ expect(r.body.networks.map((n: any) => n.network_id)).toEqual([NET]);
141
+ const u = await call(adminToken, "GET", "/api/auth/me");
142
+ expect(u.body.credential).toEqual({ kind: "user" });
143
+ expect(u.body.networks.map((n: any) => n.network_id).sort()).toEqual([NET, OTHER_NET].sort());
144
+ });
145
+ test("node token keeps its own network powers (still a member): status and tasks read fine", async () => {
146
+ expect((await call(ntok, "GET", "/api/status")).status).toBe(200);
147
+ expect((await call(ntok, "GET", "/api/tasks")).status).toBe(200);
148
+ });
149
+ });