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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (318) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +99 -11
  3. package/package.json +7 -7
  4. package/src/ack-create-request-transport.test.ts +182 -0
  5. package/src/ack-create-request.test.ts +50 -7
  6. package/src/admin-delete-network-http.test.ts +228 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/adoption-candidates-http.test.ts +137 -0
  9. package/src/adoption-candidates.ts +158 -0
  10. package/src/agent-access-batch.test.ts +210 -0
  11. package/src/agent-access.ts +487 -0
  12. package/src/agent-acl-groups-http.test.ts +247 -0
  13. package/src/agent-acl-http.test.ts +659 -0
  14. package/src/agent-acl-slice3-http.test.ts +267 -0
  15. package/src/agent-groups-perf.test.ts +223 -0
  16. package/src/agent-notice.ts +48 -0
  17. package/src/agent-team-whoami-http.test.ts +98 -0
  18. package/src/agent-teams-http.test.ts +323 -0
  19. package/src/agent-teams.ts +244 -0
  20. package/src/alias-filter.test.ts +72 -0
  21. package/src/alias-filter.ts +42 -0
  22. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  23. package/src/api-host-supervisors-fallback.test.ts +271 -0
  24. package/src/api-host-supervisors-runtime-readiness.test.ts +195 -0
  25. package/src/api-nodes-shape.test.ts +26 -10
  26. package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
  27. package/src/auth-sessions-http.test.ts +250 -0
  28. package/src/auth-validate.test.ts +5 -1
  29. package/src/auth-weak-password-login.test.ts +176 -0
  30. package/src/auth.ts +598 -49
  31. package/src/avatar-validate.test.ts +163 -0
  32. package/src/avatar-validate.ts +103 -0
  33. package/src/binding-generation-http.test.ts +121 -0
  34. package/src/blocked-exit-on-reply.test.ts +122 -0
  35. package/src/boot-side-effect.test.ts +124 -0
  36. package/src/clone-node.test.ts +248 -0
  37. package/src/clone-node.ts +289 -0
  38. package/src/codex-config-flags.ts +77 -0
  39. package/src/codex-fork-contract.ts +23 -0
  40. package/src/codex-fork-migration.test.ts +38 -0
  41. package/src/commhub-bin-args.test.ts +85 -0
  42. package/src/config-apply-sec1.test.ts +107 -0
  43. package/src/config-apply-validate.test.ts +134 -1
  44. package/src/config-apply-validate.ts +111 -5
  45. package/src/create-node-blocked-gate.test.ts +356 -0
  46. package/src/create-node-network-id.test.ts +79 -0
  47. package/src/create-node-tool-schema.test.ts +48 -0
  48. package/src/create-node-unicode-name.test.ts +120 -0
  49. package/src/create-node-validate.test.ts +193 -7
  50. package/src/create-node-validate.ts +227 -15
  51. package/src/create-node-workdir.test.ts +289 -0
  52. package/src/create-node.ts +77 -23
  53. package/src/daemon-provider-snapshots.test.ts +421 -0
  54. package/src/daemon-provider-snapshots.ts +327 -0
  55. package/src/daemon-token-owner-http.test.ts +236 -0
  56. package/src/dashboard-slash-routing-http.test.ts +107 -0
  57. package/src/db-adapter-guard.test.ts +148 -0
  58. package/src/db-adapter-translate.test.ts +128 -0
  59. package/src/db-adapter.ts +323 -87
  60. package/src/db-timestamp.test.ts +38 -0
  61. package/src/db-timestamp.ts +15 -0
  62. package/src/db.ts +933 -36
  63. package/src/delivered-without-push.test.ts +140 -0
  64. package/src/department-groups-http.test.ts +209 -0
  65. package/src/department-groups-sync-http.test.ts +328 -0
  66. package/src/department-groups.ts +434 -0
  67. package/src/department-heads-http.test.ts +330 -0
  68. package/src/department-heads.ts +226 -0
  69. package/src/departments-http.test.ts +185 -0
  70. package/src/departments.ts +260 -0
  71. package/src/desktop-message-seam.test.ts +150 -0
  72. package/src/desktop-message.test.ts +368 -0
  73. package/src/dm-file-scope-http.test.ts +178 -0
  74. package/src/ext-token-shared.test.ts +60 -0
  75. package/src/ext-validation-regression.test.ts +131 -0
  76. package/src/external-schedule-edits-http.test.ts +321 -0
  77. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  78. package/src/external-schedule-edits.ts +327 -0
  79. package/src/file-download-authz-dev-open.test.ts +135 -0
  80. package/src/file-download-authz.test.ts +404 -0
  81. package/src/file-download-date-bucket.test.ts +436 -0
  82. package/src/file-network-scope-dev-open.test.ts +97 -0
  83. package/src/file-network-scope.test.ts +732 -0
  84. package/src/group-membership-events-http.test.ts +264 -0
  85. package/src/group-messages-http.test.ts +482 -0
  86. package/src/group-messages.ts +297 -0
  87. package/src/health-redaction.test.ts +642 -0
  88. package/src/http-gzip.test.ts +94 -0
  89. package/src/http-gzip.ts +112 -0
  90. package/src/hub-provider-config.test.ts +139 -0
  91. package/src/hub-provider-config.ts +105 -0
  92. package/src/hub-timestamp.test.ts +38 -0
  93. package/src/hub-timestamp.ts +27 -0
  94. package/src/human-dm.ts +215 -0
  95. package/src/inbox-caller.ts +49 -0
  96. package/src/inbox-count-sse.test.ts +310 -0
  97. package/src/inbox-count.ts +29 -0
  98. package/src/index.ts +15 -2362
  99. package/src/lifecycle-guard.ts +1 -1
  100. package/src/mcp-context-cost-http.test.ts +123 -0
  101. package/src/mcp-projects-events-http.test.ts +162 -0
  102. package/src/mcp-route-label-http.test.ts +120 -0
  103. package/src/mcp-write-network-resolution.test.ts +432 -0
  104. package/src/member-audit-network-id-http.test.ts +93 -0
  105. package/src/member-presence-http.test.ts +210 -0
  106. package/src/messages-ack-by-agent-http.test.ts +142 -0
  107. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  108. package/src/migrate-sqlite-to-pg.ts +263 -0
  109. package/src/model-auth-notify-http.test.ts +181 -0
  110. package/src/model-auth-notify.ts +116 -0
  111. package/src/network-name-http.test.ts +75 -0
  112. package/src/network-scope.ts +276 -0
  113. package/src/network-token-membership-http.test.ts +139 -0
  114. package/src/node-alias-control.ts +12 -0
  115. package/src/node-attrs-validate.ts +88 -0
  116. package/src/node-attrs.test.ts +217 -0
  117. package/src/node-caller-identity-http.test.ts +781 -0
  118. package/src/node-create-request-status-http.test.ts +105 -0
  119. package/src/node-daemon-bindings.test.ts +132 -0
  120. package/src/node-daemon-bindings.ts +130 -0
  121. package/src/node-files-transport.test.ts +344 -0
  122. package/src/node-health-codex-login.test.ts +120 -0
  123. package/src/node-health-dispatch-http.test.ts +279 -0
  124. package/src/node-health-guard.ts +105 -0
  125. package/src/node-health-passthrough.test.ts +115 -0
  126. package/src/node-health-store.ts +101 -0
  127. package/src/node-id-alias.test.ts +190 -0
  128. package/src/node-identity-conflict-http.test.ts +269 -0
  129. package/src/node-identity-conflict.ts +142 -0
  130. package/src/node-identity-mismatch.test.ts +74 -0
  131. package/src/node-lifecycle-controllable.test.ts +64 -0
  132. package/src/node-lifecycle-controllable.ts +53 -0
  133. package/src/node-lifecycle-read-http.test.ts +245 -0
  134. package/src/node-lifecycle-read.ts +121 -0
  135. package/src/node-logs-transport.test.ts +263 -0
  136. package/src/node-permissions-http.test.ts +276 -0
  137. package/src/node-permissions.ts +285 -0
  138. package/src/node-request-retention.test.ts +281 -0
  139. package/src/node-request-retention.ts +120 -0
  140. package/src/node-skills-transport.test.ts +209 -0
  141. package/src/node-token-ownership.ts +51 -0
  142. package/src/node-token-privilege-http.test.ts +158 -0
  143. package/src/observer-avatar-http.test.ts +431 -0
  144. package/src/observer-push.test.ts +252 -0
  145. package/src/opencode-v2-catalog.test.ts +268 -0
  146. package/src/opencode-v2-catalog.ts +352 -0
  147. package/src/package-version-consistency.test.ts +25 -0
  148. package/src/peer-reply-atomic.test.ts +455 -0
  149. package/src/peer-reply-capability.test.ts +70 -0
  150. package/src/pg-worker.ts +87 -0
  151. package/src/project-dir-warn-clear-http.test.ts +132 -0
  152. package/src/project-viewer-can-http.test.ts +114 -0
  153. package/src/push.ts +429 -10
  154. package/src/redact-tokens.test.ts +65 -0
  155. package/src/redact-tokens.ts +40 -0
  156. package/src/rename.ts +22 -2
  157. package/src/reply-target-derivation.test.ts +96 -0
  158. package/src/report-status-handover-capabilities-http.test.ts +124 -0
  159. package/src/report-status-host-schema.test.ts +235 -0
  160. package/src/report-status-resume-id-handover.test.ts +150 -0
  161. package/src/require-explicit-test-db.ts +9 -0
  162. package/src/requirement-due-reminders-http.test.ts +620 -0
  163. package/src/requirement-due-reminders.ts +416 -0
  164. package/src/requirement-events-http.test.ts +268 -0
  165. package/src/requirement-events.test.ts +45 -0
  166. package/src/requirement-events.ts +144 -0
  167. package/src/requirement-notify.ts +165 -0
  168. package/src/requirement-participant-notify-http.test.ts +309 -0
  169. package/src/requirement-tag-ops-http.test.ts +266 -0
  170. package/src/requirement-tags.ts +89 -0
  171. package/src/requirements-abandoned-http.test.ts +236 -0
  172. package/src/requirements-comment-http.test.ts +167 -0
  173. package/src/requirements-completed-at-migration.test.ts +70 -0
  174. package/src/requirements-errors-mcp-http.test.ts +164 -0
  175. package/src/requirements-errors.ts +143 -0
  176. package/src/requirements-etag-http.test.ts +91 -0
  177. package/src/requirements-http.test.ts +709 -0
  178. package/src/requirements-last-event-http.test.ts +349 -0
  179. package/src/requirements-list-mcp-slim-http.test.ts +155 -0
  180. package/src/requirements-list-slim-http.test.ts +319 -0
  181. package/src/requirements-mcp.test.ts +163 -0
  182. package/src/requirements-migrate.ts +259 -0
  183. package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
  184. package/src/requirements-people-http.test.ts +114 -0
  185. package/src/requirements-people-mcp-http.test.ts +225 -0
  186. package/src/requirements-people.ts +127 -0
  187. package/src/requirements-priority-lowest.test.ts +114 -0
  188. package/src/requirements-search-http.test.ts +182 -0
  189. package/src/requirements-search.test.ts +32 -0
  190. package/src/requirements-search.ts +145 -0
  191. package/src/requirements-seq-http.test.ts +193 -0
  192. package/src/requirements-seq-migration.test.ts +63 -0
  193. package/src/requirements-start-date.test.ts +125 -0
  194. package/src/requirements-stats-http.test.ts +288 -0
  195. package/src/requirements-stats.ts +167 -0
  196. package/src/requirements-status-participants-http.test.ts +222 -0
  197. package/src/requirements-upgrade-e2e.test.ts +126 -0
  198. package/src/requirements-upgrade-migration.test.ts +98 -0
  199. package/src/requirements.ts +1806 -0
  200. package/src/resolve-port.test.ts +57 -0
  201. package/src/resolve-port.ts +44 -0
  202. package/src/response-charset.test.ts +130 -0
  203. package/src/rest-explicit-columns-http.test.ts +222 -0
  204. package/src/rest-identity.test.ts +130 -0
  205. package/src/rest-identity.ts +139 -0
  206. package/src/rest-projections.ts +68 -0
  207. package/src/rest-write-network-resolution.test.ts +133 -0
  208. package/src/restricted-files.ts +76 -0
  209. package/src/retention.ts +15 -3
  210. package/src/route-callers-http.test.ts +120 -0
  211. package/src/route-timing.test.ts +211 -0
  212. package/src/route-timing.ts +258 -0
  213. package/src/rules-file-duplicate-alias.test.ts +234 -0
  214. package/src/rules-file-session-target.test.ts +207 -0
  215. package/src/rules-file-transport.test.ts +237 -0
  216. package/src/runtime-label.test.ts +22 -0
  217. package/src/runtime-label.ts +21 -0
  218. package/src/runtime-readiness.ts +90 -0
  219. package/src/schedule-agent-mcp-http.test.ts +381 -0
  220. package/src/schedule-agent.ts +192 -0
  221. package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
  222. package/src/scheduled-failures-http.test.ts +233 -0
  223. package/src/scheduled-failures.ts +179 -0
  224. package/src/scheduled-offline.test.ts +131 -0
  225. package/src/scheduled-offline.ts +66 -0
  226. package/src/scheduled-reply-unread-http.test.ts +212 -0
  227. package/src/scheduled-run-terminal.test.ts +268 -0
  228. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  229. package/src/scheduled-stuck-http.test.ts +238 -0
  230. package/src/scheduled-stuck.ts +153 -0
  231. package/src/scheduled-tasks-http.test.ts +638 -0
  232. package/src/scheduled-tasks.ts +766 -0
  233. package/src/scoped-secrets.ts +124 -0
  234. package/src/send-dedup-request-id.test.ts +229 -0
  235. package/src/send-reply-agent-warning.test.ts +202 -0
  236. package/src/send-reply-alias-optional.test.ts +131 -0
  237. package/src/send-reply-attachments.test.ts +458 -0
  238. package/src/send_dedup.test.ts +54 -0
  239. package/src/send_dedup.ts +52 -6
  240. package/src/serve-error.test.ts +38 -0
  241. package/src/serve-error.ts +27 -0
  242. package/src/server.ts +5257 -0
  243. package/src/session-task-on-dispatch.ts +14 -0
  244. package/src/session-token-ownership-http.test.ts +122 -0
  245. package/src/shared/codex-inventory.ts +41 -0
  246. package/src/shared/daemon-provider-login.ts +31 -0
  247. package/src/shared/daemon-provider-snapshot.ts +52 -0
  248. package/src/shared/external-schedule-contract.ts +60 -0
  249. package/src/shared/node-name.test.ts +62 -0
  250. package/src/shared/node-name.ts +161 -0
  251. package/src/shared/provider-application.ts +29 -0
  252. package/src/shared/provider-collection.ts +14 -0
  253. package/src/shared/provider-initialization.ts +8 -0
  254. package/src/shared/reserved-env-drift.test.ts +10 -0
  255. package/src/shared/scoped-secret-contract.ts +5 -0
  256. package/src/shared/task-content-limit-drift.test.ts +28 -0
  257. package/src/shared/task-content-limit.ts +25 -0
  258. package/src/side-thread-command-transport.test.ts +312 -0
  259. package/src/side-thread-command-transport.ts +318 -0
  260. package/src/side-thread-http-integration.test.ts +233 -0
  261. package/src/side-thread-http.ts +504 -0
  262. package/src/side-thread-production.test.ts +72 -0
  263. package/src/side-thread-production.ts +118 -0
  264. package/src/side-thread.test.ts +1067 -0
  265. package/src/side-thread.ts +2499 -0
  266. package/src/skillhub-http.test.ts +108 -0
  267. package/src/sqlite-close-checkpoint.test.ts +78 -0
  268. package/src/start-node.test.ts +264 -0
  269. package/src/status-alias-filter-http.test.ts +106 -0
  270. package/src/status-alias-resolver-http.test.ts +163 -0
  271. package/src/status-alias-resolver.ts +19 -0
  272. package/src/status-node-id-filter-http.test.ts +161 -0
  273. package/src/status-read-cache-http.test.ts +229 -0
  274. package/src/status-read-cache.ts +122 -0
  275. package/src/stop-delete-node.test.ts +509 -1
  276. package/src/task-access-http.test.ts +402 -0
  277. package/src/task-access.ts +420 -0
  278. package/src/task-auth-origin-http.test.ts +93 -0
  279. package/src/task-auth-origin.test.ts +24 -0
  280. package/src/task-auth-origin.ts +7 -0
  281. package/src/task-consumption.test.ts +482 -0
  282. package/src/task-diagnostic-http.test.ts +149 -0
  283. package/src/task-diagnostic.test.ts +115 -0
  284. package/src/task-diagnostic.ts +118 -0
  285. package/src/task-expiry-notice-http.test.ts +287 -0
  286. package/src/task-expiry-notice.ts +315 -0
  287. package/src/task-idempotency-mcp.test.ts +142 -0
  288. package/src/task-idempotency.test.ts +60 -0
  289. package/src/task-idempotency.ts +75 -0
  290. package/src/task-late-replies.ts +54 -0
  291. package/src/task-late-reply-http.test.ts +152 -0
  292. package/src/task-lifecycle-watcher.test.ts +204 -0
  293. package/src/task-lifecycle-watcher.ts +49 -0
  294. package/src/task-network-resolution-http.test.ts +140 -0
  295. package/src/task-orphan-http.test.ts +287 -0
  296. package/src/task-orphan.ts +119 -0
  297. package/src/task-queue-ahead-http.test.ts +309 -0
  298. package/src/task-queue-ahead-scale.test.ts +236 -0
  299. package/src/task-queue-ahead.ts +186 -0
  300. package/src/task-rejection-log-http.test.ts +160 -0
  301. package/src/task-rejection-log.ts +49 -0
  302. package/src/task-sender-http.test.ts +143 -0
  303. package/src/task-stale-open-http.test.ts +290 -0
  304. package/src/task-stale-open.ts +103 -0
  305. package/src/tasks-pagination-http.test.ts +141 -0
  306. package/src/tool-audience-http.test.ts +220 -0
  307. package/src/tool-audience.ts +118 -0
  308. package/src/tools-list-json-schema.test.ts +67 -0
  309. package/src/tools.ts +3940 -325
  310. package/src/upload-early-reject-guard.test.ts +155 -0
  311. package/src/uploads-http.test.ts +97 -15
  312. package/src/uploads.ts +103 -6
  313. package/src/user-avatar-http.test.ts +74 -0
  314. package/src/user-avatar-migration.test.ts +25 -0
  315. package/src/user-inbox-by-agent-http.test.ts +161 -0
  316. package/src/user-inbox-netid-notnull.test.ts +122 -0
  317. package/src/user-inbox-read-http.test.ts +223 -0
  318. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,185 @@
1
+ // 组织架构(board #419):GET/POST/PATCH/DELETE /api/networks/:id/departments、PUT …/members/:uid/department。
2
+ // HTTP 集成测试(真实 Bun.serve,私有端口,临时库)。钉住:谁能读 / 谁能写、部门规则(同级不重名、不成环、最多 10 层、
3
+ // 只删空部门、负责人必须是成员)、成员所在部门,以及只增不改(/humans、/members 只多一个 department_id)。
4
+
5
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
6
+ import { mkdtempSync, rmSync } from "fs";
7
+ import { join } from "path";
8
+ import { tmpdir } from "os";
9
+ import { register, createNetworkTokenForNode } from "./auth.js";
10
+ import { db } from "./db.js";
11
+
12
+ const DIR = mkdtempSync(join(tmpdir(), "anet-departments-"));
13
+ let BASE = "";
14
+ let hub: any = null;
15
+ const PW = "DepartmentsPassw0rd!x";
16
+ let NET = "", OTHER_NET = "";
17
+ const U: Record<string, { token: string; id: string }> = {};
18
+
19
+ async function send(token: string, method: string, path: string, body?: unknown) {
20
+ const res = await fetch(`${BASE}${path}`, { method, headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
21
+ const text = await res.text();
22
+ let json: any = null;
23
+ try { json = JSON.parse(text); } catch {}
24
+ return { status: res.status, body: json };
25
+ }
26
+ const D = () => `/api/networks/${NET}/departments`;
27
+ const list = async (who = "owner") => (await send(U[who].token, "GET", D())).body;
28
+ const mk = async (name: string, extra: Record<string, unknown> = {}) => {
29
+ const r = await send(U.owner.token, "POST", D(), { name, ...extra });
30
+ expect(r.status).toBe(201);
31
+ return r.body.department as { id: string; name: string; parent_id: string | null };
32
+ };
33
+
34
+ beforeAll(async () => {
35
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
36
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
37
+ process.env.HOST = "127.0.0.1";
38
+ const a = register(`dep_owner_${Date.now()}`, PW, undefined, "Owner");
39
+ expect(a.ok).toBe(true);
40
+ U.owner = { token: a.token!, id: a.user!.user_id };
41
+ NET = a.network_id!;
42
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [U.owner.id]);
43
+ const mod: any = await import("./server.js");
44
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
45
+ BASE = `http://127.0.0.1:${hub.port}`;
46
+ const add = async (username: string, role: string) => {
47
+ expect((await send(U.owner.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role })).status).toBe(200);
48
+ const login = await send("", "POST", "/api/auth/login", { username, password: PW });
49
+ expect(login.status).toBe(200);
50
+ return { token: login.body.token as string, id: login.body.user.user_id as string };
51
+ };
52
+ const stamp = Date.now();
53
+ U.admin = await add(`dep_admin_${stamp}`, "admin");
54
+ U.member = await add(`dep_member_${stamp}`, "member");
55
+ U.viewer = await add(`dep_viewer_${stamp}`, "viewer");
56
+ U.node = { token: createNetworkTokenForNode(U.owner.id, NET, "dep-node", "node_dep").token!, id: "node_dep" };
57
+ const o = register(`dep_outsider_${stamp}`, PW);
58
+ U.outsider = { token: o.token!, id: o.user!.user_id };
59
+ OTHER_NET = o.network_id!;
60
+ }, 30_000);
61
+
62
+ afterAll(() => {
63
+ try { hub?.stop?.(true); } catch {}
64
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
65
+ });
66
+
67
+ describe("who can read / write", () => {
68
+ test("every member, a node token of this network and a hub admin can read; outsiders and other networks' nodes can't", async () => {
69
+ for (const who of ["owner", "admin", "member", "viewer", "node"]) expect((await send(U[who].token, "GET", D())).status).toBe(200);
70
+ expect((await send(U.outsider.token, "GET", D())).status).toBe(403);
71
+ expect((await send("", "GET", D())).status).toBe(401);
72
+ const otherNode = createNetworkTokenForNode(U.outsider.id, OTHER_NET, "dep-other", "node_dep_other").token!;
73
+ expect((await send(otherNode, "GET", D())).status).toBe(403);
74
+ });
75
+
76
+ test("only network owner / admin (and hub admin) can write", async () => {
77
+ expect((await send(U.admin.token, "POST", D(), { name: "管理员建的" })).status).toBe(201);
78
+ for (const who of ["member", "viewer", "node", "outsider"]) {
79
+ expect((await send(U[who].token, "POST", D(), { name: `不该建-${who}` })).status).toBe(403);
80
+ }
81
+ const anyDept = (await list()).departments[0].id;
82
+ expect((await send(U.member.token, "PATCH", `${D()}/${anyDept}`, { name: "x" })).status).toBe(403);
83
+ expect((await send(U.member.token, "DELETE", `${D()}/${anyDept}`)).status).toBe(403);
84
+ expect((await send(U.member.token, "PUT", `/api/networks/${NET}/members/${U.member.id}/department`, { department_id: anyDept })).status).toBe(403);
85
+ });
86
+ });
87
+
88
+ describe("departments", () => {
89
+ test("create: top level and child, auto id and custom id, sibling names unique, validation", async () => {
90
+ const top = await mk("军团基建");
91
+ expect(top.id).toMatch(/^dept_/);
92
+ expect(top.parent_id).toBeNull();
93
+ const child = await mk("前端组", { parent_id: top.id, id: "fe-team" });
94
+ expect(child.id).toBe("fe-team");
95
+ expect(child.parent_id).toBe(top.id);
96
+ expect((await send(U.owner.token, "POST", D(), { name: "前端组", parent_id: top.id })).status).toBe(409);
97
+ expect((await send(U.owner.token, "POST", D(), { name: "前端组" })).status).toBe(201); // same name under another parent is fine
98
+ expect((await send(U.owner.token, "POST", D(), { name: "另一个", id: "fe-team" })).body.error).toBe("department_id_taken");
99
+ expect((await send(U.owner.token, "POST", D(), { name: " " })).body.error).toBe("invalid_department_name");
100
+ expect((await send(U.owner.token, "POST", D(), { name: "x".repeat(41) })).body.error).toBe("invalid_department_name");
101
+ expect((await send(U.owner.token, "POST", D(), { name: "坏 id", id: "has space" })).body.error).toBe("invalid_department_id");
102
+ expect((await send(U.owner.token, "POST", D(), { name: "孤儿", parent_id: "nope" })).body.error).toBe("parent_not_found");
103
+ expect((await send(U.owner.token, "POST", D(), { name: "外人负责", leader_user_id: U.outsider.id })).body.error).toBe("leader_not_member");
104
+ });
105
+
106
+ test("rename, move (no cycles, depth ≤ 10), set / clear leader, sort", async () => {
107
+ const a = await mk("移动甲");
108
+ const b = await mk("移动乙", { parent_id: a.id });
109
+ const c = await mk("移动丙", { parent_id: b.id });
110
+ expect((await send(U.owner.token, "PATCH", `${D()}/${a.id}`, { parent_id: c.id })).body.error).toBe("department_cycle");
111
+ expect((await send(U.owner.token, "PATCH", `${D()}/${a.id}`, { parent_id: a.id })).body.error).toBe("department_cycle");
112
+ const moved = await send(U.owner.token, "PATCH", `${D()}/${c.id}`, { parent_id: null, name: "移动丙(顶层)" });
113
+ expect(moved.status).toBe(200);
114
+ expect(moved.body.department.parent_id).toBeNull();
115
+ expect(moved.body.department.name).toBe("移动丙(顶层)");
116
+ const led = await send(U.owner.token, "PATCH", `${D()}/${b.id}`, { leader_user_id: U.member.id, sort: 5 });
117
+ expect(led.body.department.leader_user_id).toBe(U.member.id);
118
+ expect(led.body.department.sort).toBe(5);
119
+ expect((await send(U.owner.token, "PATCH", `${D()}/${b.id}`, { leader_user_id: null })).body.department.leader_user_id).toBeNull();
120
+ expect((await send(U.owner.token, "PATCH", `${D()}/${b.id}`, {})).body.error).toBe("empty_patch");
121
+ expect((await send(U.owner.token, "PATCH", `${D()}/missing`, { name: "x" })).status).toBe(404);
122
+ // depth: a chain of 10 is fine, the 11th level is refused (create and move)
123
+ let parent: string | null = null;
124
+ const chain: string[] = [];
125
+ for (let i = 0; i < 10; i++) { const d = await mk(`层${i}`, parent ? { parent_id: parent } : {}); chain.push(d.id); parent = d.id; }
126
+ expect((await send(U.owner.token, "POST", D(), { name: "第11层", parent_id: parent })).body.error).toBe("department_too_deep");
127
+ const two = await mk("两层");
128
+ await mk("两层的子", { parent_id: two.id });
129
+ expect((await send(U.owner.token, "PATCH", `${D()}/${two.id}`, { parent_id: chain[8] })).body.error).toBe("department_too_deep");
130
+ });
131
+
132
+ test("members: put into a department, counts, unassign; /members (admin list) carries department_id, /humans unchanged", async () => {
133
+ const team = await mk("成员测试部");
134
+ expect((await send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U.member.id}/department`, { department_id: team.id })).body.department_id).toBe(team.id);
135
+ expect((await send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U.viewer.id}/department`, { department_id: team.id })).status).toBe(200);
136
+ const l = await list("member");
137
+ expect(l.departments.find((d: any) => d.id === team.id).member_count).toBe(2);
138
+ expect(l.members.find((m: any) => m.user_id === U.member.id).department_id).toBe(team.id);
139
+ expect(l.members.find((m: any) => m.user_id === U.owner.id).department_id).toBeNull();
140
+ // /humans stays identity-only (its key set is pinned by member-presence / agent-acl tests): who sits where comes
141
+ // from GET …/departments (members[]), which every member can read.
142
+ const humans = await send(U.viewer.token, "GET", `/api/networks/${NET}/humans`);
143
+ expect(humans.body.humans.find((h: any) => h.user_id === U.member.id).department_id).toBeUndefined();
144
+ const members = await send(U.owner.token, "GET", `/api/networks/${NET}/members`);
145
+ const row = members.body.members.find((m: any) => m.user_id === U.member.id);
146
+ expect(row.department_id).toBe(team.id);
147
+ for (const k of ["user_id", "role", "username", "agent_access", "task_access"]) expect(row).toHaveProperty(k); // old fields unchanged
148
+ expect((await send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U.outsider.id}/department`, { department_id: team.id })).status).toBe(404);
149
+ expect((await send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U.member.id}/department`, { department_id: "nope" })).body.error).toBe("department_not_found");
150
+ expect((await send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U.viewer.id}/department`, { department_id: null })).body.department_id).toBeNull();
151
+ });
152
+
153
+ test("delete only when empty (no sub-departments, no members), with the counts", async () => {
154
+ const parent = await mk("删除测试部");
155
+ const kid = await mk("删除测试子部", { parent_id: parent.id });
156
+ await send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U.admin.id}/department`, { department_id: parent.id });
157
+ const blocked = await send(U.owner.token, "DELETE", `${D()}/${parent.id}`);
158
+ expect(blocked.status).toBe(409);
159
+ expect(blocked.body).toMatchObject({ error: "department_not_empty", children: 1, members: 1 });
160
+ expect((await send(U.owner.token, "DELETE", `${D()}/${kid.id}`)).status).toBe(200);
161
+ await send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U.admin.id}/department`, { department_id: null });
162
+ expect((await send(U.owner.token, "DELETE", `${D()}/${parent.id}`)).status).toBe(200);
163
+ expect((await list()).departments.some((d: any) => d.id === parent.id)).toBe(false);
164
+ expect((await send(U.owner.token, "DELETE", `${D()}/${parent.id}`)).status).toBe(404);
165
+ });
166
+
167
+ test("a leader who leaves the network reads as no leader", async () => {
168
+ const stamp = Date.now();
169
+ expect((await send(U.owner.token, "POST", "/api/admin/users", { username: `dep_leaver_${stamp}`, password: PW, network_id: NET, role: "member" })).status).toBe(200);
170
+ const leaver = (await send("", "POST", "/api/auth/login", { username: `dep_leaver_${stamp}`, password: PW })).body.user.user_id as string;
171
+ const d = await mk("负责人会走", { leader_user_id: leaver });
172
+ expect(d).toBeTruthy();
173
+ expect((await send(U.owner.token, "DELETE", `/api/networks/${NET}/members/${leaver}`)).status).toBe(200);
174
+ expect((await list()).departments.find((x: any) => x.id === d.id).leader_user_id).toBeNull();
175
+ });
176
+
177
+ test("deleting a network removes its departments", async () => {
178
+ const r = register(`dep_gone_${Date.now()}`, PW);
179
+ const net = r.network_id!;
180
+ expect((await send(r.token!, "POST", `/api/networks/${net}/departments`, { name: "会被删" })).status).toBe(201);
181
+ expect(db.get("SELECT 1 AS x FROM network_departments WHERE network_id = ?1", net)).toBeTruthy();
182
+ expect((await send(r.token!, "DELETE", `/api/networks/${net}`)).status).toBe(200);
183
+ expect(db.get("SELECT 1 AS x FROM network_departments WHERE network_id = ?1", net)).toBeFalsy();
184
+ });
185
+ });
@@ -0,0 +1,260 @@
1
+ // 人类成员的组织架构(board #419):部门(多层)+ 每个成员归一个部门 + 部门负责人。
2
+ //
3
+ // 只增不改:新表 network_departments,network_members 多一列 department_id(默认 NULL = 未分配)。
4
+ // 旧 app 不读这两样,行为不变。网络本身就是根(parent_id = NULL 的部门挂在网络下面)。
5
+ //
6
+ // 权限:网络里任何成员(含 viewer、受限成员)都能读组织架构 —— 和 /humans 通讯录同一可见范围;
7
+ // 写(建 / 改名 / 移动 / 删除部门、设负责人、调人)只给网络 owner / admin 和 Hub 管理员。节点令牌只能读自己网络的。
8
+ //
9
+ // /humans 通讯录保持只有身份字段(它的键集合被测试钉着);谁在哪个部门从 GET …/departments 的 members[] 读。
10
+ // 规则:同一上级下部门不重名;移动不能挂到自己或自己的下级下面;最多 MAX_DEPTH 层;
11
+ // 只有空部门(没有子部门、没有成员)能删;负责人必须是本网络成员;人离开网络后,作为负责人的引用读出来是 null。
12
+
13
+ import { db, logAudit } from "./db.js";
14
+ import { deleteDepartmentGrants, deleteDepartmentGrantsForNetwork } from "./department-heads.js";
15
+ import { deleteChatGroupsForNetwork, groupTx, removeMemberFromChatGroups, syncDepartmentGroups, unlinkDepartmentGroup } from "./department-groups.js";
16
+
17
+ // auth.ts 的 removeNetworkMember 从这里拿(auth.ts 只 import departments.js 一行,文档钉着它的行号)。
18
+ export { groupTx, removeMemberFromChatGroups };
19
+
20
+ try { db.exec("ALTER TABLE network_members ADD COLUMN department_id TEXT"); } catch {}
21
+ db.exec(`
22
+ CREATE TABLE IF NOT EXISTS network_departments (
23
+ network_id TEXT NOT NULL,
24
+ department_id TEXT NOT NULL,
25
+ name TEXT NOT NULL,
26
+ parent_id TEXT,
27
+ leader_user_id TEXT,
28
+ sort INTEGER NOT NULL DEFAULT 0,
29
+ created_by TEXT,
30
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
31
+ updated_at TEXT NOT NULL DEFAULT (datetime('now')),
32
+ PRIMARY KEY (network_id, department_id)
33
+ );
34
+ CREATE INDEX IF NOT EXISTS idx_network_departments_parent ON network_departments(network_id, parent_id);
35
+ `);
36
+
37
+ export const MAX_DEPTH = 10;
38
+ export const MAX_DEPARTMENTS = 500;
39
+ const NAME_MAX = 40;
40
+ const ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
41
+
42
+ type DeptRow = { network_id: string; department_id: string; name: string; parent_id: string | null; leader_user_id: string | null; sort: number; created_at: string; updated_at: string };
43
+ export type Department = { id: string; name: string; parent_id: string | null; leader_user_id: string | null; sort: number; member_count: number; created_at: string; updated_at: string };
44
+
45
+ type Fail = { ok: false; status: number; error: string };
46
+ type Ok<T> = { ok: true } & T;
47
+
48
+ const fail = (status: number, error: string): Fail => ({ ok: false, status, error });
49
+
50
+ function rows(networkId: string): DeptRow[] {
51
+ return db.all<DeptRow>(
52
+ "SELECT network_id, department_id, name, parent_id, leader_user_id, sort, created_at, updated_at FROM network_departments WHERE network_id = ?1 ORDER BY sort, created_at, department_id",
53
+ networkId,
54
+ );
55
+ }
56
+ function one(networkId: string, id: string): DeptRow | null {
57
+ return db.get<DeptRow>(
58
+ "SELECT network_id, department_id, name, parent_id, leader_user_id, sort, created_at, updated_at FROM network_departments WHERE network_id = ?1 AND department_id = ?2",
59
+ networkId, id,
60
+ ) ?? null;
61
+ }
62
+ function isMember(networkId: string, userId: string): boolean {
63
+ return !!db.get<{ user_id: string }>("SELECT user_id FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId);
64
+ }
65
+
66
+ /** 读:部门(带直属人数)+ 每个成员在哪个部门。负责人不在本网络了 → null。 */
67
+ export function listDepartments(networkId: string): { departments: Department[]; members: Array<{ user_id: string; department_id: string | null }> } {
68
+ const members = db.all<{ user_id: string; department_id: string | null }>(
69
+ "SELECT user_id, department_id FROM network_members WHERE network_id = ?1 ORDER BY joined_at, user_id",
70
+ networkId,
71
+ );
72
+ const memberIds = new Set(members.map((m) => m.user_id));
73
+ const all = rows(networkId);
74
+ const ids = new Set(all.map((d) => d.department_id));
75
+ // 成员行指着一个已经不存在的部门(理论上删除时已清空;防御)→ 当未分配。
76
+ const placed = members.map((m) => ({ user_id: m.user_id, department_id: m.department_id && ids.has(m.department_id) ? m.department_id : null }));
77
+ const counts = new Map<string, number>();
78
+ for (const m of placed) if (m.department_id) counts.set(m.department_id, (counts.get(m.department_id) ?? 0) + 1);
79
+ return {
80
+ departments: all.map((d) => ({
81
+ id: d.department_id,
82
+ name: d.name,
83
+ parent_id: d.parent_id,
84
+ leader_user_id: d.leader_user_id && memberIds.has(d.leader_user_id) ? d.leader_user_id : null,
85
+ sort: d.sort,
86
+ member_count: counts.get(d.department_id) ?? 0,
87
+ created_at: d.created_at,
88
+ updated_at: d.updated_at,
89
+ })),
90
+ members: placed,
91
+ };
92
+ }
93
+
94
+ const cleanName = (raw: unknown): string | null => {
95
+ if (typeof raw !== "string") return null;
96
+ const name = raw.trim();
97
+ return name && [...name].length <= NAME_MAX ? name : null;
98
+ };
99
+
100
+ function depthOf(networkId: string, id: string | null): number {
101
+ let depth = 0;
102
+ let cur = id;
103
+ const seen = new Set<string>();
104
+ while (cur) {
105
+ if (seen.has(cur)) return Infinity;
106
+ seen.add(cur);
107
+ depth += 1;
108
+ cur = one(networkId, cur)?.parent_id ?? null;
109
+ }
110
+ return depth;
111
+ }
112
+ /** 以 id 为根的子树最深有几层(含自己)。 */
113
+ function subtreeHeight(networkId: string, id: string): number {
114
+ const children = db.all<{ department_id: string }>("SELECT department_id FROM network_departments WHERE network_id = ?1 AND parent_id = ?2", networkId, id);
115
+ return 1 + children.reduce((m, c) => Math.max(m, subtreeHeight(networkId, c.department_id)), 0);
116
+ }
117
+ function isDescendant(networkId: string, ancestor: string, id: string | null): boolean {
118
+ let cur = id;
119
+ const seen = new Set<string>();
120
+ while (cur) {
121
+ if (cur === ancestor) return true;
122
+ if (seen.has(cur)) return true;
123
+ seen.add(cur);
124
+ cur = one(networkId, cur)?.parent_id ?? null;
125
+ }
126
+ return false;
127
+ }
128
+ function siblingNameTaken(networkId: string, parentId: string | null, name: string, exceptId?: string): boolean {
129
+ const sql = parentId === null
130
+ ? "SELECT department_id FROM network_departments WHERE network_id = ?1 AND parent_id IS NULL AND name = ?2"
131
+ : "SELECT department_id FROM network_departments WHERE network_id = ?1 AND parent_id = ?3 AND name = ?2";
132
+ const hits = parentId === null ? db.all<{ department_id: string }>(sql, networkId, name) : db.all<{ department_id: string }>(sql, networkId, name, parentId);
133
+ return hits.some((h) => h.department_id !== exceptId);
134
+ }
135
+ function publicOne(networkId: string, id: string): Department {
136
+ return listDepartments(networkId).departments.find((d) => d.id === id)!;
137
+ }
138
+
139
+ export function createDepartment(networkId: string, actor: string, body: Record<string, unknown>): Ok<{ department: Department }> | Fail {
140
+ const name = cleanName(body.name);
141
+ if (!name) return fail(400, "invalid_department_name");
142
+ let id: string;
143
+ if (body.id !== undefined && body.id !== null && body.id !== "") {
144
+ if (typeof body.id !== "string" || !ID_RE.test(body.id)) return fail(400, "invalid_department_id");
145
+ if (one(networkId, body.id)) return fail(409, "department_id_taken");
146
+ id = body.id;
147
+ } else {
148
+ id = `dept_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`;
149
+ }
150
+ const parentId = body.parent_id === undefined || body.parent_id === null || body.parent_id === "" ? null : body.parent_id;
151
+ if (parentId !== null && (typeof parentId !== "string" || !one(networkId, parentId))) return fail(400, "parent_not_found");
152
+ if (depthOf(networkId, parentId as string | null) + 1 > MAX_DEPTH) return fail(400, "department_too_deep");
153
+ if (siblingNameTaken(networkId, parentId as string | null, name)) return fail(409, "department_name_taken");
154
+ const leader = body.leader_user_id === undefined || body.leader_user_id === null || body.leader_user_id === "" ? null : body.leader_user_id;
155
+ if (leader !== null && (typeof leader !== "string" || !isMember(networkId, leader))) return fail(400, "leader_not_member");
156
+ if (body.sort !== undefined && !Number.isInteger(body.sort)) return fail(400, "invalid_sort");
157
+ const count = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM network_departments WHERE network_id = ?1", networkId)?.n ?? 0;
158
+ if (count >= MAX_DEPARTMENTS) return fail(400, "too_many_departments");
159
+ const sort = body.sort === undefined
160
+ ? (db.get<{ n: number }>(
161
+ parentId === null
162
+ ? "SELECT COUNT(*) AS n FROM network_departments WHERE network_id = ?1 AND parent_id IS NULL"
163
+ : "SELECT COUNT(*) AS n FROM network_departments WHERE network_id = ?1 AND parent_id = ?2",
164
+ ...(parentId === null ? [networkId] : [networkId, parentId]),
165
+ )?.n ?? 0)
166
+ : body.sort as number;
167
+ groupTx(() => {
168
+ db.run(
169
+ "INSERT INTO network_departments (network_id, department_id, name, parent_id, leader_user_id, sort, created_by) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
170
+ [networkId, id, name, parentId, leader, sort, actor],
171
+ );
172
+ // RFC-042 §4:新部门带负责人 → 上级部门群的 roster 多了这个负责人。没有负责人的新部门是空的,roster 不变。
173
+ if (leader !== null) syncDepartmentGroups(networkId);
174
+ });
175
+ return { ok: true, department: publicOne(networkId, id) };
176
+ }
177
+
178
+ export function updateDepartment(networkId: string, id: string, body: Record<string, unknown>): Ok<{ department: Department }> | Fail {
179
+ const cur = one(networkId, id);
180
+ if (!cur) return fail(404, "department_not_found");
181
+ let name = cur.name;
182
+ let parentId = cur.parent_id;
183
+ let leader = cur.leader_user_id;
184
+ let sort = cur.sort;
185
+ let touched = false;
186
+ if (body.name !== undefined) {
187
+ const n = cleanName(body.name);
188
+ if (!n) return fail(400, "invalid_department_name");
189
+ name = n; touched = true;
190
+ }
191
+ if (body.parent_id !== undefined) {
192
+ const p = body.parent_id === null || body.parent_id === "" ? null : body.parent_id;
193
+ if (p !== null && (typeof p !== "string" || !one(networkId, p))) return fail(400, "parent_not_found");
194
+ if (p !== null && isDescendant(networkId, id, p as string)) return fail(400, "department_cycle");
195
+ if (depthOf(networkId, p as string | null) + subtreeHeight(networkId, id) > MAX_DEPTH) return fail(400, "department_too_deep");
196
+ parentId = p as string | null; touched = true;
197
+ }
198
+ if (body.leader_user_id !== undefined) {
199
+ const l = body.leader_user_id === null || body.leader_user_id === "" ? null : body.leader_user_id;
200
+ if (l !== null && (typeof l !== "string" || !isMember(networkId, l))) return fail(400, "leader_not_member");
201
+ leader = l as string | null; touched = true;
202
+ }
203
+ if (body.sort !== undefined) {
204
+ if (!Number.isInteger(body.sort)) return fail(400, "invalid_sort");
205
+ sort = body.sort as number; touched = true;
206
+ }
207
+ if (!touched) return fail(400, "empty_patch");
208
+ if ((name !== cur.name || parentId !== cur.parent_id) && siblingNameTaken(networkId, parentId, name, id)) return fail(409, "department_name_taken");
209
+ groupTx(() => {
210
+ db.run(
211
+ "UPDATE network_departments SET name = ?3, parent_id = ?4, leader_user_id = ?5, sort = ?6, updated_at = datetime('now') WHERE network_id = ?1 AND department_id = ?2",
212
+ [networkId, id, name, parentId, leader, sort],
213
+ );
214
+ // RFC-042 §4:改上级(子树变了)/ 换负责人 → 同一事务对账部门群。只改名 / 排序不影响 roster。
215
+ if (parentId !== cur.parent_id || leader !== cur.leader_user_id) syncDepartmentGroups(networkId);
216
+ });
217
+ return { ok: true, department: publicOne(networkId, id) };
218
+ }
219
+
220
+ /** 只删空部门:有子部门 / 有成员 → 409(带数字,界面照着说)。 */
221
+ export function deleteDepartment(networkId: string, id: string): Ok<{ deleted: string }> | (Fail & { children?: number; members?: number }) {
222
+ if (!one(networkId, id)) return fail(404, "department_not_found");
223
+ const children = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM network_departments WHERE network_id = ?1 AND parent_id = ?2", networkId, id)?.n ?? 0;
224
+ const members = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM network_members WHERE network_id = ?1 AND department_id = ?2", networkId, id)?.n ?? 0;
225
+ if (children || members) return { ...fail(409, "department_not_empty"), children, members };
226
+ // RFC-040:部门的项目授权(network_department_project_grants)和部门同一事务删掉。
227
+ groupTx(() => {
228
+ db.run("DELETE FROM network_departments WHERE network_id = ?1 AND department_id = ?2", [networkId, id]);
229
+ deleteDepartmentGrants(networkId, id);
230
+ // RFC-042:部门群解除关联(群和成员、以后的聊天记录都留着),不删。
231
+ unlinkDepartmentGroup(networkId, id);
232
+ // 删掉的部门的负责人可能还在上级部门群的 roster 里(负责人不必在本部门):对账一次。解除关联的群不再对账。
233
+ syncDepartmentGroups(networkId);
234
+ });
235
+ return { ok: true, deleted: id };
236
+ }
237
+
238
+ /** 把成员放进一个部门(null = 未分配)。 */
239
+ export function setMemberDepartment(networkId: string, userId: string, departmentId: unknown): Ok<{ user_id: string; department_id: string | null }> | Fail {
240
+ if (!isMember(networkId, userId)) return fail(404, "member_not_found");
241
+ const dept = departmentId === null || departmentId === "" ? null : departmentId;
242
+ if (dept !== null && (typeof dept !== "string" || !one(networkId, dept))) return fail(400, "department_not_found");
243
+ groupTx(() => {
244
+ db.run("UPDATE network_members SET department_id = ?3 WHERE network_id = ?1 AND user_id = ?2", [networkId, userId, dept]);
245
+ // RFC-042 §4:调进 / 调出 → 同一事务对账部门群(manual 行不动)。
246
+ syncDepartmentGroups(networkId);
247
+ });
248
+ return { ok: true, user_id: userId, department_id: dept as string | null };
249
+ }
250
+
251
+ /** 网络删掉时一起清(和项目授权同一时机调用)。 */
252
+ export function deleteDepartmentsForNetwork(networkId: string): void {
253
+ db.run("DELETE FROM network_departments WHERE network_id = ?1", [networkId]);
254
+ deleteDepartmentGrantsForNetwork(networkId);
255
+ deleteChatGroupsForNetwork(networkId); // RFC-042:网络没了,群一起删
256
+ }
257
+
258
+ export function auditDepartment(user: { user_id: string; username: string }, action: string, networkId: string, detail: string): void {
259
+ logAudit(user.user_id, user.username, action, "network", networkId, detail.slice(0, 2000), undefined, networkId);
260
+ }
@@ -0,0 +1,150 @@
1
+ // #1459 —— P2 写路径与 P3 读路径的**接缝**。
2
+ //
3
+ // 通信龙 在 origin/main 上核出的覆盖缝:desktop-message.test.ts 测真实 send 但
4
+ // 不碰 scope=user 读;user-inbox-read-http.test.ts 测 scope=user 读但用 SQL 直接
5
+ // 播种行。两半各自对着自己的夹具绿,而"真实 send 写进的行、真实 scope=user
6
+ // 读得回来"从没验过。本文件就走这一条:真 send → 真 HTTP 读。
7
+ //
8
+ // 附带钉住 network_id 不为 NULL 的**真实机制**:不是 INSERT 时兜底,而是
9
+ // tools.ts 里 `if (!effectiveNetId) return writeDeniedReply(...)` 在写之前就拒。
10
+ // 见下面 "解析不出 network 时拒发" 那条 —— 把那个 if 拿掉,本文件会红。
11
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
12
+ import { mkdtempSync, rmSync } from "node:fs";
13
+ import { tmpdir } from "node:os";
14
+ import { join } from "node:path";
15
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16
+
17
+ const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-dm-seam-"));
18
+ process.env.COMMHUB_DB ||= join(PRIVATE_DB_DIR, "hub.db");
19
+
20
+ let server: ReturnType<typeof Bun.serve>;
21
+ let base = "";
22
+ let targetToken = "";
23
+ let senderId = "", targetId = "", senderNet = "", secondNet = "";
24
+ let send: (args: any) => Promise<Record<string, any>>;
25
+
26
+ function buildSend(opts: { netId: string | null; userId: string | null; alias: string | null }) {
27
+ const mcp = new McpServer({ name: "test", version: "0" }) as any;
28
+ const tools: Record<string, any> = {};
29
+ const origTool = mcp.tool.bind(mcp);
30
+ mcp.tool = (name: string, _d: string, _s: any, handler: any) => { tools[name] = handler; return origTool(name, _d, _s, handler); };
31
+ const { registerTools } = require("./tools.js");
32
+ registerTools(mcp, undefined, opts.netId, opts.userId, opts.alias, false, "tok_seam_test");
33
+ return async (args: any) => JSON.parse((await tools["send_desktop_message"](args)).content[0].text);
34
+ }
35
+
36
+ beforeAll(async () => {
37
+ const { db } = await import("./db.js");
38
+ const { register, addNetworkMember } = await import("./auth.js");
39
+ const stamp = Date.now();
40
+
41
+ // 首个注册用户自动成为全局 admin,而 admin 读侧不走 scope 过滤分支。
42
+ // 收件人必须是普通 user,否则"读得回来"可能只是因为它绕过了过滤。
43
+ expect(register(`seam_admin_${stamp}`, "SeamAdmin-Strong-1!").ok).toBe(true);
44
+ const s = register(`seam_sender_${stamp}`, "SeamSender-Strong-1!");
45
+ const t = register(`seam_target_${stamp}`, "SeamTarget-Strong-1!");
46
+ expect(s.ok && t.ok).toBe(true);
47
+ targetToken = t.token!;
48
+ senderNet = s.network_id!;
49
+ secondNet = t.network_id!;
50
+ senderId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", `seam_sender_${stamp}`)!.user_id;
51
+ targetId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", `seam_target_${stamp}`)!.user_id;
52
+ expect(db.get<{ role: string }>("SELECT role FROM users WHERE user_id = ?1", targetId)?.role).toBe("user");
53
+
54
+ // 收件人进发件人的网(真实形态:同网两成员)。
55
+ expect(addNetworkMember(senderNet, targetId, "member").ok).toBe(true);
56
+
57
+ send = buildSend({ netId: senderNet, userId: senderId, alias: "seam-sender" });
58
+
59
+ const mod = await import("./server.js");
60
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
61
+ base = `http://127.0.0.1:${server.port}`;
62
+ });
63
+
64
+ afterAll(() => {
65
+ try { server?.stop(true); } catch {}
66
+ try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
67
+ });
68
+
69
+ const readOwn = async (token: string, qs = "") => {
70
+ const r = await fetch(`${base}/api/messages?scope=user${qs}`, { headers: { Authorization: `Bearer ${token}` } });
71
+ return { status: r.status, body: await r.json() as any };
72
+ };
73
+
74
+ describe("#1459 P2 写 → P3 读 接缝", () => {
75
+ test("🔴 真实 send_desktop_message 写进的行,真实 GET ?scope=user 读得回来", async () => {
76
+ const sent = await send({ to_user_id: targetId, message: "接缝测试正文", title: "接缝", severity: "warning", kind: "agent_message" });
77
+ expect([sent.ok, sent.persisted]).toEqual([true, true]);
78
+
79
+ const { status, body } = await readOwn(targetToken);
80
+ expect(status).toBe(200);
81
+ const row = body.messages.find((m: any) => m.message_id === sent.message_id);
82
+ expect(row).toBeTruthy(); // 承重:两半接得上
83
+ expect(row.content).toBe("接缝测试正文");
84
+ expect(row.title).toBe("接缝");
85
+ expect(row.severity).toBe("warning");
86
+ expect(row.user_id).toBe(targetId);
87
+ });
88
+
89
+ test("🔴 落库的 network_id 就是授权用的那个网,不是 NULL —— 否则 scoped 读回不来", async () => {
90
+ const { db } = require("./db.js");
91
+ const sent = await send({ to_user_id: targetId, message: "网络归属" });
92
+ const row = db.get<any>("SELECT network_id FROM user_inbox WHERE message_id = ?1", sent.message_id);
93
+ expect(row.network_id).toBe(senderNet);
94
+
95
+ const { body } = await readOwn(targetToken);
96
+ expect(body.messages.map((m: any) => m.message_id)).toContain(sent.message_id);
97
+ });
98
+
99
+ test("unread 随真实 send 增长", async () => {
100
+ const before = (await readOwn(targetToken)).body.unread;
101
+ await send({ to_user_id: targetId, message: "计数 +1" });
102
+ expect((await readOwn(targetToken)).body.unread).toBe(before + 1);
103
+ });
104
+
105
+ test("redact-at-read 作用在真实写进去的正文上", async () => {
106
+ const sent = await send({ to_user_id: targetId, message: "凭据 ntok_seamtoken123456 在正文里" });
107
+ const { db } = require("./db.js");
108
+ // 存储侧保留原文,遮蔽发生在**回读**时 —— 这两句一起才说明是 read-time 脱敏。
109
+ expect(db.get<any>("SELECT content FROM user_inbox WHERE message_id = ?1", sent.message_id).content)
110
+ .toContain("ntok_seamtoken123456");
111
+ const row = (await readOwn(targetToken)).body.messages.find((m: any) => m.message_id === sent.message_id);
112
+ expect(row.content).toContain("ntok_***redacted***");
113
+ expect(row.content).not.toContain("ntok_seamtoken123456");
114
+ });
115
+
116
+ // 🔴 这一条是 `if (!effectiveNetId) return` 这道守卫**唯一**能被触发的场景。
117
+ // 有 auth ctx 时前一道 `canWrite` 就拦了;而 canRestWriteNetwork 对
118
+ // legacy 全局 token / --dev-open(authCtx 为 null)是 `return true`
119
+ // (network-scope.ts:96)—— 那条路上只剩这道守卫挡着 NULL 孤儿行。
120
+ // 把 tools.ts:2320 那行删掉,本条会红(写出一行 network_id=NULL)。
121
+ test("🔴 legacy 无 auth ctx 的调用者同样拒发,不写 NULL 孤儿行", async () => {
122
+ const { db } = require("./db.js");
123
+ const legacy = buildSend({ netId: null, userId: null as any, alias: null as any });
124
+ const before = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM user_inbox")!.n;
125
+ const r = await legacy({ to_user_id: targetId, message: "legacy 无网络上下文" });
126
+ expect(r.ok).toBe(false);
127
+ expect(r.error).toBe("network_id_required");
128
+ expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM user_inbox")!.n).toBe(before);
129
+ expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM user_inbox WHERE network_id IS NULL")!.n).toBe(0);
130
+ });
131
+
132
+ test("🔴 解析不出 network 时**拒发**,而不是写一行 network_id=NULL 的孤儿", async () => {
133
+ const { db } = require("./db.js");
134
+ const { addNetworkMember } = require("./auth.js");
135
+ // 发件人进第二个网 ⇒ 不带 network_id 时 singleNetworkId 解析不出唯一网。
136
+ addNetworkMember(secondNet, senderId, "member");
137
+ const ambiguous = buildSend({ netId: null, userId: senderId, alias: "seam-sender" });
138
+
139
+ const before = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM user_inbox")!.n;
140
+ const r = await ambiguous({ to_user_id: targetId, message: "无法解析网络" });
141
+
142
+ expect(r.ok).toBe(false);
143
+ expect(r.error).toBe("network_id_required");
144
+ // 承重:拒发意味着**一行都不写**。若把 tools.ts 的
145
+ // `if (!effectiveNetId) return writeDeniedReply(...)` 拿掉,这里会多出一行
146
+ // network_id=NULL 的孤儿 —— 写得进去、scoped 读不回来。
147
+ expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM user_inbox")!.n).toBe(before);
148
+ expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM user_inbox WHERE network_id IS NULL")!.n).toBe(0);
149
+ });
150
+ });