@sleep2agi/commhub-server 0.9.0-preview.1 → 0.9.0-preview.101

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 (269) 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 +984 -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 +518 -0
  135. package/src/requirement-due-reminders.ts +369 -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-failures-http.test.ts +233 -0
  191. package/src/scheduled-failures.ts +179 -0
  192. package/src/scheduled-reply-unread-http.test.ts +212 -0
  193. package/src/scheduled-run-terminal.test.ts +268 -0
  194. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  195. package/src/scheduled-stuck-http.test.ts +238 -0
  196. package/src/scheduled-stuck.ts +153 -0
  197. package/src/scheduled-tasks-http.test.ts +638 -0
  198. package/src/scheduled-tasks.ts +650 -0
  199. package/src/send-dedup-request-id.test.ts +229 -0
  200. package/src/send-reply-agent-warning.test.ts +202 -0
  201. package/src/send-reply-alias-optional.test.ts +131 -0
  202. package/src/send-reply-attachments.test.ts +458 -0
  203. package/src/send_dedup.test.ts +54 -0
  204. package/src/send_dedup.ts +52 -6
  205. package/src/serve-error.test.ts +38 -0
  206. package/src/serve-error.ts +27 -0
  207. package/src/server.ts +5038 -0
  208. package/src/shared/external-schedule-contract.ts +60 -0
  209. package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
  210. package/src/shared/probe-host-allowlist.ts +128 -0
  211. package/src/shared/reserved-env-drift.test.ts +43 -0
  212. package/src/shared/reserved-env.test.ts +35 -0
  213. package/src/shared/reserved-env.ts +31 -0
  214. package/src/side-thread-command-transport.test.ts +312 -0
  215. package/src/side-thread-command-transport.ts +318 -0
  216. package/src/side-thread-http-integration.test.ts +233 -0
  217. package/src/side-thread-http.ts +504 -0
  218. package/src/side-thread-production.test.ts +72 -0
  219. package/src/side-thread-production.ts +118 -0
  220. package/src/side-thread.test.ts +1067 -0
  221. package/src/side-thread.ts +2499 -0
  222. package/src/skillhub-http.test.ts +108 -0
  223. package/src/sqlite-close-checkpoint.test.ts +78 -0
  224. package/src/start-node.test.ts +109 -0
  225. package/src/status-alias-filter-http.test.ts +106 -0
  226. package/src/status-alias-resolver-http.test.ts +163 -0
  227. package/src/status-alias-resolver.ts +19 -0
  228. package/src/status-node-id-filter-http.test.ts +161 -0
  229. package/src/status-read-cache-http.test.ts +229 -0
  230. package/src/status-read-cache.ts +122 -0
  231. package/src/stop-delete-node.test.ts +1164 -0
  232. package/src/task-access-http.test.ts +379 -0
  233. package/src/task-access.ts +413 -0
  234. package/src/task-auth-origin-http.test.ts +91 -0
  235. package/src/task-auth-origin.test.ts +24 -0
  236. package/src/task-auth-origin.ts +7 -0
  237. package/src/task-consumption.test.ts +482 -0
  238. package/src/task-diagnostic-http.test.ts +149 -0
  239. package/src/task-diagnostic.test.ts +115 -0
  240. package/src/task-diagnostic.ts +118 -0
  241. package/src/task-expiry-notice-http.test.ts +287 -0
  242. package/src/task-expiry-notice.ts +313 -0
  243. package/src/task-idempotency-mcp.test.ts +142 -0
  244. package/src/task-idempotency.test.ts +60 -0
  245. package/src/task-idempotency.ts +75 -0
  246. package/src/task-lifecycle-watcher.test.ts +204 -0
  247. package/src/task-lifecycle-watcher.ts +49 -0
  248. package/src/task-network-resolution-http.test.ts +141 -0
  249. package/src/task-queue-ahead-http.test.ts +309 -0
  250. package/src/task-queue-ahead-scale.test.ts +211 -0
  251. package/src/task-queue-ahead.ts +186 -0
  252. package/src/task-stale-open-http.test.ts +290 -0
  253. package/src/task-stale-open.ts +103 -0
  254. package/src/tasks-pagination-http.test.ts +141 -0
  255. package/src/tool-audience-http.test.ts +195 -0
  256. package/src/tool-audience.ts +109 -0
  257. package/src/tools-list-json-schema.test.ts +67 -0
  258. package/src/tools.ts +4955 -300
  259. package/src/update-provider.test.ts +234 -0
  260. package/src/upload-early-reject-guard.test.ts +155 -0
  261. package/src/uploads-http.test.ts +97 -15
  262. package/src/uploads.test.ts +66 -0
  263. package/src/uploads.ts +138 -5
  264. package/src/user-inbox-by-agent-http.test.ts +161 -0
  265. package/src/user-inbox-netid-notnull.test.ts +122 -0
  266. package/src/user-inbox-read-http.test.ts +223 -0
  267. package/src/user-inbox-schema.test.ts +54 -0
  268. package/src/vault.test.ts +200 -0
  269. package/src/vault.ts +156 -0
@@ -0,0 +1,331 @@
1
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { db } from "./db.js";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { registerTools } from "./tools.js";
5
+
6
+ // RFC-026 §9.3 D2 / #338 PR3 — ack_create_request HANDLER tests.
7
+ //
8
+ // 通信龙 pre-review caught B1 + B2: my first cut of PR3 added a new daemon
9
+ // ack status `runtime_capability_check_failed` to create-node-daemon.ts but
10
+ // the hub's zod enum only accepted ["started", "failed", "rejected"] — the
11
+ // new status was silently zod-rejected on the hub side and the daemon's
12
+ // `.catch(()=>{})` swallowed the rejection. The PR body also claimed
13
+ // `daemon_capability_lied` audit was written when in fact the action wasn't
14
+ // in the audit enum and no insert ran.
15
+ //
16
+ // This handler-driven suite captures and invokes the registered callback
17
+ // directly. It validates handler branches and DB effects, but deliberately
18
+ // does NOT claim to exercise the MCP SDK's zod transport gate. The
19
+ // complementary in-process transport coverage lives in
20
+ // ack-create-request-transport.test.ts (#344).
21
+
22
+ const NET = "net_ack_t";
23
+ const DAEMON_NODE_ID = "node_ack_daemon";
24
+ const DAEMON_ALIAS = "ack-daemon";
25
+ const DAEMON_USER = "u_ack_owner";
26
+ const DAEMON_TOKEN_ID = "tok_ack_daemon";
27
+ const DAEMON_TOKEN_HASH = "hash_ack_daemon";
28
+
29
+ interface ToolHandler { (args: any, extra?: any): Promise<{ content: Array<{ type: "text"; text: string }> }>; }
30
+ interface AckReply { ok?: boolean; error?: string; status?: string; }
31
+
32
+ function cleanup() {
33
+ try { db.run("DELETE FROM audit_log WHERE network_id = ?1", [NET]); } catch {}
34
+ try { db.run("DELETE FROM node_create_requests WHERE network_id = ?1", [NET]); } catch {}
35
+ try { db.run("DELETE FROM nodes WHERE network_id = ?1", [NET]); } catch {}
36
+ try { db.run("DELETE FROM api_tokens WHERE network_id = ?1", [NET]); } catch {}
37
+ try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET]); } catch {}
38
+ try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET]); } catch {}
39
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [DAEMON_USER]); } catch {}
40
+ }
41
+
42
+ beforeEach(cleanup);
43
+ afterAll(cleanup);
44
+
45
+ function seedDaemonWorld() {
46
+ db.run(
47
+ `INSERT INTO users (user_id, username, password_hash, role, created_at)
48
+ VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
49
+ [DAEMON_USER, DAEMON_USER],
50
+ );
51
+ db.run(
52
+ `INSERT OR REPLACE INTO networks (network_id, network_name, owner_id, created_at)
53
+ VALUES (?1, ?2, ?3, datetime('now'))`,
54
+ [NET, NET, DAEMON_USER],
55
+ );
56
+ db.run(
57
+ `INSERT INTO network_members (user_id, network_id, role, joined_at)
58
+ VALUES (?1, ?2, 'owner', datetime('now'))`,
59
+ [DAEMON_USER, NET],
60
+ );
61
+ // daemon node — must exist for resolveCallerDaemonTokenBound to bind.
62
+ db.run(
63
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at)
64
+ VALUES (?1, ?2, ?3, ?4, datetime('now'), datetime('now'))`,
65
+ [DAEMON_NODE_ID, DAEMON_ALIAS, DAEMON_ALIAS, NET],
66
+ );
67
+ // daemon ntok — bound to the daemon node via name='node:<alias>'.
68
+ db.run(
69
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
70
+ VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
71
+ [DAEMON_TOKEN_ID, DAEMON_USER, NET, `node:${DAEMON_ALIAS}`, DAEMON_TOKEN_HASH],
72
+ );
73
+ }
74
+
75
+ function seedCreateRequest(opts: {
76
+ request_id: string,
77
+ child_token_id?: string,
78
+ status?: "pending" | "delivered" | "succeeded" | "failed",
79
+ child_name?: string,
80
+ runtime?: string,
81
+ }) {
82
+ const childName = opts.child_name ?? `child_${opts.request_id}`;
83
+ if (opts.child_token_id) {
84
+ db.run(
85
+ `INSERT INTO api_tokens (token_id, user_id, network_id, scope, name, token_hash, expires_at, revoked_at)
86
+ VALUES (?1, ?2, ?3, 'network', ?4, ?5, NULL, NULL)`,
87
+ [opts.child_token_id, DAEMON_USER, NET, `node:${childName}`, `hash_${opts.child_token_id}`],
88
+ );
89
+ }
90
+ db.run(
91
+ `INSERT INTO node_create_requests
92
+ (request_id, daemon_node_id, child_name, network_id, runtime, model, flags_json, env_keys, status, child_token_id, created_at, created_by_token)
93
+ VALUES (?1, ?2, ?3, ?4, ?5, 'x', '{}', '[]', ?6, ?7, ?8, ?9)`,
94
+ [
95
+ opts.request_id, DAEMON_NODE_ID, childName, NET,
96
+ opts.runtime ?? "claude-agent-sdk",
97
+ opts.status ?? "delivered",
98
+ opts.child_token_id ?? null,
99
+ Date.now(),
100
+ DAEMON_TOKEN_ID,
101
+ ],
102
+ );
103
+ }
104
+
105
+ /** Build a server with registerTools wired as if the daemon's ntok bound. */
106
+ function buildAckHandler(): ToolHandler {
107
+ const server = new McpServer({ name: "test", version: "0" }) as any;
108
+ const tools: Record<string, ToolHandler> = {};
109
+ const origTool = server.tool.bind(server);
110
+ server.tool = (name: string, _desc: string, _schema: any, handler: ToolHandler) => {
111
+ tools[name] = handler;
112
+ return origTool(name, _desc, _schema, handler);
113
+ };
114
+ const origRegisterTool = server.registerTool?.bind(server);
115
+ if (origRegisterTool) {
116
+ server.registerTool = (name: string, _cfg: any, handler: ToolHandler) => {
117
+ tools[name] = handler;
118
+ return origRegisterTool(name, _cfg, handler);
119
+ };
120
+ }
121
+ registerTools(
122
+ server,
123
+ /* clientIP */ undefined,
124
+ /* enforceNetworkId */ NET,
125
+ /* enforceUserId */ DAEMON_USER,
126
+ /* callerAlias */ DAEMON_ALIAS,
127
+ /* callerTokenIsNetwork */ true,
128
+ /* callerTokenId */ DAEMON_TOKEN_ID,
129
+ );
130
+ const h = tools["ack_create_request"];
131
+ if (!h) throw new Error("ack_create_request tool not registered");
132
+ return h;
133
+ }
134
+
135
+ async function callAck(handler: ToolHandler, args: any): Promise<AckReply> {
136
+ const r = await handler(args);
137
+ return JSON.parse(r.content[0].text) as AckReply;
138
+ }
139
+
140
+ function readRequest(request_id: string) {
141
+ return db.get<{ status: string; error: string | null; acked_at: number | null }>(
142
+ `SELECT status, error, acked_at FROM node_create_requests WHERE request_id = ?1`,
143
+ request_id,
144
+ );
145
+ }
146
+
147
+ function readToken(token_id: string) {
148
+ return db.get<{ revoked_at: string | null }>(
149
+ `SELECT revoked_at FROM api_tokens WHERE token_id = ?1`,
150
+ token_id,
151
+ );
152
+ }
153
+
154
+ function readAuditRows(action: string, target_id: string) {
155
+ return db.all<{ action: string; network_id: string; target_id: string; detail: string }>(
156
+ `SELECT action, network_id, target_id, detail FROM audit_log
157
+ WHERE action = ?1 AND target_id = ?2 ORDER BY id DESC`,
158
+ [action, target_id],
159
+ );
160
+ }
161
+
162
+ describe("ack_create_request HANDLER — B1 schema accepts runtime_capability_check_failed", () => {
163
+ test("schema accepts runtime_capability_check_failed + runtime field (no zod reject)", async () => {
164
+ seedDaemonWorld();
165
+ const reqId = "cr_ack_b1_accept";
166
+ seedCreateRequest({ request_id: reqId });
167
+ const handler = buildAckHandler();
168
+ const reply = await callAck(handler, {
169
+ request_id: reqId,
170
+ status: "runtime_capability_check_failed",
171
+ error: "child died within 5000ms post-spawn",
172
+ runtime: "codex-sdk",
173
+ });
174
+ // PR3 v1 BUG: hub zod enum rejected this status → reply.ok=false /
175
+ // tool would even throw before getting here. Now must accept.
176
+ expect(reply.ok).toBe(true);
177
+ expect(reply.status).toBe("runtime_capability_check_failed");
178
+ });
179
+
180
+ test("request flips to terminal runtime_capability_check_failed status", async () => {
181
+ seedDaemonWorld();
182
+ const reqId = "cr_ack_b1_terminal";
183
+ seedCreateRequest({ request_id: reqId, status: "delivered" });
184
+ const handler = buildAckHandler();
185
+ await callAck(handler, {
186
+ request_id: reqId,
187
+ status: "runtime_capability_check_failed",
188
+ error: "boom",
189
+ runtime: "codex-sdk",
190
+ });
191
+ const row = readRequest(reqId);
192
+ expect(row?.status).toBe("runtime_capability_check_failed");
193
+ expect(row?.error).toBe("boom");
194
+ expect(row?.acked_at).toBeGreaterThan(0);
195
+ });
196
+
197
+ test("child ntok is revoked on capability-check-failed (same teardown as 'failed')", async () => {
198
+ seedDaemonWorld();
199
+ const reqId = "cr_ack_b1_revoke";
200
+ const childTok = "tok_ack_child_b1";
201
+ seedCreateRequest({ request_id: reqId, child_token_id: childTok });
202
+ expect(readToken(childTok)?.revoked_at).toBeNull();
203
+ const handler = buildAckHandler();
204
+ await callAck(handler, {
205
+ request_id: reqId,
206
+ status: "runtime_capability_check_failed",
207
+ runtime: "codex-sdk",
208
+ });
209
+ expect(readToken(childTok)?.revoked_at).not.toBeNull();
210
+ });
211
+ });
212
+
213
+ describe("ack_create_request HANDLER — B2 audit_log daemon_capability_lied", () => {
214
+ test("writes daemon_capability_lied audit row with runtime + daemon + error in detail", async () => {
215
+ seedDaemonWorld();
216
+ const reqId = "cr_ack_b2_audit";
217
+ seedCreateRequest({ request_id: reqId });
218
+ const handler = buildAckHandler();
219
+ await callAck(handler, {
220
+ request_id: reqId,
221
+ status: "runtime_capability_check_failed",
222
+ error: "OPENAI_API_KEY unset",
223
+ runtime: "codex-sdk",
224
+ });
225
+ const rows = readAuditRows("daemon_capability_lied", reqId);
226
+ // PR3 v1 BUG: action wasn't in auditCreateNode enum → 0 rows + no
227
+ // surface for ops to spot lying daemons. Now must write exactly 1.
228
+ expect(rows.length).toBe(1);
229
+ expect(rows[0].network_id).toBe(NET);
230
+ const detail = JSON.parse(rows[0].detail);
231
+ expect(detail.runtime).toBe("codex-sdk");
232
+ expect(detail.daemon_node_id).toBe(DAEMON_NODE_ID);
233
+ expect(detail.error).toBe("OPENAI_API_KEY unset");
234
+ expect(typeof detail.acked_at).toBe("number");
235
+ });
236
+
237
+ test("does NOT write daemon_capability_lied on generic 'failed' ack (status discriminates)", async () => {
238
+ seedDaemonWorld();
239
+ const reqId = "cr_ack_b2_no_audit_on_failed";
240
+ seedCreateRequest({ request_id: reqId });
241
+ const handler = buildAckHandler();
242
+ await callAck(handler, {
243
+ request_id: reqId,
244
+ status: "failed",
245
+ error: "child died immediately after spawn",
246
+ });
247
+ const rows = readAuditRows("daemon_capability_lied", reqId);
248
+ expect(rows.length).toBe(0);
249
+ });
250
+
251
+ test("does NOT write daemon_capability_lied on 'started' (happy path silence)", async () => {
252
+ seedDaemonWorld();
253
+ const reqId = "cr_ack_b2_no_audit_on_started";
254
+ seedCreateRequest({ request_id: reqId });
255
+ const handler = buildAckHandler();
256
+ await callAck(handler, { request_id: reqId, status: "started", child_pid: 12345 });
257
+ const rows = readAuditRows("daemon_capability_lied", reqId);
258
+ expect(rows.length).toBe(0);
259
+ });
260
+
261
+ test("runtime field nullable if daemon omitted it (older daemon back-compat)", async () => {
262
+ // Older daemon (preview.10 and earlier) wouldn't send `runtime`.
263
+ // The hub must still accept + audit, just with runtime=null.
264
+ seedDaemonWorld();
265
+ const reqId = "cr_ack_b2_nullable_runtime";
266
+ seedCreateRequest({ request_id: reqId });
267
+ const handler = buildAckHandler();
268
+ const reply = await callAck(handler, {
269
+ request_id: reqId,
270
+ status: "runtime_capability_check_failed",
271
+ error: "child died",
272
+ });
273
+ expect(reply.ok).toBe(true);
274
+ const rows = readAuditRows("daemon_capability_lied", reqId);
275
+ expect(rows.length).toBe(1);
276
+ const detail = JSON.parse(rows[0].detail);
277
+ expect(detail.runtime).toBeNull();
278
+ });
279
+
280
+ test("error string is truncated to 500 chars in audit detail (defense vs giant payload)", async () => {
281
+ seedDaemonWorld();
282
+ const reqId = "cr_ack_b2_trunc";
283
+ seedCreateRequest({ request_id: reqId });
284
+ const handler = buildAckHandler();
285
+ const giant = "x".repeat(900);
286
+ await callAck(handler, {
287
+ request_id: reqId,
288
+ status: "runtime_capability_check_failed",
289
+ error: giant,
290
+ runtime: "codex-sdk",
291
+ });
292
+ const rows = readAuditRows("daemon_capability_lied", reqId);
293
+ expect(rows.length).toBe(1);
294
+ const detail = JSON.parse(rows[0].detail);
295
+ expect(detail.error.length).toBe(500);
296
+ });
297
+ });
298
+
299
+ describe("ack_create_request HANDLER — pre-existing happy-path stays green (regression)", () => {
300
+ test("status='started' still flips acked_at without terminal teardown", async () => {
301
+ seedDaemonWorld();
302
+ const reqId = "cr_ack_started_regression";
303
+ const childTok = "tok_ack_child_started";
304
+ seedCreateRequest({ request_id: reqId, child_token_id: childTok });
305
+ const handler = buildAckHandler();
306
+ const reply = await callAck(handler, { request_id: reqId, status: "started", child_pid: 7777 });
307
+ expect(reply.ok).toBe(true);
308
+ expect(reply.status).toBe("awaiting_register");
309
+ const row = readRequest(reqId);
310
+ // 'started' doesn't flip to terminal; status stays 'delivered'.
311
+ expect(row?.status).toBe("delivered");
312
+ expect(row?.acked_at).toBeGreaterThan(0);
313
+ // child ntok is NOT revoked on 'started'.
314
+ expect(readToken(childTok)?.revoked_at).toBeNull();
315
+ });
316
+
317
+ test("status='failed' still revokes child + flips to failed (no audit_log daemon_capability_lied)", async () => {
318
+ seedDaemonWorld();
319
+ const reqId = "cr_ack_failed_regression";
320
+ const childTok = "tok_ack_child_failed";
321
+ seedCreateRequest({ request_id: reqId, child_token_id: childTok });
322
+ const handler = buildAckHandler();
323
+ const reply = await callAck(handler, { request_id: reqId, status: "failed", error: "fork failed" });
324
+ expect(reply.ok).toBe(true);
325
+ const row = readRequest(reqId);
326
+ expect(row?.status).toBe("failed");
327
+ expect(row?.error).toBe("fork failed");
328
+ expect(readToken(childTok)?.revoked_at).not.toBeNull();
329
+ expect(readAuditRows("daemon_capability_lied", reqId).length).toBe(0);
330
+ });
331
+ });
@@ -0,0 +1,228 @@
1
+ // Hub 管理员删别人的**空**网络(清理旧测试账号留下的个人网络)。
2
+ // 真 HTTP:走 DELETE /api/networks/:id,库和上传目录都是本文件私有的临时目录。
3
+ // bun test server/src/admin-delete-network-http.test.ts
4
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
5
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+
9
+ const PRIVATE_DIR = mkdtempSync(join(tmpdir(), "anet-admin-delete-network-"));
10
+ process.env.COMMHUB_DB ||= join(PRIVATE_DIR, "hub.db");
11
+ process.env.COMMHUB_UPLOADS_DIR = join(PRIVATE_DIR, "uploads");
12
+
13
+ let server: ReturnType<typeof Bun.serve>;
14
+ let base = "";
15
+ let db: typeof import("./db.js").db;
16
+ let auth: typeof import("./auth.js");
17
+ let adminToken = "";
18
+ let adminUserId = "";
19
+ let adminNetworkId = "";
20
+ let memberToken = "";
21
+ let memberUserId = "";
22
+ let memberNodeToken = "";
23
+ const stamp = Date.now();
24
+
25
+ function del(networkId: string, token: string) {
26
+ return fetch(`${base}/api/networks/${networkId}`, { method: "DELETE", headers: { Authorization: `Bearer ${token}` } });
27
+ }
28
+
29
+ function memberNetwork(name: string): string {
30
+ const created = auth.createNetwork(memberUserId, `${name}-${stamp}`);
31
+ expect(created.ok).toBe(true);
32
+ return created.network_id!;
33
+ }
34
+
35
+ function rowCount(table: string, networkId: string): number {
36
+ return db.get<{ cnt: number }>(`SELECT COUNT(*) AS cnt FROM ${table} WHERE network_id = ?1`, networkId)!.cnt;
37
+ }
38
+
39
+ // 同 admin-networks-http.test.ts:register() 跑 KDF + bootServer,给 30s 而不是 bun 默认 5s。
40
+ beforeAll(async () => {
41
+ ({ db } = await import("./db.js"));
42
+ auth = await import("./auth.js");
43
+
44
+ // 第一个注册的是 Hub 管理员;第二个是普通用户,不在管理员的网络里。
45
+ const admin = auth.register(`admin_delnet_${stamp}`, "AdminDelNet-Strong-1!", undefined, "admin");
46
+ expect(admin.ok).toBe(true);
47
+ adminToken = admin.token!;
48
+ adminNetworkId = admin.network_id!;
49
+ adminUserId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", `admin_delnet_${stamp}`)!.user_id;
50
+
51
+ const member = auth.register(`member_delnet_${stamp}`, "MemberDelNet-Strong-1!", undefined, "member");
52
+ expect(member.ok).toBe(true);
53
+ memberToken = member.token!;
54
+ memberUserId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", `member_delnet_${stamp}`)!.user_id;
55
+ expect(db.get<{ role: string }>("SELECT role FROM users WHERE user_id = ?1", adminUserId)!.role).toBe("admin");
56
+ expect(db.get<{ role: string }>("SELECT role FROM users WHERE user_id = ?1", memberUserId)!.role).not.toBe("admin");
57
+ // free 档只能拥有 2 个网络;本文件要给它建七八个。plan 只影响配额,不影响任何权限判定。
58
+ db.run("UPDATE users SET plan = 'pro' WHERE user_id = ?1", [memberUserId]);
59
+
60
+ const minted = auth.createNetworkTokenForNode(memberUserId, member.network_id!, "delnet-node");
61
+ expect(minted.ok).toBe(true);
62
+ memberNodeToken = minted.token!;
63
+
64
+ const mod = await import("./server.js");
65
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
66
+ base = `http://127.0.0.1:${server.port}`;
67
+ }, 30_000);
68
+
69
+ afterAll(() => {
70
+ try { server?.stop(true); } catch {}
71
+ try { rmSync(PRIVATE_DIR, { recursive: true, force: true }); } catch {}
72
+ });
73
+
74
+ describe("DELETE /api/networks/:id — hub admin override for empty foreign networks", () => {
75
+ test("admin deletes an empty foreign network: 200, network + members + scoped tokens gone, audit has admin_override", async () => {
76
+ const netId = memberNetwork("empty");
77
+ const minted = auth.createNetworkTokenForNode(memberUserId, netId, "leftover-node");
78
+ expect(minted.ok).toBe(true);
79
+ expect(rowCount("network_members", netId)).toBe(1);
80
+ expect(rowCount("api_tokens", netId)).toBe(1);
81
+
82
+ const res = await del(netId, adminToken);
83
+ const body = await res.json() as any;
84
+ expect(res.status).toBe(200);
85
+ expect(body.ok).toBe(true);
86
+ expect(body.admin_override).toBe(true);
87
+
88
+ expect(db.get("SELECT 1 AS x FROM networks WHERE network_id = ?1", netId)).toBeFalsy();
89
+ expect(rowCount("network_members", netId)).toBe(0);
90
+ expect(rowCount("api_tokens", netId)).toBe(0);
91
+ // 被删网络的节点令牌不再能解析。
92
+ expect(auth.resolveToken(minted.token!)).toBeFalsy();
93
+
94
+ const audit = db.get<{ user_id: string; detail: string; network_id: string }>(
95
+ "SELECT user_id, detail, network_id FROM audit_log WHERE action = 'network_deleted' AND target_id = ?1", netId);
96
+ expect(audit).toBeTruthy();
97
+ expect(audit!.user_id).toBe(adminUserId);
98
+ const detail = JSON.parse(audit!.detail);
99
+ expect(detail.admin_override).toBe(true);
100
+ expect(detail.owner_id).toBe(memberUserId);
101
+ });
102
+
103
+ test("admin is refused with 409 and counts when the network has a node and a session", async () => {
104
+ const netId = memberNetwork("busy");
105
+ db.run("INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, ?2, ?2, ?3, datetime('now'), datetime('now'))",
106
+ [`node-${stamp}`, `busy-node-${stamp}`, netId]);
107
+ db.run("INSERT INTO sessions (resume_id, alias, network_id) VALUES (?1, ?2, ?3)", [`resume-${stamp}`, `busy-node-${stamp}`, netId]);
108
+
109
+ const res = await del(netId, adminToken);
110
+ const body = await res.json() as any;
111
+ expect(res.status).toBe(409);
112
+ expect(body.ok).toBe(false);
113
+ expect(body.counts).toEqual({ nodes: 1, sessions: 1 });
114
+ expect(body.error).toContain("nodes=1");
115
+ expect(db.get("SELECT 1 AS x FROM networks WHERE network_id = ?1", netId)).toBeTruthy();
116
+ expect(rowCount("network_members", netId)).toBe(1);
117
+ });
118
+
119
+ test("admin is refused when the only content left is an uploaded file (disk index, not the DB)", async () => {
120
+ const netId = memberNetwork("files");
121
+ const indexDir = join(process.env.COMMHUB_UPLOADS_DIR!, ".index");
122
+ mkdirSync(indexDir, { recursive: true });
123
+ writeFileSync(join(indexDir, `f${stamp}.json`), JSON.stringify({ file_id: `f${stamp}`, network_id: netId }));
124
+
125
+ const res = await del(netId, adminToken);
126
+ const body = await res.json() as any;
127
+ expect(res.status).toBe(409);
128
+ expect(body.counts).toEqual({ files: 1 });
129
+ expect(db.get("SELECT 1 AS x FROM networks WHERE network_id = ?1", netId)).toBeTruthy();
130
+ });
131
+
132
+ test("admin cannot delete the 'default' network even when it is empty", async () => {
133
+ db.run("INSERT INTO networks (network_id, network_name, owner_id) VALUES ('default', ?1, ?2)", [`legacy-default-${stamp}`, memberUserId]);
134
+ const res = await del("default", adminToken);
135
+ expect(res.status).toBe(409);
136
+ expect(((await res.json()) as any).error).toContain("default");
137
+ expect(db.get("SELECT 1 AS x FROM networks WHERE network_id = 'default'")).toBeTruthy();
138
+ });
139
+
140
+ test("a non-admin user cannot delete someone else's network", async () => {
141
+ const res = await del(adminNetworkId, memberToken);
142
+ expect(res.status).toBe(400);
143
+ expect(((await res.json()) as any).error).toBe("not your network");
144
+ expect(db.get("SELECT 1 AS x FROM networks WHERE network_id = ?1", adminNetworkId)).toBeTruthy();
145
+ });
146
+
147
+ test("a node token is refused with user_token_required", async () => {
148
+ const netId = memberNetwork("ntok");
149
+ const res = await del(netId, memberNodeToken);
150
+ expect(res.status).toBe(403);
151
+ expect(((await res.json()) as any).error).toBe("user_token_required");
152
+ expect(db.get("SELECT 1 AS x FROM networks WHERE network_id = ?1", netId)).toBeTruthy();
153
+ });
154
+
155
+ test("owner path is unchanged: owner deletes own network (even with a node row), sessions still block it", async () => {
156
+ const withNode = memberNetwork("owner-node");
157
+ db.run("INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, ?2, ?2, ?3, datetime('now'), datetime('now'))",
158
+ [`owner-node-${stamp}`, `owner-node-${stamp}`, withNode]);
159
+ const ok = await del(withNode, memberToken);
160
+ expect(ok.status).toBe(200);
161
+ expect(await ok.json()).toEqual({ ok: true });
162
+
163
+ const withSession = memberNetwork("owner-session");
164
+ db.run("INSERT INTO sessions (resume_id, alias, network_id) VALUES (?1, ?2, ?3)", [`owner-resume-${stamp}`, `owner-s-${stamp}`, withSession]);
165
+ const blocked = await del(withSession, memberToken);
166
+ expect(blocked.status).toBe(400);
167
+ expect(((await blocked.json()) as any).error).toContain("active session");
168
+ });
169
+
170
+ test("admin deleting its OWN network goes through the owner path (no admin_override)", async () => {
171
+ const created = auth.createNetwork(adminUserId, `admin-own-${stamp}`);
172
+ expect(created.ok).toBe(true);
173
+ const res = await del(created.network_id!, adminToken);
174
+ expect(res.status).toBe(200);
175
+ expect(await res.json()).toEqual({ ok: true });
176
+ });
177
+
178
+ // #2144 —— Agent 分组(#2131)的三张表。
179
+ function addGroup(netId: string, tag: string): string {
180
+ const groupId = `grp-${tag}-${stamp}`;
181
+ db.run("INSERT INTO agent_groups (group_id, network_id, name) VALUES (?1, ?2, ?3)", [groupId, netId, `g-${tag}`]);
182
+ db.run("INSERT INTO agent_group_members (group_id, node_id) VALUES (?1, ?2)", [groupId, `node-${tag}-${stamp}`]);
183
+ db.run("INSERT INTO network_member_group_grants (network_id, user_id, group_id) VALUES (?1, ?2, ?3)", [netId, memberUserId, groupId]);
184
+ return groupId;
185
+ }
186
+ function groupRows(netId: string, groupId: string) {
187
+ return {
188
+ agent_groups: rowCount("agent_groups", netId),
189
+ agent_group_members: db.get<{ cnt: number }>("SELECT COUNT(*) AS cnt FROM agent_group_members WHERE group_id = ?1", groupId)!.cnt,
190
+ network_member_group_grants: rowCount("network_member_group_grants", netId),
191
+ };
192
+ }
193
+
194
+ test("#2144 admin is refused with 409 counts.agent_groups when the network has an agent group", async () => {
195
+ const netId = memberNetwork("grouped");
196
+ const groupId = addGroup(netId, "admin-blocked");
197
+ const res = await del(netId, adminToken);
198
+ const body = await res.json() as any;
199
+ expect(res.status).toBe(409);
200
+ expect(body.counts).toEqual({ agent_groups: 1 });
201
+ expect(db.get("SELECT 1 AS x FROM networks WHERE network_id = ?1", netId)).toBeTruthy();
202
+ expect(groupRows(netId, groupId)).toEqual({ agent_groups: 1, agent_group_members: 1, network_member_group_grants: 1 });
203
+ });
204
+
205
+ test("#2144 admin delete of an otherwise-empty network cleans orphan group grants", async () => {
206
+ const netId = memberNetwork("orphan-grant");
207
+ db.run("INSERT INTO network_member_group_grants (network_id, user_id, group_id) VALUES (?1, ?2, ?3)", [netId, memberUserId, `grp-gone-${stamp}`]);
208
+ const res = await del(netId, adminToken);
209
+ const body = await res.json() as any;
210
+ expect(res.status).toBe(200);
211
+ expect(body.cleaned.network_member_group_grants).toBe(1);
212
+ expect(rowCount("network_member_group_grants", netId)).toBe(0);
213
+ });
214
+
215
+ test("#2144 owner delete removes the network's groups, group members and group grants, and leaves other networks' groups", async () => {
216
+ const netId = memberNetwork("owner-grouped");
217
+ const groupId = addGroup(netId, "owner");
218
+ const otherNet = memberNetwork("owner-grouped-other");
219
+ const otherGroup = addGroup(otherNet, "other");
220
+
221
+ const res = await del(netId, memberToken);
222
+ expect(res.status).toBe(200);
223
+ expect(await res.json()).toEqual({ ok: true });
224
+ expect(db.get("SELECT 1 AS x FROM networks WHERE network_id = ?1", netId)).toBeFalsy();
225
+ expect(groupRows(netId, groupId)).toEqual({ agent_groups: 0, agent_group_members: 0, network_member_group_grants: 0 });
226
+ expect(groupRows(otherNet, otherGroup)).toEqual({ agent_groups: 1, agent_group_members: 1, network_member_group_grants: 1 });
227
+ });
228
+ });
@@ -0,0 +1,88 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { NETWORK_REST_COLUMNS } from "./rest-projections.js";
6
+
7
+ const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-admin-networks-"));
8
+ process.env.COMMHUB_DB ||= join(PRIVATE_DB_DIR, "hub.db");
9
+
10
+ let server: ReturnType<typeof Bun.serve>;
11
+ let base = "";
12
+ let adminToken = "";
13
+ let memberToken = "";
14
+ let nodeToken = "";
15
+ let adminNetworkId = "";
16
+ let memberNetworkId = "";
17
+
18
+ async function listNetworks(token: string): Promise<any[]> {
19
+ const response = await fetch(`${base}/api/networks`, {
20
+ headers: { Authorization: `Bearer ${token}` },
21
+ });
22
+ expect(response.status).toBe(200);
23
+ const body = await response.json() as any;
24
+ expect(body.ok).toBe(true);
25
+ return body.networks;
26
+ }
27
+
28
+ // 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
29
+ // 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
30
+ // (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
31
+ // 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
32
+ beforeAll(async () => {
33
+ const { db } = await import("./db.js");
34
+ const { createNetworkTokenForNode, register } = await import("./auth.js");
35
+
36
+ // The first registered user is the global Hub admin. The second user owns a
37
+ // separate network and is intentionally not a member of the admin's network.
38
+ const admin = register(`admin_networks_${Date.now()}`, "AdminNetworks-Strong-1!", undefined, "admin");
39
+ expect(admin.ok).toBe(true);
40
+ adminToken = admin.token!;
41
+ adminNetworkId = admin.network_id!;
42
+
43
+ const member = register(`member_networks_${Date.now()}`, "MemberNetworks-Strong-1!", undefined, "member");
44
+ expect(member.ok).toBe(true);
45
+ memberToken = member.token!;
46
+ memberNetworkId = member.network_id!;
47
+
48
+ const memberUserId = db.get<{ user_id: string }>(
49
+ "SELECT user_id FROM users WHERE username LIKE 'member_networks_%' ORDER BY created_at DESC LIMIT 1",
50
+ )!.user_id;
51
+ const minted = createNetworkTokenForNode(memberUserId, memberNetworkId, "admin-networks-node");
52
+ expect(minted.ok).toBe(true);
53
+ nodeToken = minted.token!;
54
+
55
+ const mod = await import("./server.js");
56
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
57
+ base = `http://127.0.0.1:${server.port}`;
58
+ }, 30_000);
59
+
60
+ afterAll(() => {
61
+ try { server?.stop(true); } catch {}
62
+ try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
63
+ });
64
+
65
+ describe("GET /api/networks global-admin visibility (#94)", () => {
66
+ test("global admin utok sees every network without losing public shape", async () => {
67
+ const rows = await listNetworks(adminToken);
68
+ expect(new Set(rows.map((row) => row.network_id))).toEqual(new Set([adminNetworkId, memberNetworkId]));
69
+
70
+ const own = rows.find((row) => row.network_id === adminNetworkId);
71
+ const foreign = rows.find((row) => row.network_id === memberNetworkId);
72
+ expect(own.member_role).toBe("owner");
73
+ expect(foreign.member_role).toBe("admin");
74
+ expect(Object.keys(foreign).sort()).toEqual([...NETWORK_REST_COLUMNS, "member_role", "name"].sort());
75
+ });
76
+
77
+ test("ordinary utok remains limited to member networks", async () => {
78
+ const rows = await listNetworks(memberToken);
79
+ expect(rows.map((row) => row.network_id)).toEqual([memberNetworkId]);
80
+ expect(rows.some((row) => row.network_id === adminNetworkId)).toBe(false);
81
+ });
82
+
83
+ test("network token remains limited to its bound network", async () => {
84
+ const rows = await listNetworks(nodeToken);
85
+ expect(rows.map((row) => row.network_id)).toEqual([memberNetworkId]);
86
+ expect(rows.some((row) => row.network_id === adminNetworkId)).toBe(false);
87
+ });
88
+ });