@sleep2agi/commhub-server 0.9.0-preview.8 → 0.9.0-preview.80

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 (202) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +92 -11
  3. package/package.json +5 -5
  4. package/src/ack-create-request-transport.test.ts +159 -0
  5. package/src/ack-create-request.test.ts +5 -5
  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 +478 -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/alias-filter.test.ts +72 -0
  15. package/src/alias-filter.ts +32 -0
  16. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  17. package/src/api-host-supervisors-fallback.test.ts +271 -0
  18. package/src/api-nodes-shape.test.ts +26 -10
  19. package/src/auth-sessions-http.test.ts +250 -0
  20. package/src/auth-validate.test.ts +5 -1
  21. package/src/auth.ts +450 -31
  22. package/src/avatar-validate.test.ts +163 -0
  23. package/src/avatar-validate.ts +103 -0
  24. package/src/blocked-exit-on-reply.test.ts +122 -0
  25. package/src/boot-side-effect.test.ts +124 -0
  26. package/src/commhub-bin-args.test.ts +85 -0
  27. package/src/config-apply-sec1.test.ts +107 -0
  28. package/src/config-apply-validate.test.ts +92 -0
  29. package/src/config-apply-validate.ts +81 -3
  30. package/src/create-node-blocked-gate.test.ts +356 -0
  31. package/src/create-node-tool-schema.test.ts +39 -0
  32. package/src/create-node-validate.test.ts +89 -1
  33. package/src/create-node-validate.ts +89 -7
  34. package/src/create-node-workdir.test.ts +255 -0
  35. package/src/create-node.ts +46 -1
  36. package/src/dashboard-slash-routing-http.test.ts +107 -0
  37. package/src/db-adapter-guard.test.ts +148 -0
  38. package/src/db-adapter-translate.test.ts +128 -0
  39. package/src/db-adapter.ts +323 -87
  40. package/src/db-timestamp.test.ts +38 -0
  41. package/src/db-timestamp.ts +15 -0
  42. package/src/db.ts +805 -33
  43. package/src/delivered-without-push.test.ts +140 -0
  44. package/src/desktop-message-seam.test.ts +150 -0
  45. package/src/desktop-message.test.ts +368 -0
  46. package/src/dm-file-scope-http.test.ts +178 -0
  47. package/src/ext-token-shared.test.ts +60 -0
  48. package/src/ext-validation-regression.test.ts +131 -0
  49. package/src/external-schedule-edits-http.test.ts +321 -0
  50. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  51. package/src/external-schedule-edits.ts +327 -0
  52. package/src/file-download-authz-dev-open.test.ts +135 -0
  53. package/src/file-download-authz.test.ts +404 -0
  54. package/src/file-download-date-bucket.test.ts +436 -0
  55. package/src/file-network-scope-dev-open.test.ts +97 -0
  56. package/src/file-network-scope.test.ts +732 -0
  57. package/src/health-redaction.test.ts +642 -0
  58. package/src/http-gzip.test.ts +94 -0
  59. package/src/http-gzip.ts +110 -0
  60. package/src/hub-timestamp.test.ts +38 -0
  61. package/src/hub-timestamp.ts +27 -0
  62. package/src/human-dm.ts +215 -0
  63. package/src/inbox-count-sse.test.ts +310 -0
  64. package/src/inbox-count.ts +29 -0
  65. package/src/index.ts +15 -2331
  66. package/src/lifecycle-guard.ts +50 -0
  67. package/src/mcp-route-label-http.test.ts +120 -0
  68. package/src/mcp-write-network-resolution.test.ts +429 -0
  69. package/src/member-audit-network-id-http.test.ts +93 -0
  70. package/src/member-presence-http.test.ts +206 -0
  71. package/src/messages-ack-by-agent-http.test.ts +142 -0
  72. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  73. package/src/migrate-sqlite-to-pg.ts +263 -0
  74. package/src/network-name-http.test.ts +75 -0
  75. package/src/network-scope.ts +224 -0
  76. package/src/node-attrs-validate.ts +88 -0
  77. package/src/node-attrs.test.ts +217 -0
  78. package/src/node-create-request-status-http.test.ts +92 -0
  79. package/src/node-files-transport.test.ts +344 -0
  80. package/src/node-id-alias.test.ts +188 -0
  81. package/src/node-lifecycle-controllable.test.ts +64 -0
  82. package/src/node-lifecycle-controllable.ts +51 -0
  83. package/src/node-logs-transport.test.ts +263 -0
  84. package/src/node-request-retention.test.ts +281 -0
  85. package/src/node-request-retention.ts +120 -0
  86. package/src/node-skills-transport.test.ts +209 -0
  87. package/src/node-token-privilege-http.test.ts +149 -0
  88. package/src/observer-avatar-http.test.ts +429 -0
  89. package/src/observer-push.test.ts +252 -0
  90. package/src/package-version-consistency.test.ts +25 -0
  91. package/src/peer-reply-atomic.test.ts +455 -0
  92. package/src/peer-reply-capability.test.ts +70 -0
  93. package/src/pg-worker.ts +87 -0
  94. package/src/push.ts +284 -7
  95. package/src/redact-tokens.test.ts +65 -0
  96. package/src/redact-tokens.ts +40 -0
  97. package/src/rename.ts +7 -2
  98. package/src/reply-target-derivation.test.ts +96 -0
  99. package/src/report-status-host-schema.test.ts +211 -0
  100. package/src/report-status-resume-id-handover.test.ts +150 -0
  101. package/src/require-explicit-test-db.ts +9 -0
  102. package/src/requirement-events-http.test.ts +257 -0
  103. package/src/requirement-events.test.ts +45 -0
  104. package/src/requirement-events.ts +143 -0
  105. package/src/requirement-notify.ts +134 -0
  106. package/src/requirement-participant-notify-http.test.ts +309 -0
  107. package/src/requirement-tag-ops-http.test.ts +266 -0
  108. package/src/requirement-tags.ts +89 -0
  109. package/src/requirements-completed-at-migration.test.ts +70 -0
  110. package/src/requirements-etag-http.test.ts +91 -0
  111. package/src/requirements-http.test.ts +709 -0
  112. package/src/requirements-list-slim-http.test.ts +315 -0
  113. package/src/requirements-mcp.test.ts +163 -0
  114. package/src/requirements-migrate.ts +225 -0
  115. package/src/requirements-people-http.test.ts +114 -0
  116. package/src/requirements-priority-lowest.test.ts +114 -0
  117. package/src/requirements-search-http.test.ts +182 -0
  118. package/src/requirements-search.test.ts +32 -0
  119. package/src/requirements-search.ts +138 -0
  120. package/src/requirements-seq-http.test.ts +193 -0
  121. package/src/requirements-seq-migration.test.ts +63 -0
  122. package/src/requirements-start-date.test.ts +125 -0
  123. package/src/requirements-stats-http.test.ts +288 -0
  124. package/src/requirements-stats.ts +164 -0
  125. package/src/requirements-upgrade-e2e.test.ts +126 -0
  126. package/src/requirements-upgrade-migration.test.ts +98 -0
  127. package/src/requirements.ts +1439 -0
  128. package/src/resolve-port.test.ts +57 -0
  129. package/src/resolve-port.ts +44 -0
  130. package/src/response-charset.test.ts +130 -0
  131. package/src/rest-explicit-columns-http.test.ts +216 -0
  132. package/src/rest-identity.test.ts +129 -0
  133. package/src/rest-identity.ts +100 -0
  134. package/src/rest-projections.ts +68 -0
  135. package/src/rest-write-network-resolution.test.ts +133 -0
  136. package/src/restricted-files.ts +73 -0
  137. package/src/retention.ts +15 -3
  138. package/src/route-callers-http.test.ts +120 -0
  139. package/src/route-timing.test.ts +211 -0
  140. package/src/route-timing.ts +258 -0
  141. package/src/rules-file-duplicate-alias.test.ts +234 -0
  142. package/src/rules-file-session-target.test.ts +207 -0
  143. package/src/rules-file-transport.test.ts +237 -0
  144. package/src/runtime-label.test.ts +22 -0
  145. package/src/runtime-label.ts +21 -0
  146. package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
  147. package/src/scheduled-reply-unread-http.test.ts +212 -0
  148. package/src/scheduled-run-terminal.test.ts +268 -0
  149. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  150. package/src/scheduled-tasks-http.test.ts +638 -0
  151. package/src/scheduled-tasks.ts +597 -0
  152. package/src/send-dedup-request-id.test.ts +229 -0
  153. package/src/send-reply-agent-warning.test.ts +202 -0
  154. package/src/send-reply-alias-optional.test.ts +131 -0
  155. package/src/send-reply-attachments.test.ts +458 -0
  156. package/src/send_dedup.test.ts +54 -0
  157. package/src/send_dedup.ts +52 -6
  158. package/src/serve-error.test.ts +38 -0
  159. package/src/serve-error.ts +27 -0
  160. package/src/server.ts +4522 -0
  161. package/src/shared/external-schedule-contract.ts +60 -0
  162. package/src/side-thread-command-transport.test.ts +312 -0
  163. package/src/side-thread-command-transport.ts +318 -0
  164. package/src/side-thread-http-integration.test.ts +233 -0
  165. package/src/side-thread-http.ts +504 -0
  166. package/src/side-thread-production.test.ts +72 -0
  167. package/src/side-thread-production.ts +118 -0
  168. package/src/side-thread.test.ts +1067 -0
  169. package/src/side-thread.ts +2499 -0
  170. package/src/skillhub-http.test.ts +108 -0
  171. package/src/sqlite-close-checkpoint.test.ts +78 -0
  172. package/src/start-node.test.ts +109 -0
  173. package/src/status-alias-filter-http.test.ts +106 -0
  174. package/src/status-node-id-filter-http.test.ts +161 -0
  175. package/src/stop-delete-node.test.ts +1164 -0
  176. package/src/task-access-http.test.ts +379 -0
  177. package/src/task-access.ts +307 -0
  178. package/src/task-auth-origin-http.test.ts +91 -0
  179. package/src/task-auth-origin.test.ts +24 -0
  180. package/src/task-auth-origin.ts +7 -0
  181. package/src/task-consumption.test.ts +482 -0
  182. package/src/task-diagnostic-http.test.ts +149 -0
  183. package/src/task-diagnostic.test.ts +115 -0
  184. package/src/task-diagnostic.ts +118 -0
  185. package/src/task-idempotency-mcp.test.ts +142 -0
  186. package/src/task-idempotency.test.ts +60 -0
  187. package/src/task-idempotency.ts +75 -0
  188. package/src/task-lifecycle-watcher.test.ts +204 -0
  189. package/src/task-lifecycle-watcher.ts +49 -0
  190. package/src/task-network-resolution-http.test.ts +141 -0
  191. package/src/tasks-pagination-http.test.ts +141 -0
  192. package/src/tools-list-json-schema.test.ts +66 -0
  193. package/src/tools.ts +3534 -343
  194. package/src/update-provider.test.ts +65 -0
  195. package/src/upload-early-reject-guard.test.ts +155 -0
  196. package/src/uploads-http.test.ts +97 -15
  197. package/src/uploads.test.ts +66 -0
  198. package/src/uploads.ts +138 -5
  199. package/src/user-inbox-by-agent-http.test.ts +161 -0
  200. package/src/user-inbox-netid-notnull.test.ts +122 -0
  201. package/src/user-inbox-read-http.test.ts +223 -0
  202. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,93 @@
1
+ // 成员增 / 改角色 / 移出 的审计行必须带 network_id —— HTTP 集成测试(真实 Bun.serve,私有端口,临时库)。
2
+ //
3
+ // 之前这三条 logAudit 没传 networkId,audit_log.network_id 恒为 NULL:
4
+ // 按网络归属审计时它们整批丢失,而同一组路由里的 member_agent_grants_changed 一直带着。
5
+
6
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
7
+ import { mkdtempSync, rmSync } from "fs";
8
+ import { join } from "path";
9
+ import { tmpdir } from "os";
10
+ import { register } from "./auth.js";
11
+ import { db } from "./db.js";
12
+
13
+ const DIR = mkdtempSync(join(tmpdir(), "anet-member-audit-"));
14
+ let BASE = "";
15
+ let hub: any = null;
16
+
17
+ const PW = "AuditTestPassw0rd!x";
18
+ let ownerToken = "";
19
+ let NET = "";
20
+ let bobId = "";
21
+
22
+ async function send(token: string, method: string, path: string, payload?: unknown): Promise<{ status: number; body: any }> {
23
+ const res = await fetch(`${BASE}${path}`, {
24
+ method,
25
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
26
+ body: payload === undefined ? undefined : JSON.stringify(payload),
27
+ });
28
+ const text = await res.text();
29
+ let body: any = null;
30
+ try { body = JSON.parse(text); } catch {}
31
+ return { status: res.status, body };
32
+ }
33
+
34
+ const auditRow = (action: string) =>
35
+ db.get("SELECT network_id, target_id FROM audit_log WHERE action = ?1 ORDER BY id DESC LIMIT 1", action) as
36
+ { network_id: string | null; target_id: string | null } | null;
37
+
38
+ beforeAll(async () => {
39
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
40
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
41
+ process.env.HOST = "127.0.0.1";
42
+
43
+ const owner = register(`audit_owner_${Date.now()}`, PW, undefined, "Owner");
44
+ expect(owner.ok).toBe(true);
45
+ ownerToken = owner.token!;
46
+ NET = owner.network_id!;
47
+ const bob = register(`audit_bob_${Date.now()}`, PW, undefined, "Bob");
48
+ expect(bob.ok).toBe(true);
49
+ bobId = bob.user!.user_id;
50
+
51
+ const mod: any = await import("./server.js");
52
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
53
+ BASE = `http://127.0.0.1:${hub.port}`;
54
+ }, 30_000);
55
+
56
+ afterAll(() => {
57
+ try { hub?.stop?.(true); } catch {}
58
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
59
+ });
60
+
61
+ describe("成员审计行带 network_id", () => {
62
+ test("member_added", async () => {
63
+ const r = await send(ownerToken, "POST", `/api/networks/${NET}/members`, { user_id: bobId, role: "member" });
64
+ expect(r.status).toBe(200);
65
+ const row = auditRow("member_added");
66
+ expect(row).toBeTruthy();
67
+ expect(row!.network_id).toBe(NET);
68
+ });
69
+
70
+ test("member_role_changed", async () => {
71
+ const r = await send(ownerToken, "PUT", `/api/networks/${NET}/members/${bobId}`, { role: "viewer" });
72
+ expect(r.status).toBe(200);
73
+ const row = auditRow("member_role_changed");
74
+ expect(row).toBeTruthy();
75
+ expect(row!.network_id).toBe(NET);
76
+ });
77
+
78
+ test("member_removed", async () => {
79
+ const r = await send(ownerToken, "DELETE", `/api/networks/${NET}/members/${bobId}`);
80
+ expect(r.status).toBe(200);
81
+ const row = auditRow("member_removed");
82
+ expect(row).toBeTruthy();
83
+ expect(row!.network_id).toBe(NET);
84
+ });
85
+
86
+ test("GET /api/audit-log(本人的审计)里这三条的 network_id 都是该网络", async () => {
87
+ const r = await send(ownerToken, "GET", "/api/audit-log?limit=200");
88
+ expect(r.status).toBe(200);
89
+ const mine = (r.body.logs as any[]).filter(e => ["member_added", "member_role_changed", "member_removed"].includes(e.action));
90
+ expect(mine.map(e => e.action).sort()).toEqual(["member_added", "member_removed", "member_role_changed"]);
91
+ for (const e of mine) expect(e.network_id).toBe(NET);
92
+ });
93
+ });
@@ -0,0 +1,206 @@
1
+ // 人员列表的在线状态 —— HTTP 集成测试(真实 Bun.serve,私有端口,临时库)。
2
+ //
3
+ // GET /api/networks/:id/humans 给每个人加 online / last_seen_at(判据 = 此刻有没有活着的用户流
4
+ // /events/users/me);用户流上推 member_presence。两个方向都测:
5
+ // 看得到:普通成员、受限成员、viewer;连上 → online=true,断开 → online=false + last_seen_at。
6
+ // 看不到:非成员 403;节点令牌拿到的仍是原来三个身份字段;别的网络的人收不到推送;自己收不到自己的。
7
+
8
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
9
+ import { mkdtempSync, rmSync } from "fs";
10
+ import { join } from "path";
11
+ import { tmpdir } from "os";
12
+ import { register } from "./auth.js";
13
+ import { db, hashToken, generateNetworkToken, generateId } from "./db.js";
14
+
15
+ const DIR = mkdtempSync(join(tmpdir(), "anet-member-presence-"));
16
+ let BASE = "";
17
+ let hub: any = null;
18
+
19
+ const PW = "PresenceTestPassw0rd!x";
20
+ let ownerToken = "";
21
+ let NET = "";
22
+ const users: Record<string, { token: string; userId: string }> = {};
23
+ let nodeToken = "";
24
+
25
+ const auth = (token: string) => ({ Authorization: `Bearer ${token}` });
26
+
27
+ async function get(token: string, path: string): Promise<{ status: number; body: any }> {
28
+ const res = await fetch(`${BASE}${path}`, { headers: auth(token) });
29
+ const text = await res.text();
30
+ let body: any = text;
31
+ try { body = JSON.parse(text); } catch {}
32
+ return { status: res.status, body };
33
+ }
34
+ async function post(token: string, path: string, payload: unknown): Promise<{ status: number; body: any }> {
35
+ const res = await fetch(`${BASE}${path}`, { method: "POST", headers: { ...auth(token), "Content-Type": "application/json" }, body: JSON.stringify(payload) });
36
+ const text = await res.text();
37
+ let body: any = text;
38
+ try { body = JSON.parse(text); } catch {}
39
+ return { status: res.status, body };
40
+ }
41
+ async function login(username: string): Promise<{ token: string; userId: string }> {
42
+ const r = await post("", "/api/auth/login", { username, password: PW });
43
+ expect(r.status).toBe(200);
44
+ return { token: r.body.token, userId: r.body.user.user_id };
45
+ }
46
+
47
+ /** 一条用户流:收集 data: 帧,close() 模拟 app 退出。 */
48
+ async function openUserStream(token: string, networkId: string) {
49
+ const ctrl = new AbortController();
50
+ const res = await fetch(`${BASE}/events/users/me?network_id=${encodeURIComponent(networkId)}`, { headers: auth(token), signal: ctrl.signal });
51
+ expect(res.status).toBe(200);
52
+ const events: any[] = [];
53
+ const reader = res.body!.getReader();
54
+ const decoder = new TextDecoder();
55
+ let buf = "";
56
+ void (async () => {
57
+ try {
58
+ for (;;) {
59
+ const { done, value } = await reader.read();
60
+ if (done) break;
61
+ buf += decoder.decode(value);
62
+ let i: number;
63
+ while ((i = buf.indexOf("\n\n")) >= 0) {
64
+ const frame = buf.slice(0, i);
65
+ buf = buf.slice(i + 2);
66
+ for (const line of frame.split("\n")) if (line.startsWith("data: ")) events.push(JSON.parse(line.slice(6)));
67
+ }
68
+ }
69
+ } catch { /* aborted */ }
70
+ })();
71
+ await waitFor(() => events.some((e) => e.type === "connected"));
72
+ return { events, close: () => ctrl.abort(), presence: () => events.filter((e) => e.type === "member_presence") };
73
+ }
74
+
75
+ async function waitFor(cond: () => boolean | Promise<boolean>, ms = 3000): Promise<void> {
76
+ const until = Date.now() + ms;
77
+ while (Date.now() < until) {
78
+ if (await cond()) return;
79
+ await new Promise((r) => setTimeout(r, 20));
80
+ }
81
+ throw new Error("waitFor timed out");
82
+ }
83
+
84
+ const humanOf = async (token: string, userId: string) =>
85
+ (await get(token, `/api/networks/${NET}/humans`)).body.humans.find((h: any) => h.user_id === userId);
86
+
87
+ beforeAll(async () => {
88
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
89
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
90
+ process.env.HOST = "127.0.0.1";
91
+
92
+ const owner = register(`pres_owner_${Date.now()}`, PW, undefined, "Owner");
93
+ expect(owner.ok).toBe(true);
94
+ ownerToken = owner.token!;
95
+ NET = owner.network_id!;
96
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [owner.user!.user_id]);
97
+ users.owner = { token: ownerToken, userId: owner.user!.user_id };
98
+
99
+ const mod: any = await import("./server.js");
100
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
101
+ BASE = `http://127.0.0.1:${hub.port}`;
102
+
103
+ // 受限成员(默认 agent_access=granted)、viewer、普通成员;外人自己一个网络。
104
+ for (const [name, role] of [["pres_alpha", "member"], ["pres_beta", "member"], ["pres_gamma", "viewer"]] as const) {
105
+ const r = await post(ownerToken, "/api/admin/users", { username: name, password: PW, network_id: NET, role });
106
+ expect(r.status).toBe(200);
107
+ users[name] = await login(name);
108
+ }
109
+ const outsider = register(`pres_out_${Date.now()}`, PW);
110
+ expect(outsider.ok).toBe(true);
111
+ users.outsider = { token: outsider.token!, userId: outsider.user!.user_id };
112
+ users.outsider_net = { token: outsider.token!, userId: outsider.network_id! };
113
+
114
+ nodeToken = generateNetworkToken();
115
+ db.run("INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, 'pres-node', 'network')",
116
+ [generateId("tok"), hashToken(nodeToken), users.owner.userId, NET]);
117
+ }, 30_000);
118
+
119
+ afterAll(() => {
120
+ try { hub?.stop?.(true); } catch {}
121
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
122
+ });
123
+
124
+ describe("GET /api/networks/:id/humans — online / last_seen_at", () => {
125
+ test("never connected since hub start → online=false, last_seen_at=null (unknown, not 'long ago')", async () => {
126
+ const r = await get(users.pres_alpha.token, `/api/networks/${NET}/humans`);
127
+ expect(r.status).toBe(200);
128
+ expect(Object.keys(r.body.humans[0]).sort()).toEqual(["display_name", "last_seen_at", "online", "user_id", "username"]);
129
+ const beta = r.body.humans.find((h: any) => h.user_id === users.pres_beta.userId);
130
+ expect(beta.online).toBe(false);
131
+ expect(beta.last_seen_at).toBeNull();
132
+ });
133
+
134
+ test("user stream open → online for member, restricted member and viewer; closed → offline with last_seen_at", async () => {
135
+ const watcher = await openUserStream(users.pres_alpha.token, NET);
136
+ const before = Date.now();
137
+ const beta = await openUserStream(users.pres_beta.token, NET);
138
+
139
+ for (const viewer of ["pres_alpha", "pres_gamma", "owner"]) {
140
+ const h = await humanOf(users[viewer].token, users.pres_beta.userId);
141
+ expect(h.online).toBe(true);
142
+ expect(Date.parse(h.last_seen_at)).toBeGreaterThanOrEqual(before - 1000);
143
+ }
144
+ // 推送:alpha 收到 beta 上线;beta 收不到自己的。
145
+ await waitFor(() => watcher.presence().some((e) => e.member_user_id === users.pres_beta.userId && e.online === true));
146
+ const up = watcher.presence().find((e) => e.member_user_id === users.pres_beta.userId)!;
147
+ expect(up.network_id).toBe(NET);
148
+ expect(up.user_id).toBe(users.pres_alpha.userId); // 收件人
149
+ expect(beta.presence().filter((e) => e.member_user_id === users.pres_beta.userId)).toEqual([]);
150
+
151
+ beta.close();
152
+ await waitFor(async () => (await humanOf(users.pres_alpha.token, users.pres_beta.userId)).online === false);
153
+ const off = await humanOf(users.pres_gamma.token, users.pres_beta.userId);
154
+ expect(off.online).toBe(false);
155
+ expect(Date.parse(off.last_seen_at)).toBeGreaterThanOrEqual(before - 1000);
156
+ await waitFor(() => watcher.presence().some((e) => e.member_user_id === users.pres_beta.userId && e.online === false));
157
+ const down = watcher.presence().find((e) => e.member_user_id === users.pres_beta.userId && e.online === false)!;
158
+ expect(typeof down.last_seen_at).toBe("string");
159
+ watcher.close();
160
+ });
161
+
162
+ test("two streams for one user: closing one keeps them online, no offline event", async () => {
163
+ const watcher = await openUserStream(users.pres_alpha.token, NET);
164
+ const a = await openUserStream(users.pres_beta.token, NET);
165
+ const b = await openUserStream(users.pres_beta.token, NET);
166
+ await waitFor(() => watcher.presence().some((e) => e.member_user_id === users.pres_beta.userId && e.online));
167
+ a.close();
168
+ await new Promise((r) => setTimeout(r, 200));
169
+ expect((await humanOf(users.pres_alpha.token, users.pres_beta.userId)).online).toBe(true);
170
+ expect(watcher.presence().filter((e) => e.member_user_id === users.pres_beta.userId && !e.online)).toEqual([]);
171
+ b.close();
172
+ await waitFor(async () => (await humanOf(users.pres_alpha.token, users.pres_beta.userId)).online === false);
173
+ watcher.close();
174
+ });
175
+ });
176
+
177
+ describe("who must NOT see presence", () => {
178
+ test("non-member → 403, no humans at all", async () => {
179
+ const r = await get(users.outsider.token, `/api/networks/${NET}/humans`);
180
+ expect(r.status).toBe(403);
181
+ expect(r.body.humans).toBeUndefined();
182
+ });
183
+
184
+ test("node token → same three identity fields as before, no online / last_seen_at", async () => {
185
+ const beta = await openUserStream(users.pres_beta.token, NET);
186
+ const r = await get(nodeToken, `/api/networks/${NET}/humans`);
187
+ expect(r.status).toBe(200);
188
+ for (const h of r.body.humans) expect(Object.keys(h).sort()).toEqual(["display_name", "user_id", "username"]);
189
+ // 节点令牌也不能订阅用户流(既有行为,这里钉住:不能借它收 member_presence)。
190
+ expect((await fetch(`${BASE}/events/users/me?network_id=${NET}`, { headers: auth(nodeToken) })).status).toBe(403);
191
+ beta.close();
192
+ await waitFor(async () => (await humanOf(users.pres_alpha.token, users.pres_beta.userId)).online === false);
193
+ });
194
+
195
+ test("a user connected in another network gets no member_presence about this network's people", async () => {
196
+ const outsiderStream = await openUserStream(users.outsider.token, users.outsider_net.userId);
197
+ const watcher = await openUserStream(users.pres_alpha.token, NET);
198
+ const beta = await openUserStream(users.pres_beta.token, NET);
199
+ await waitFor(() => watcher.presence().some((e) => e.member_user_id === users.pres_beta.userId && e.online));
200
+ beta.close();
201
+ await waitFor(() => watcher.presence().some((e) => e.member_user_id === users.pres_beta.userId && !e.online));
202
+ expect(outsiderStream.presence()).toEqual([]);
203
+ watcher.close();
204
+ outsiderStream.close();
205
+ });
206
+ });
@@ -0,0 +1,142 @@
1
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "fs";
3
+ import { join } from "path";
4
+ import { tmpdir } from "os";
5
+ import { register } from "./auth.js";
6
+ import { db } from "./db.js";
7
+
8
+ // #1909 —— POST /api/messages/ack 的 `{ agent }` 形态:把调用者收到的、来自该 agent 的
9
+ // 两张表(user_inbox + inbox reply/task/message)全部标已读。角标分母 unread_by_agent
10
+ // 数的是全集,按 id 只能 ack 拉到的那一页 ⇒ 老行永远清不掉,这一形态补上。
11
+ const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-ack-by-agent-http-"));
12
+ let server: any;
13
+ let base = "";
14
+ let tokenA = "";
15
+ let tokenB = "";
16
+ let userA = "";
17
+ let userB = "";
18
+ let userIdA = "";
19
+ let userIdB = "";
20
+ let netA = "";
21
+ let netB = "";
22
+
23
+ const AGENT = "ack-agent-x";
24
+ const OTHER = "ack-agent-y";
25
+
26
+ function seed() {
27
+ const ui = (id: string, net: string, uid: string, from: string) =>
28
+ db.run(
29
+ `INSERT INTO user_inbox (message_id, network_id, user_id, from_session, kind, content) VALUES (?1, ?2, ?3, ?4, 'agent_message', 'x')`,
30
+ [id, net, uid, from],
31
+ );
32
+ const ib = (id: string, net: string, to: string, from: string, type: string) =>
33
+ db.run(
34
+ `INSERT INTO inbox (id, session_name, type, content, from_session, network_id) VALUES (?1, ?2, ?3, 'x', ?4, ?5)`,
35
+ [id, to, type, from, net],
36
+ );
37
+ ui("ui_a_x_1", netA, userIdA, AGENT);
38
+ ui("ui_a_x_2", netA, userIdA, AGENT);
39
+ ui("ui_a_y_1", netA, userIdA, OTHER);
40
+ ui("ui_b_x_1", netB, userIdB, AGENT);
41
+ ib("ib_a_x_reply", netA, userA, AGENT, "reply");
42
+ ib("ib_a_x_task", netA, userA, AGENT, "task");
43
+ ib("ib_a_x_status", netA, userA, AGENT, "status"); // 不在 reply/task/message 内,不该被 ack
44
+ ib("ib_a_y_reply", netA, userA, OTHER, "reply");
45
+ ib("ib_b_x_reply", netB, userB, AGENT, "reply");
46
+ }
47
+
48
+ beforeAll(async () => {
49
+ process.env.COMMHUB_DB = process.env.COMMHUB_DB || join(PRIVATE_DB_DIR, "hub.db");
50
+ const stamp = `${Date.now()}_${process.pid}`;
51
+ userA = `ack_agent_a_${stamp}`;
52
+ userB = `ack_agent_b_${stamp}`;
53
+ const a = register(userA, "AckAgent123!", undefined, "seed");
54
+ const b = register(userB, "AckAgent123!", undefined, "seed");
55
+ expect(a.ok).toBe(true);
56
+ expect(b.ok).toBe(true);
57
+ tokenA = a.token!; netA = a.network_id!;
58
+ tokenB = b.token!; netB = b.network_id!;
59
+ userIdA = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", [userA])!.user_id;
60
+ userIdB = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", [userB])!.user_id;
61
+ seed();
62
+ const mod: any = await import("./server.js");
63
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
64
+ base = `http://127.0.0.1:${server.port}`;
65
+ }, 30_000);
66
+
67
+ afterAll(() => {
68
+ try { server?.stop?.(true); } catch {}
69
+ try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
70
+ });
71
+
72
+ async function ack(token: string, body: unknown): Promise<{ status: number; body: any }> {
73
+ const response = await fetch(`${base}/api/messages/ack`, {
74
+ method: "POST",
75
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
76
+ body: JSON.stringify(body),
77
+ });
78
+ return { status: response.status, body: await response.json() };
79
+ }
80
+
81
+ const uiAcked = (id: string) => db.get<{ acked: number }>("SELECT acked FROM user_inbox WHERE message_id = ?1", [id])!.acked;
82
+ const ibAcked = (id: string) => db.get<{ acked: number }>("SELECT acked FROM inbox WHERE id = ?1", [id])!.acked;
83
+
84
+ async function unreadByAgent(token: string): Promise<Record<string, number>> {
85
+ const response = await fetch(`${base}/api/messages?scope=user&limit=5`, { headers: { Authorization: `Bearer ${token}` } });
86
+ expect(response.status).toBe(200);
87
+ return ((await response.json()) as any).unread_by_agent ?? {};
88
+ }
89
+
90
+ describe("POST /api/messages/ack { agent } (#1909)", () => {
91
+ test("missing everything → message_id_required; agent + ids → ambiguous_ack", async () => {
92
+ const none = await ack(tokenA, {});
93
+ expect(none.status).toBe(400);
94
+ expect(none.body.error).toBe("message_id_required");
95
+ const both = await ack(tokenA, { agent: AGENT, message_ids: ["ui_a_x_1"] });
96
+ expect(both.status).toBe(400);
97
+ expect(both.body.error).toBe("ambiguous_ack");
98
+ // 拒绝的那次不能有副作用
99
+ expect(uiAcked("ui_a_x_1")).toBe(0);
100
+ });
101
+
102
+ test("unread_by_agent counts the seeded rows before ack", async () => {
103
+ const by = await unreadByAgent(tokenA);
104
+ expect(by[AGENT]).toBe(4); // 2 user_inbox + reply + task (status 不计)
105
+ expect(by[OTHER]).toBe(2);
106
+ });
107
+
108
+ test("agent form acks both tables, only that agent, only the caller", async () => {
109
+ const r = await ack(tokenA, { agent: ` ${AGENT} ` });
110
+ expect(r.status).toBe(200);
111
+ expect(r.body).toMatchObject({ ok: true, scope: "agent", agent: AGENT, acked: 4, acked_user_inbox: 2, acked_inbox: 2 });
112
+ expect(uiAcked("ui_a_x_1")).toBe(1);
113
+ expect(uiAcked("ui_a_x_2")).toBe(1);
114
+ expect(ibAcked("ib_a_x_reply")).toBe(1);
115
+ expect(ibAcked("ib_a_x_task")).toBe(1);
116
+ // 不该动的:别的 agent、非 reply/task/message 类型、别的用户
117
+ expect(uiAcked("ui_a_y_1")).toBe(0);
118
+ expect(ibAcked("ib_a_x_status")).toBe(0);
119
+ expect(ibAcked("ib_a_y_reply")).toBe(0);
120
+ expect(uiAcked("ui_b_x_1")).toBe(0);
121
+ expect(ibAcked("ib_b_x_reply")).toBe(0);
122
+ const by = await unreadByAgent(tokenA);
123
+ expect(by[AGENT] ?? 0).toBe(0);
124
+ expect(by[OTHER]).toBe(2);
125
+ expect((await unreadByAgent(tokenB))[AGENT]).toBe(2);
126
+ });
127
+
128
+ test("agent form is idempotent: second call changes 0 rows", async () => {
129
+ const r = await ack(tokenA, { agent: AGENT });
130
+ expect(r.status).toBe(200);
131
+ expect(r.body.acked).toBe(0);
132
+ });
133
+
134
+ test("id form still works and now reports scope ids", async () => {
135
+ const r = await ack(tokenA, { message_ids: ["ui_a_y_1", "ib_a_y_reply", "ui_b_x_1"] });
136
+ expect(r.status).toBe(200);
137
+ expect(r.body).toMatchObject({ ok: true, scope: "ids", acked: 2, acked_user_inbox: 1, acked_inbox: 1 });
138
+ expect(uiAcked("ui_a_y_1")).toBe(1);
139
+ expect(ibAcked("ib_a_y_reply")).toBe(1);
140
+ expect(uiAcked("ui_b_x_1")).toBe(0); // 别人的 id 匹配不到
141
+ });
142
+ });
@@ -0,0 +1,47 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { MigrateRefused, migrateSqliteToPg } from "./migrate-sqlite-to-pg";
6
+
7
+ // Only the refusals that happen before any connection is attempted; the full
8
+ // copy runs against a real PostgreSQL in test2123 (migrate-stage.sh).
9
+ const home = mkdtempSync(join(tmpdir(), "anet-migrate-home-"));
10
+ const commhub = join(home, ".commhub");
11
+ mkdirSync(commhub);
12
+ writeFileSync(join(commhub, "commhub.db"), "");
13
+ const elsewhere = join(home, "copies");
14
+ mkdirSync(elsewhere);
15
+ symlinkSync(join(commhub, "commhub.db"), join(elsewhere, "link.db"));
16
+ const PG = "postgres://user:s3cret@127.0.0.1:1/anet_never_test";
17
+
18
+ afterAll(() => rmSync(home, { recursive: true, force: true }));
19
+
20
+ async function refusal(opts: Parameters<typeof migrateSqliteToPg>[0]): Promise<string> {
21
+ try {
22
+ await migrateSqliteToPg({ ...opts, home, log: () => {} });
23
+ } catch (e) {
24
+ expect(e).toBeInstanceOf(MigrateRefused);
25
+ return String((e as Error).message);
26
+ }
27
+ throw new Error("expected a refusal");
28
+ }
29
+
30
+ describe("RFC-039 S5 migrate-to-pg refusals", () => {
31
+ test("target must be a postgres URL", async () => {
32
+ expect(await refusal({ from: join(elsewhere, "x.db"), to: "mysql://h/db" })).toContain("--to must be a postgres");
33
+ });
34
+
35
+ test("missing source", async () => {
36
+ expect(await refusal({ from: join(elsewhere, "missing.db"), to: PG })).toContain("does not exist");
37
+ });
38
+
39
+ test("the live Hub directory ~/.commhub is refused, also through a symlink", async () => {
40
+ for (const from of [join(commhub, "commhub.db"), join(elsewhere, "link.db")]) {
41
+ const message = await refusal({ from, to: PG });
42
+ expect(message).toContain("live Hub's directory");
43
+ expect(message).toContain("--i-know-this-is-a-copy");
44
+ expect(message).not.toContain("s3cret");
45
+ }
46
+ });
47
+ });