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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (317) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +99 -11
  3. package/package.json +7 -7
  4. package/src/ack-create-request-transport.test.ts +182 -0
  5. package/src/ack-create-request.test.ts +50 -7
  6. package/src/admin-delete-network-http.test.ts +228 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/adoption-candidates-http.test.ts +137 -0
  9. package/src/adoption-candidates.ts +158 -0
  10. package/src/agent-access-batch.test.ts +210 -0
  11. package/src/agent-access.ts +487 -0
  12. package/src/agent-acl-groups-http.test.ts +247 -0
  13. package/src/agent-acl-http.test.ts +659 -0
  14. package/src/agent-acl-slice3-http.test.ts +267 -0
  15. package/src/agent-groups-perf.test.ts +223 -0
  16. package/src/agent-notice.ts +48 -0
  17. package/src/agent-team-whoami-http.test.ts +98 -0
  18. package/src/agent-teams-http.test.ts +323 -0
  19. package/src/agent-teams.ts +244 -0
  20. package/src/alias-filter.test.ts +72 -0
  21. package/src/alias-filter.ts +42 -0
  22. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  23. package/src/api-host-supervisors-fallback.test.ts +271 -0
  24. package/src/api-host-supervisors-runtime-readiness.test.ts +195 -0
  25. package/src/api-nodes-shape.test.ts +26 -10
  26. package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
  27. package/src/auth-sessions-http.test.ts +250 -0
  28. package/src/auth-validate.test.ts +5 -1
  29. package/src/auth-weak-password-login.test.ts +176 -0
  30. package/src/auth.ts +598 -49
  31. package/src/avatar-validate.test.ts +163 -0
  32. package/src/avatar-validate.ts +103 -0
  33. package/src/binding-generation-http.test.ts +121 -0
  34. package/src/blocked-exit-on-reply.test.ts +122 -0
  35. package/src/boot-side-effect.test.ts +124 -0
  36. package/src/clone-node.test.ts +248 -0
  37. package/src/clone-node.ts +289 -0
  38. package/src/codex-config-flags.ts +77 -0
  39. package/src/codex-fork-contract.ts +23 -0
  40. package/src/codex-fork-migration.test.ts +38 -0
  41. package/src/commhub-bin-args.test.ts +85 -0
  42. package/src/config-apply-sec1.test.ts +107 -0
  43. package/src/config-apply-validate.test.ts +134 -1
  44. package/src/config-apply-validate.ts +111 -5
  45. package/src/create-node-blocked-gate.test.ts +356 -0
  46. package/src/create-node-network-id.test.ts +79 -0
  47. package/src/create-node-tool-schema.test.ts +48 -0
  48. package/src/create-node-unicode-name.test.ts +120 -0
  49. package/src/create-node-validate.test.ts +193 -7
  50. package/src/create-node-validate.ts +227 -15
  51. package/src/create-node-workdir.test.ts +289 -0
  52. package/src/create-node.ts +77 -23
  53. package/src/daemon-provider-snapshots.test.ts +421 -0
  54. package/src/daemon-provider-snapshots.ts +327 -0
  55. package/src/daemon-token-owner-http.test.ts +236 -0
  56. package/src/dashboard-slash-routing-http.test.ts +107 -0
  57. package/src/db-adapter-guard.test.ts +148 -0
  58. package/src/db-adapter-translate.test.ts +128 -0
  59. package/src/db-adapter.ts +323 -87
  60. package/src/db-timestamp.test.ts +38 -0
  61. package/src/db-timestamp.ts +15 -0
  62. package/src/db.ts +930 -35
  63. package/src/delivered-without-push.test.ts +140 -0
  64. package/src/department-groups-http.test.ts +209 -0
  65. package/src/department-groups-sync-http.test.ts +328 -0
  66. package/src/department-groups.ts +434 -0
  67. package/src/department-heads-http.test.ts +330 -0
  68. package/src/department-heads.ts +226 -0
  69. package/src/departments-http.test.ts +185 -0
  70. package/src/departments.ts +260 -0
  71. package/src/desktop-message-seam.test.ts +150 -0
  72. package/src/desktop-message.test.ts +368 -0
  73. package/src/dm-file-scope-http.test.ts +178 -0
  74. package/src/ext-token-shared.test.ts +60 -0
  75. package/src/ext-validation-regression.test.ts +131 -0
  76. package/src/external-schedule-edits-http.test.ts +321 -0
  77. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  78. package/src/external-schedule-edits.ts +327 -0
  79. package/src/file-download-authz-dev-open.test.ts +135 -0
  80. package/src/file-download-authz.test.ts +404 -0
  81. package/src/file-download-date-bucket.test.ts +436 -0
  82. package/src/file-network-scope-dev-open.test.ts +97 -0
  83. package/src/file-network-scope.test.ts +732 -0
  84. package/src/group-membership-events-http.test.ts +264 -0
  85. package/src/group-messages-http.test.ts +482 -0
  86. package/src/group-messages.ts +297 -0
  87. package/src/health-redaction.test.ts +642 -0
  88. package/src/http-gzip.test.ts +94 -0
  89. package/src/http-gzip.ts +112 -0
  90. package/src/hub-provider-config.test.ts +139 -0
  91. package/src/hub-provider-config.ts +105 -0
  92. package/src/hub-timestamp.test.ts +38 -0
  93. package/src/hub-timestamp.ts +27 -0
  94. package/src/human-dm.ts +215 -0
  95. package/src/inbox-caller.ts +49 -0
  96. package/src/inbox-count-sse.test.ts +310 -0
  97. package/src/inbox-count.ts +29 -0
  98. package/src/index.ts +15 -2362
  99. package/src/lifecycle-guard.ts +1 -1
  100. package/src/mcp-context-cost-http.test.ts +123 -0
  101. package/src/mcp-projects-events-http.test.ts +162 -0
  102. package/src/mcp-route-label-http.test.ts +120 -0
  103. package/src/mcp-write-network-resolution.test.ts +432 -0
  104. package/src/member-audit-network-id-http.test.ts +93 -0
  105. package/src/member-presence-http.test.ts +210 -0
  106. package/src/messages-ack-by-agent-http.test.ts +142 -0
  107. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  108. package/src/migrate-sqlite-to-pg.ts +263 -0
  109. package/src/model-auth-notify-http.test.ts +181 -0
  110. package/src/model-auth-notify.ts +116 -0
  111. package/src/network-name-http.test.ts +75 -0
  112. package/src/network-scope.ts +276 -0
  113. package/src/network-token-membership-http.test.ts +139 -0
  114. package/src/node-alias-control.ts +12 -0
  115. package/src/node-attrs-validate.ts +88 -0
  116. package/src/node-attrs.test.ts +217 -0
  117. package/src/node-caller-identity-http.test.ts +781 -0
  118. package/src/node-create-request-status-http.test.ts +105 -0
  119. package/src/node-daemon-bindings.test.ts +132 -0
  120. package/src/node-daemon-bindings.ts +130 -0
  121. package/src/node-files-transport.test.ts +344 -0
  122. package/src/node-health-codex-login.test.ts +120 -0
  123. package/src/node-health-dispatch-http.test.ts +279 -0
  124. package/src/node-health-guard.ts +105 -0
  125. package/src/node-health-passthrough.test.ts +115 -0
  126. package/src/node-health-store.ts +101 -0
  127. package/src/node-id-alias.test.ts +190 -0
  128. package/src/node-identity-conflict-http.test.ts +269 -0
  129. package/src/node-identity-conflict.ts +142 -0
  130. package/src/node-identity-mismatch.test.ts +74 -0
  131. package/src/node-lifecycle-controllable.test.ts +64 -0
  132. package/src/node-lifecycle-controllable.ts +53 -0
  133. package/src/node-lifecycle-read-http.test.ts +245 -0
  134. package/src/node-lifecycle-read.ts +121 -0
  135. package/src/node-logs-transport.test.ts +263 -0
  136. package/src/node-permissions-http.test.ts +276 -0
  137. package/src/node-permissions.ts +285 -0
  138. package/src/node-request-retention.test.ts +281 -0
  139. package/src/node-request-retention.ts +120 -0
  140. package/src/node-skills-transport.test.ts +209 -0
  141. package/src/node-token-ownership.ts +51 -0
  142. package/src/node-token-privilege-http.test.ts +158 -0
  143. package/src/observer-avatar-http.test.ts +431 -0
  144. package/src/observer-push.test.ts +252 -0
  145. package/src/opencode-v2-catalog.test.ts +268 -0
  146. package/src/opencode-v2-catalog.ts +352 -0
  147. package/src/package-version-consistency.test.ts +25 -0
  148. package/src/peer-reply-atomic.test.ts +455 -0
  149. package/src/peer-reply-capability.test.ts +70 -0
  150. package/src/pg-worker.ts +87 -0
  151. package/src/project-dir-warn-clear-http.test.ts +132 -0
  152. package/src/project-viewer-can-http.test.ts +114 -0
  153. package/src/push.ts +429 -10
  154. package/src/redact-tokens.test.ts +65 -0
  155. package/src/redact-tokens.ts +40 -0
  156. package/src/rename.ts +22 -2
  157. package/src/reply-target-derivation.test.ts +96 -0
  158. package/src/report-status-handover-capabilities-http.test.ts +124 -0
  159. package/src/report-status-host-schema.test.ts +235 -0
  160. package/src/report-status-resume-id-handover.test.ts +150 -0
  161. package/src/require-explicit-test-db.ts +9 -0
  162. package/src/requirement-due-reminders-http.test.ts +620 -0
  163. package/src/requirement-due-reminders.ts +416 -0
  164. package/src/requirement-events-http.test.ts +268 -0
  165. package/src/requirement-events.test.ts +45 -0
  166. package/src/requirement-events.ts +144 -0
  167. package/src/requirement-notify.ts +165 -0
  168. package/src/requirement-participant-notify-http.test.ts +309 -0
  169. package/src/requirement-tag-ops-http.test.ts +266 -0
  170. package/src/requirement-tags.ts +89 -0
  171. package/src/requirements-abandoned-http.test.ts +236 -0
  172. package/src/requirements-comment-http.test.ts +167 -0
  173. package/src/requirements-completed-at-migration.test.ts +70 -0
  174. package/src/requirements-errors-mcp-http.test.ts +164 -0
  175. package/src/requirements-errors.ts +143 -0
  176. package/src/requirements-etag-http.test.ts +91 -0
  177. package/src/requirements-http.test.ts +709 -0
  178. package/src/requirements-last-event-http.test.ts +349 -0
  179. package/src/requirements-list-mcp-slim-http.test.ts +155 -0
  180. package/src/requirements-list-slim-http.test.ts +319 -0
  181. package/src/requirements-mcp.test.ts +163 -0
  182. package/src/requirements-migrate.ts +259 -0
  183. package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
  184. package/src/requirements-people-http.test.ts +114 -0
  185. package/src/requirements-people-mcp-http.test.ts +225 -0
  186. package/src/requirements-people.ts +127 -0
  187. package/src/requirements-priority-lowest.test.ts +114 -0
  188. package/src/requirements-search-http.test.ts +182 -0
  189. package/src/requirements-search.test.ts +32 -0
  190. package/src/requirements-search.ts +145 -0
  191. package/src/requirements-seq-http.test.ts +193 -0
  192. package/src/requirements-seq-migration.test.ts +63 -0
  193. package/src/requirements-start-date.test.ts +125 -0
  194. package/src/requirements-stats-http.test.ts +288 -0
  195. package/src/requirements-stats.ts +167 -0
  196. package/src/requirements-status-participants-http.test.ts +222 -0
  197. package/src/requirements-upgrade-e2e.test.ts +126 -0
  198. package/src/requirements-upgrade-migration.test.ts +98 -0
  199. package/src/requirements.ts +1806 -0
  200. package/src/resolve-port.test.ts +57 -0
  201. package/src/resolve-port.ts +44 -0
  202. package/src/response-charset.test.ts +130 -0
  203. package/src/rest-explicit-columns-http.test.ts +222 -0
  204. package/src/rest-identity.test.ts +129 -0
  205. package/src/rest-identity.ts +125 -0
  206. package/src/rest-projections.ts +68 -0
  207. package/src/rest-write-network-resolution.test.ts +133 -0
  208. package/src/restricted-files.ts +76 -0
  209. package/src/retention.ts +15 -3
  210. package/src/route-callers-http.test.ts +120 -0
  211. package/src/route-timing.test.ts +211 -0
  212. package/src/route-timing.ts +258 -0
  213. package/src/rules-file-duplicate-alias.test.ts +234 -0
  214. package/src/rules-file-session-target.test.ts +207 -0
  215. package/src/rules-file-transport.test.ts +237 -0
  216. package/src/runtime-label.test.ts +22 -0
  217. package/src/runtime-label.ts +21 -0
  218. package/src/runtime-readiness.ts +90 -0
  219. package/src/schedule-agent-mcp-http.test.ts +381 -0
  220. package/src/schedule-agent.ts +192 -0
  221. package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
  222. package/src/scheduled-failures-http.test.ts +233 -0
  223. package/src/scheduled-failures.ts +179 -0
  224. package/src/scheduled-offline.test.ts +131 -0
  225. package/src/scheduled-offline.ts +66 -0
  226. package/src/scheduled-reply-unread-http.test.ts +212 -0
  227. package/src/scheduled-run-terminal.test.ts +268 -0
  228. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  229. package/src/scheduled-stuck-http.test.ts +238 -0
  230. package/src/scheduled-stuck.ts +153 -0
  231. package/src/scheduled-tasks-http.test.ts +638 -0
  232. package/src/scheduled-tasks.ts +766 -0
  233. package/src/scoped-secrets.ts +124 -0
  234. package/src/send-dedup-request-id.test.ts +229 -0
  235. package/src/send-reply-agent-warning.test.ts +202 -0
  236. package/src/send-reply-alias-optional.test.ts +131 -0
  237. package/src/send-reply-attachments.test.ts +458 -0
  238. package/src/send_dedup.test.ts +54 -0
  239. package/src/send_dedup.ts +52 -6
  240. package/src/serve-error.test.ts +38 -0
  241. package/src/serve-error.ts +27 -0
  242. package/src/server.ts +5257 -0
  243. package/src/session-task-on-dispatch.ts +14 -0
  244. package/src/session-token-ownership-http.test.ts +122 -0
  245. package/src/shared/codex-inventory.ts +41 -0
  246. package/src/shared/daemon-provider-login.ts +31 -0
  247. package/src/shared/daemon-provider-snapshot.ts +52 -0
  248. package/src/shared/external-schedule-contract.ts +60 -0
  249. package/src/shared/node-name.test.ts +62 -0
  250. package/src/shared/node-name.ts +161 -0
  251. package/src/shared/provider-application.ts +29 -0
  252. package/src/shared/provider-collection.ts +14 -0
  253. package/src/shared/provider-initialization.ts +8 -0
  254. package/src/shared/reserved-env-drift.test.ts +10 -0
  255. package/src/shared/scoped-secret-contract.ts +5 -0
  256. package/src/shared/task-content-limit-drift.test.ts +28 -0
  257. package/src/shared/task-content-limit.ts +25 -0
  258. package/src/side-thread-command-transport.test.ts +312 -0
  259. package/src/side-thread-command-transport.ts +318 -0
  260. package/src/side-thread-http-integration.test.ts +233 -0
  261. package/src/side-thread-http.ts +504 -0
  262. package/src/side-thread-production.test.ts +72 -0
  263. package/src/side-thread-production.ts +118 -0
  264. package/src/side-thread.test.ts +1067 -0
  265. package/src/side-thread.ts +2499 -0
  266. package/src/skillhub-http.test.ts +108 -0
  267. package/src/sqlite-close-checkpoint.test.ts +78 -0
  268. package/src/start-node.test.ts +264 -0
  269. package/src/status-alias-filter-http.test.ts +106 -0
  270. package/src/status-alias-resolver-http.test.ts +163 -0
  271. package/src/status-alias-resolver.ts +19 -0
  272. package/src/status-node-id-filter-http.test.ts +161 -0
  273. package/src/status-read-cache-http.test.ts +229 -0
  274. package/src/status-read-cache.ts +122 -0
  275. package/src/stop-delete-node.test.ts +509 -1
  276. package/src/task-access-http.test.ts +402 -0
  277. package/src/task-access.ts +420 -0
  278. package/src/task-auth-origin-http.test.ts +91 -0
  279. package/src/task-auth-origin.test.ts +24 -0
  280. package/src/task-auth-origin.ts +7 -0
  281. package/src/task-consumption.test.ts +482 -0
  282. package/src/task-diagnostic-http.test.ts +149 -0
  283. package/src/task-diagnostic.test.ts +115 -0
  284. package/src/task-diagnostic.ts +118 -0
  285. package/src/task-expiry-notice-http.test.ts +287 -0
  286. package/src/task-expiry-notice.ts +315 -0
  287. package/src/task-idempotency-mcp.test.ts +142 -0
  288. package/src/task-idempotency.test.ts +60 -0
  289. package/src/task-idempotency.ts +75 -0
  290. package/src/task-late-replies.ts +54 -0
  291. package/src/task-late-reply-http.test.ts +152 -0
  292. package/src/task-lifecycle-watcher.test.ts +204 -0
  293. package/src/task-lifecycle-watcher.ts +49 -0
  294. package/src/task-network-resolution-http.test.ts +141 -0
  295. package/src/task-orphan-http.test.ts +287 -0
  296. package/src/task-orphan.ts +119 -0
  297. package/src/task-queue-ahead-http.test.ts +309 -0
  298. package/src/task-queue-ahead-scale.test.ts +236 -0
  299. package/src/task-queue-ahead.ts +186 -0
  300. package/src/task-rejection-log-http.test.ts +160 -0
  301. package/src/task-rejection-log.ts +49 -0
  302. package/src/task-stale-open-http.test.ts +290 -0
  303. package/src/task-stale-open.ts +103 -0
  304. package/src/tasks-pagination-http.test.ts +141 -0
  305. package/src/tool-audience-http.test.ts +220 -0
  306. package/src/tool-audience.ts +118 -0
  307. package/src/tools-list-json-schema.test.ts +67 -0
  308. package/src/tools.ts +3927 -323
  309. package/src/upload-early-reject-guard.test.ts +155 -0
  310. package/src/uploads-http.test.ts +97 -15
  311. package/src/uploads.ts +103 -6
  312. package/src/user-avatar-http.test.ts +74 -0
  313. package/src/user-avatar-migration.test.ts +25 -0
  314. package/src/user-inbox-by-agent-http.test.ts +161 -0
  315. package/src/user-inbox-netid-notnull.test.ts +122 -0
  316. package/src/user-inbox-read-http.test.ts +223 -0
  317. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,161 @@
1
+ // #1828 —— /api/messages?scope=user 按 agent 分的未读 + 用户 ack 覆盖 inbox 里的回复行。
2
+ //
3
+ // 生产 hub 24h:admin 收到的 70 条 agent 回复全在 `inbox`(session_name='admin', type='reply',
4
+ // acked 永远 0),`user_inbox` 只有 2 行;角标链只读 user_inbox,所以回复从不进未读(app#260)。
5
+ // 这里让 scope=user 同时给 `unread_by_agent`(两表合并)与 `unread_total`,`unread`/`pending_count`
6
+ // 口径不动(qa-hub-14 钉着);POST /api/messages/ack 也能 ack inbox 里发给调用者用户名的回复行。
7
+ //
8
+ // 承重的是两条边界:
9
+ // ① 只算/只 ack **调用者自己用户名** 的 inbox 行 —— B 拿 A 的 id 来 ack 匹配不到;
10
+ // ② 用户名与某节点 alias 撞名时,inbox 那半边整体跳过(那是节点的待办,不是用户的)。
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
+
16
+ const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-user-inbox-by-agent-"));
17
+ process.env.COMMHUB_DB ||= join(PRIVATE_DB_DIR, "hub.db");
18
+
19
+ let server: ReturnType<typeof Bun.serve>;
20
+ let base = "";
21
+ let aToken = "", bToken = "", cToken = "";
22
+ let aUserId = "", bUserId = "", cUserId = "", aNet = "", cNet = "";
23
+ let aName = "", bName = "", cName = "";
24
+
25
+ const get = async (path: string, token: string) => {
26
+ const r = await fetch(`${base}${path}`, { headers: { Authorization: `Bearer ${token}` } });
27
+ return { status: r.status, body: await r.json() as any };
28
+ };
29
+ const post = async (path: string, token: string, body: unknown) => {
30
+ const r = await fetch(`${base}${path}`, {
31
+ method: "POST",
32
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
33
+ body: JSON.stringify(body),
34
+ });
35
+ return { status: r.status, body: await r.json() as any };
36
+ };
37
+
38
+ function seedUserInbox(mid: string, userId: string, netId: string, from: string) {
39
+ const { db } = require("./db.js");
40
+ db.run(
41
+ `INSERT INTO user_inbox (message_id, network_id, user_id, from_session, kind, title, content, severity, meta_json, acked)
42
+ VALUES (?1, ?2, ?3, ?4, 'agent_message', NULL, 'dm', 'info', NULL, 0)`,
43
+ [mid, netId, userId, from],
44
+ );
45
+ }
46
+ function seedInbox(id: string, sessionName: string, netId: string, from: string, type = "reply", acked = 0) {
47
+ const { db } = require("./db.js");
48
+ db.run(
49
+ `INSERT INTO inbox (id, session_name, type, priority, content, from_session, acked, network_id, created_at)
50
+ VALUES (?1, ?2, ?3, 'normal', 'reply body', ?4, ?5, ?6, datetime('now'))`,
51
+ [id, sessionName, type, from, acked, netId],
52
+ );
53
+ }
54
+
55
+ beforeAll(async () => {
56
+ process.env.COMMHUB_AUTH_TOKEN ||= "legacy-master-token-for-test";
57
+ const { db } = await import("./db.js");
58
+ const { register, addNetworkMember } = await import("./auth.js");
59
+ const stamp = Date.now();
60
+
61
+ // 首个注册用户 = 全局 admin;探针 A/B/C 都不是 admin(admin 走不过滤分支,隔离断言会白过)。
62
+ const admin = register(`uba_admin_${stamp}`, "UbaAdmin-Strong-1!");
63
+ expect(admin.ok).toBe(true);
64
+ aName = `uba_a_${stamp}`; bName = `uba_b_${stamp}`; cName = `uba_c_${stamp}`;
65
+ const a = register(aName, "UbaA-Strong-1!");
66
+ const b = register(bName, "UbaB-Strong-1!");
67
+ const c = register(cName, "UbaC-Strong-1!");
68
+ expect(a.ok && b.ok && c.ok).toBe(true);
69
+ aToken = a.token!; bToken = b.token!; cToken = c.token!;
70
+ aNet = a.network_id!; cNet = c.network_id!;
71
+ const uid = (name: string) => db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", name)!.user_id;
72
+ aUserId = uid(aName); bUserId = uid(bName); cUserId = uid(cName);
73
+ for (const id of [aUserId, bUserId, cUserId]) {
74
+ expect(db.get<{ role: string }>("SELECT role FROM users WHERE user_id = ?1", id)?.role).toBe("user");
75
+ }
76
+ // B 与 A 同网:user_id / session_name 过滤是唯一的隔离手段。
77
+ expect(addNetworkMember(aNet, bUserId, "member").ok).toBe(true);
78
+
79
+ // A:user_inbox 两条(node-x),inbox 回复两条(peer-1)+ 一条已 ack + 一条 status 类(不算)。
80
+ seedUserInbox("dm_a1", aUserId, aNet, "node-x");
81
+ seedUserInbox("dm_a2", aUserId, aNet, "node-x");
82
+ seedInbox("ib_a1", aName, aNet, "peer-1");
83
+ seedInbox("ib_a2", aName, aNet, "peer-1");
84
+ seedInbox("ib_a_acked", aName, aNet, "peer-1", "reply", 1);
85
+ seedInbox("ib_a_status", aName, aNet, "peer-1", "status");
86
+ // B 同网:自己的一条回复;不该出现在 A 的数里。
87
+ seedInbox("ib_b1", bName, aNet, "peer-2");
88
+ // C:用户名撞节点 alias —— inbox 里那行是节点的待办。
89
+ db.run(
90
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at)
91
+ VALUES (?1, ?2, ?3, ?4, datetime('now'), datetime('now'))`,
92
+ [`node_${stamp}`, cName, cName, cNet],
93
+ );
94
+ seedInbox("ib_c_node_task", cName, cNet, "leader", "task");
95
+ seedUserInbox("dm_c1", cUserId, cNet, "node-y");
96
+
97
+ const mod = await import("./server.js");
98
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
99
+ base = `http://127.0.0.1:${server.port}`;
100
+ });
101
+
102
+ afterAll(() => {
103
+ try { server?.stop(true); } catch {}
104
+ try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
105
+ });
106
+
107
+ describe("#1828 GET /api/messages?scope=user unread_by_agent", () => {
108
+ test("A:user_inbox + inbox 回复按 agent 合并;unread/pending_count 口径不变", async () => {
109
+ const { status, body } = await get("/api/messages?scope=user", aToken);
110
+ expect(status).toBe(200);
111
+ expect(body.unread).toBe(2); // 只算 user_inbox(qa-hub-14 钉着)
112
+ expect(body.pending_count).toBe(2);
113
+ expect(body.unread_by_agent).toEqual({ "node-x": 2, "peer-1": 2 }); // 已 ack 与 status 类不算
114
+ expect(body.unread_total).toBe(4);
115
+ });
116
+
117
+ test("B(同网):只看到自己的回复,看不到 A 的", async () => {
118
+ const { body } = await get("/api/messages?scope=user", bToken);
119
+ expect(body.unread_by_agent).toEqual({ "peer-2": 1 });
120
+ expect(body.unread_total).toBe(1);
121
+ });
122
+
123
+ test("🔴 C 的用户名撞节点 alias:inbox 半边整体跳过,只剩 user_inbox", async () => {
124
+ const { body } = await get("/api/messages?scope=user", cToken);
125
+ expect(body.unread_by_agent).toEqual({ "node-y": 1 });
126
+ expect(body.unread_total).toBe(1);
127
+ });
128
+ });
129
+
130
+ describe("#1828 POST /api/messages/ack covers inbox reply rows", () => {
131
+ test("🔴 B 拿 A 的 inbox id 来 ack:匹配不到,A 的数不动", async () => {
132
+ const { body } = await post("/api/messages/ack", bToken, { message_ids: ["ib_a1"] });
133
+ expect(body).toMatchObject({ ok: true, acked: 0, acked_inbox: 0 });
134
+ const a = await get("/api/messages?scope=user", aToken);
135
+ expect(a.body.unread_by_agent["peer-1"]).toBe(2);
136
+ });
137
+
138
+ test("A ack 一条 inbox 回复 + 一条 user_inbox:两边各减一,响应分开计数", async () => {
139
+ const { body } = await post("/api/messages/ack", aToken, { message_ids: ["ib_a1", "dm_a1"] });
140
+ expect(body).toMatchObject({ ok: true, acked: 2, acked_user_inbox: 1, acked_inbox: 1 });
141
+ const a = await get("/api/messages?scope=user", aToken);
142
+ expect(a.body.unread).toBe(1);
143
+ expect(a.body.unread_by_agent).toEqual({ "node-x": 1, "peer-1": 1 });
144
+ expect(a.body.unread_total).toBe(2);
145
+ // 再 ack 同一条:幂等,0 行。
146
+ const again = await post("/api/messages/ack", aToken, { message_ids: ["ib_a1"] });
147
+ expect(again.body.acked).toBe(0);
148
+ });
149
+
150
+ test("status 类 inbox 行不会被用户 ack 掉(不是发给用户的消息)", async () => {
151
+ const { body } = await post("/api/messages/ack", aToken, { message_ids: ["ib_a_status"] });
152
+ expect(body.acked_inbox).toBe(0);
153
+ });
154
+
155
+ test("🔴 C 撞名:节点的待办不能被用户 ack 走", async () => {
156
+ const { body } = await post("/api/messages/ack", cToken, { message_ids: ["ib_c_node_task"] });
157
+ expect(body.acked_inbox).toBe(0);
158
+ const { db } = require("./db.js");
159
+ expect(db.get<{ acked: number }>("SELECT acked FROM inbox WHERE id = 'ib_c_node_task'")?.acked).toBe(0);
160
+ });
161
+ });
@@ -0,0 +1,122 @@
1
+ // #1493 — user_inbox.network_id 升 schema 级 NOT NULL(belt-and-suspenders)。
2
+ //
3
+ // 「不产生 NULL 孤儿」原是代码级三闸(#1492 证不可达)。schema 级 NOT NULL 让**任何**
4
+ // 未来绕过那三闸写 NULL 的回归在 INSERT 处被 DB 直接拒,而不是产出 scoped-unreadable
5
+ // 的静默孤儿。生产 hub 已在 preview.41、user_inbox 有真实行 → 迁移必须:① 新库直接
6
+ // NOT NULL;② 存量库无损重建(建新表→copy→drop→rename);③ 迁移前若发现 NULL 行(理应
7
+ // 为 0)fail-closed 停下,不静默丢。
8
+ //
9
+ // 后两条要预置「旧 schema(nullable)」的库 → 用 bun:sqlite 手造库 + 子进程 import db.js
10
+ // 触发迁移(与 boot-side-effect.test.ts 同款子进程范式)。
11
+
12
+ import { describe, expect, test } from "bun:test";
13
+ import { Database } from "bun:sqlite";
14
+ import { mkdtempSync } from "fs";
15
+ import { join } from "path";
16
+ import { tmpdir } from "os";
17
+ import { db } from "./db.js";
18
+
19
+ const OLD_SCHEMA = `CREATE TABLE user_inbox (
20
+ message_id TEXT PRIMARY KEY, network_id TEXT, user_id TEXT NOT NULL, from_session TEXT,
21
+ kind TEXT NOT NULL DEFAULT 'info', title TEXT, content TEXT NOT NULL,
22
+ severity TEXT NOT NULL DEFAULT 'info', meta_json TEXT, acked INTEGER NOT NULL DEFAULT 0,
23
+ created_at TEXT NOT NULL DEFAULT (datetime('now')), acked_at TEXT)`;
24
+
25
+ /** 预置一个「旧 schema(network_id 可空)」的库文件,可选带若干行。返回文件路径。 */
26
+ function seedOldDb(rows: Array<{ mid: string; net: string | null; user: string }>): string {
27
+ const file = join(mkdtempSync(join(tmpdir(), "anet-1493-")), "hub.db");
28
+ const raw = new Database(file);
29
+ raw.exec(OLD_SCHEMA);
30
+ for (const r of rows) {
31
+ raw.run("INSERT INTO user_inbox (message_id, network_id, user_id, content) VALUES (?1, ?2, ?3, 'body')",
32
+ [r.mid, r.net, r.user]);
33
+ }
34
+ raw.close();
35
+ return file;
36
+ }
37
+
38
+ /** 子进程里 import db.js(触发迁移),回读一段结果。返回 {exitCode, stdout, stderr}。 */
39
+ function bootWithDb(file: string, probe: string) {
40
+ const script = `
41
+ process.env.COMMHUB_DB = ${JSON.stringify(file)};
42
+ const { db } = await import("./src/db.js");
43
+ ${probe}
44
+ process.exit(0);
45
+ `;
46
+ const child = Bun.spawnSync({
47
+ cmd: ["bun", "-e", script],
48
+ cwd: `${import.meta.dir}/..`,
49
+ env: { ...process.env, COMMHUB_DB: file },
50
+ timeout: 20_000,
51
+ });
52
+ return {
53
+ exitCode: child.exitCode,
54
+ stdout: new TextDecoder().decode(child.stdout),
55
+ stderr: new TextDecoder().decode(child.stderr),
56
+ };
57
+ }
58
+
59
+ describe("#1493 user_inbox.network_id NOT NULL (belt-and-suspenders)", () => {
60
+ test("new DB: network_id is NOT NULL and rejects a NULL insert", () => {
61
+ const col = db.all<{ name: string; notnull: number }>("PRAGMA table_info(user_inbox)").find(c => c.name === "network_id");
62
+ expect(col?.notnull).toBe(1);
63
+ // 绕过代码闸直接写 NULL → DB 拒(这正是 belt-and-suspenders 的价值)
64
+ expect(() => db.run(
65
+ "INSERT INTO user_inbox (message_id, user_id, content) VALUES (?1, ?2, ?3)",
66
+ [`dm_nn_${Date.now()}`, "u_nn", "hi"],
67
+ )).toThrow(/NOT NULL/i);
68
+ });
69
+
70
+ test("migration: old nullable table with valid rows → rebuilt NOT NULL, rows preserved, index recreated", () => {
71
+ const file = seedOldDb([
72
+ { mid: "dm_1", net: "net_a", user: "u_1" },
73
+ { mid: "dm_2", net: "net_b", user: "u_2" },
74
+ ]);
75
+ const r = bootWithDb(file, `
76
+ const col = db.all("PRAGMA table_info(user_inbox)").find(c => c.name === "network_id");
77
+ const rows = db.get("SELECT COUNT(*) AS n FROM user_inbox").n;
78
+ const idx = db.get("SELECT COUNT(*) AS n FROM sqlite_master WHERE type='index' AND name='idx_user_inbox_user_acked'").n;
79
+ let nullRejected = false;
80
+ try { db.run("INSERT INTO user_inbox (message_id, user_id, content) VALUES ('dm_z','u_z','x')"); }
81
+ catch { nullRejected = true; }
82
+ console.log("RESULT:" + JSON.stringify({ notnull: col.notnull, rows, idx, nullRejected }));
83
+ `);
84
+ expect(r.exitCode).toBe(0);
85
+ const m = r.stdout.match(/RESULT:(\{.*\})/);
86
+ expect(m).not.toBeNull();
87
+ const out = JSON.parse(m![1]);
88
+ expect(out.notnull).toBe(1); // 迁移后 NOT NULL
89
+ expect(out.rows).toBe(2); // 两行无损保留
90
+ expect(out.idx).toBe(1); // 索引重建
91
+ expect(out.nullRejected).toBe(true);
92
+ });
93
+
94
+ test("migration guard: old table with a network_id IS NULL row → fail-safe (warn + skip, hub boots, data intact)", () => {
95
+ const file = seedOldDb([
96
+ { mid: "dm_ok", net: "net_a", user: "u_1" },
97
+ { mid: "dm_orphan", net: null, user: "u_2" }, // 理应不可达;若真出现 → warn+跳过,不停机
98
+ ]);
99
+ const r = bootWithDb(file, `
100
+ const col = db.all("PRAGMA table_info(user_inbox)").find(c => c.name === "network_id");
101
+ const rows = db.get("SELECT COUNT(*) AS n FROM user_inbox").n;
102
+ console.log("RESULT:" + JSON.stringify({ notnull: col.notnull, rows }));
103
+ `);
104
+ expect(r.exitCode).toBe(0); // hub 照常启动(fail-safe,不停机)
105
+ expect(r.stderr).toMatch(/migrate #1493/); // 大声 warn(明确运维信号)
106
+ expect(r.stderr).toMatch(/network_id IS NULL/);
107
+ const m = r.stdout.match(/RESULT:(\{.*\})/);
108
+ expect(m).not.toBeNull();
109
+ const out = JSON.parse(m![1]);
110
+ expect(out.notnull).toBe(0); // 迁移被跳过 → 列暂保持可空
111
+ expect(out.rows).toBe(2); // 数据没丢(没盲目清空)
112
+ });
113
+
114
+ test("idempotent: booting an already-migrated (NOT NULL) DB twice is a no-op", () => {
115
+ const file = seedOldDb([{ mid: "dm_a", net: "net_a", user: "u_1" }]);
116
+ const r1 = bootWithDb(file, `console.log("RESULT:" + db.all("PRAGMA table_info(user_inbox)").find(c=>c.name==="network_id").notnull);`);
117
+ expect(r1.exitCode).toBe(0);
118
+ const r2 = bootWithDb(file, `console.log("RESULT:" + db.all("PRAGMA table_info(user_inbox)").find(c=>c.name==="network_id").notnull);`);
119
+ expect(r2.exitCode).toBe(0);
120
+ expect(r2.stdout).toContain("RESULT:1"); // 第二次仍 NOT NULL、不炸
121
+ });
122
+ });
@@ -0,0 +1,223 @@
1
+ // #1459 ① P3 —— per-user 收件回读 + ack + 未读数 + redact-at-read。
2
+ //
3
+ // 承重的是**隔离**:A 只能读到 A 的。所以下面每条隔离断言都额外钉住探针身份 ——
4
+ // 首个注册用户会自动成为全局 admin,而 admin 在别处走的是不过滤的分支;
5
+ // 若探针恰好是 admin,隔离断言会"通过但什么都没证明"(#500/#506 同款)。
6
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
+ import { mkdtempSync, rmSync } from "node:fs";
8
+ import { tmpdir } from "node:os";
9
+ import { join } from "node:path";
10
+
11
+ const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-user-inbox-read-"));
12
+ process.env.COMMHUB_DB ||= join(PRIVATE_DB_DIR, "hub.db");
13
+
14
+ let server: ReturnType<typeof Bun.serve>;
15
+ let base = "";
16
+ let adminToken = "", aToken = "", bToken = "";
17
+ const MASTER_TOKEN = "legacy-master-token-for-test";
18
+ let aUserId = "", bUserId = "", aNet = "", bNet = "";
19
+ let adminUserId = "", adminNet = "";
20
+
21
+ const get = async (path: string, token: string) => {
22
+ const r = await fetch(`${base}${path}`, { headers: { Authorization: `Bearer ${token}` } });
23
+ return { status: r.status, body: await r.json() as any };
24
+ };
25
+ const post = async (path: string, token: string, body: unknown) => {
26
+ const r = await fetch(`${base}${path}`, {
27
+ method: "POST",
28
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
29
+ body: JSON.stringify(body),
30
+ });
31
+ return { status: r.status, body: await r.json() as any };
32
+ };
33
+
34
+ function seedMessage(mid: string, userId: string, netId: string, content: string, meta?: string, acked = 0) {
35
+ const { db } = require("./db.js");
36
+ db.run(
37
+ `INSERT INTO user_inbox (message_id, network_id, user_id, from_session, kind, title, content, severity, meta_json, acked, acked_at)
38
+ VALUES (?1, ?2, ?3, 'node-x', 'agent_message', NULL, ?4, 'info', ?5, ?6, ?7)`,
39
+ [mid, netId, userId, content, meta ?? null, acked, acked ? "2026-01-01 00:00:00" : null],
40
+ );
41
+ }
42
+
43
+ beforeAll(async () => {
44
+ // 传统全局 master token:requireAuth 对 GET /api/* 放行,但 resolveRequestAuth
45
+ // 返回 null(没有 user)。这条路径是分支内 `if (!callerUserId)` 唯一能被触发的
46
+ // 场景 —— 必须在 import server.js 之前设,AUTH_TOKEN 是模块加载期读的常量。
47
+ process.env.COMMHUB_AUTH_TOKEN ||= MASTER_TOKEN;
48
+
49
+ const { db } = await import("./db.js");
50
+ const { register, addNetworkMember } = await import("./auth.js");
51
+ const stamp = Date.now();
52
+
53
+ // 首个注册用户 = 全局 admin。**故意先注册一个用不到的 admin**,这样下面
54
+ // 两个探针 A/B 都不是 admin —— 隔离断言才有分辨力。
55
+ const admin = register(`ui_admin_${stamp}`, "UiAdmin-Strong-1!");
56
+ expect(admin.ok).toBe(true);
57
+ adminToken = admin.token!;
58
+ adminUserId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", `ui_admin_${stamp}`)!.user_id;
59
+ adminNet = admin.network_id!;
60
+
61
+ const a = register(`ui_a_${stamp}`, "UiA-Strong-1!");
62
+ const b = register(`ui_b_${stamp}`, "UiB-Strong-1!");
63
+ expect(a.ok && b.ok).toBe(true);
64
+ aToken = a.token!; bToken = b.token!;
65
+ aNet = a.network_id!; bNet = b.network_id!;
66
+ aUserId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", `ui_a_${stamp}`)!.user_id;
67
+ bUserId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", `ui_b_${stamp}`)!.user_id;
68
+
69
+ // 前提断言:探针不是 admin。靠"注册顺序"是隐式前提,会随夹具变化悄悄失效,
70
+ // 所以显式断言。
71
+ // 承重的是 role ≠ "admin";这里钉死具体值(auth.ts:88 非首用户写 "user"),
72
+ // 因为 `not.toBe("admin")` 在 role 为 undefined(查不到行)时也会通过 —— 松判据
73
+ // 会朝"没问题"那边失败。
74
+ for (const [name, id] of [["A", aUserId], ["B", bUserId]] as const) {
75
+ const role = db.get<{ role: string }>("SELECT role FROM users WHERE user_id = ?1", id)?.role;
76
+ expect([name, role]).toEqual([name, "user"]);
77
+ }
78
+
79
+ // 🔴 B 必须在**同一个 network** 里也有一条私信。
80
+ // 第一版夹具让 A/B 各处一网,于是 addNetworkScope 顺手挡住了 B 的行 ——
81
+ // 变异掉 `WHERE user_id = ?1` 后隔离断言照样通过(M1/M2/M4 全部存活)。
82
+ // 同网同事之间,user_id 过滤是**唯一**的隔离手段,夹具必须落在那一格。
83
+ expect(addNetworkMember(aNet, bUserId, "member").ok).toBe(true);
84
+
85
+ seedMessage("dm_a1", aUserId, aNet, "A 的第一条");
86
+ seedMessage("dm_a2", aUserId, aNet, "A 的第二条");
87
+ seedMessage("dm_b_same_net", bUserId, aNet, "B 在 A 同网里的私信");
88
+ seedMessage("dm_b1", bUserId, bNet, "B 的私信");
89
+ // A 自己的、但落在 A 不是成员的 network 上的行(例如 A 被移出该网后的遗留)。
90
+ // 它是 addNetworkScope 这次调用的唯一 witnessed-red 对象:user_id 过滤对它无效。
91
+ seedMessage("dm_a_foreign_net", aUserId, bNet, "A 在外网的遗留私信");
92
+ // #1493 —— network_id 升到 schema 级 NOT NULL 后,「network_id 为 NULL 的行」已
93
+ // **schema 级不可能**(写路径原本三闸挡、现在 DB 直接拒)。原先手播的 dm_a_nonet /
94
+ // dm_admin_nonet 两条 NULL 夹具随之删除:
95
+ // · dm_a_nonet(证"受网络限制成员读不到 NULL 行")—— 网络 scope 隔离已由下面
96
+ // dm_a_foreign_net(A 自己的行、落在 A 非成员的网)覆盖,不丢覆盖。
97
+ // · dm_admin_nonet 重锚到 admin **自己网络**的非 NULL 行 dm_admin_own,保住
98
+ // "admin(scope 助手 no-op)仍只读自己的、不绕过 user_id 过滤"这条覆盖。
99
+ seedMessage("dm_admin_own", adminUserId, adminNet, "admin 自己网络里的私信");
100
+ // #1493 SDK马 建议:用**非 NULL** 行补回"admin(scope 助手 no-op)能读到别网的自己的行"
101
+ // 这维覆盖(原先靠 NULL 行,现 NULL 不可能)。对照 dm_a_foreign_net(受限成员读不到),
102
+ // 两边都不依赖 NULL。
103
+ seedMessage("dm_admin_foreign_net", adminUserId, bNet, "admin 在别网的自己的私信");
104
+ seedMessage("dm_a_read", aUserId, aNet, "A 已读的一条", undefined, 1);
105
+ seedMessage("dm_a_secret", aUserId, aNet,
106
+ "凭据在正文里 ntok_abcdef123456", JSON.stringify({ k: "ghp_" + "A".repeat(24) }));
107
+
108
+ const mod = await import("./server.js");
109
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
110
+ base = `http://127.0.0.1:${server.port}`;
111
+ });
112
+
113
+ afterAll(() => {
114
+ try { server?.stop(true); } catch {}
115
+ try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
116
+ });
117
+
118
+ describe("#1459 ① P3 GET /api/messages?scope=user", () => {
119
+ test("🔴 只返回调用者自己的消息 —— A 读不到 B 的", async () => {
120
+ const { status, body } = await get("/api/messages?scope=user", aToken);
121
+ expect(status).toBe(200);
122
+ expect(body.ok).toBe(true);
123
+ const ids = body.messages.map((m: any) => m.message_id);
124
+ expect(ids).toContain("dm_a1");
125
+ expect(ids).toContain("dm_a_read"); // 不加 unacked 时已读也要回来
126
+ expect(ids).not.toContain("dm_b_same_net"); // 承重:同网,只有 user_id 过滤挡得住
127
+ expect(ids).not.toContain("dm_b1"); // 跨网,scope 助手挡
128
+ expect(ids).not.toContain("dm_a_foreign_net"); // 承重:A 自己的行,只有 scope 助手挡得住
129
+ expect(body.messages.every((m: any) => m.user_id === aUserId)).toBe(true);
130
+ });
131
+
132
+ test("🔴 收件人取自 auth ctx —— query 里指定别人的 user_id 无效", async () => {
133
+ const { body } = await get(`/api/messages?scope=user&user_id=${bUserId}`, aToken);
134
+ const ids = body.messages.map((m: any) => m.message_id);
135
+ expect(ids).not.toContain("dm_b_same_net");
136
+ expect(ids).not.toContain("dm_b1");
137
+ expect(body.messages.every((m: any) => m.user_id === aUserId)).toBe(true);
138
+ });
139
+
140
+ test("🔴 redact-at-read:正文与 meta 里的凭据形状被遮住", async () => {
141
+ const { body } = await get("/api/messages?scope=user", aToken);
142
+ const row = body.messages.find((m: any) => m.message_id === "dm_a_secret");
143
+ expect(row).toBeTruthy();
144
+ expect(row.content).toContain("ntok_***redacted***");
145
+ expect(row.content).not.toContain("ntok_abcdef123456");
146
+ expect(row.meta_json).toContain("ghp_***redacted***");
147
+ });
148
+
149
+ // #1493 后 NULL 网络行不可能,原"受网络限制成员读不到 NULL 行"一测删除——
150
+ // 网络 scope 隔离已由上面 dm_a_foreign_net(承重断言,test 112 line 121)覆盖。
151
+ // admin 一测重锚到非 NULL:验 admin(scope 助手 no-op)**仍只读自己的、不绕过 user_id 过滤**。
152
+ test("admin(scope 助手 no-op)仍受 user_id 限制:只读自己的、不读别人的", async () => {
153
+ const { body } = await get("/api/messages?scope=user", adminToken);
154
+ const ids = body.messages.map((m: any) => m.message_id);
155
+ expect(ids).toContain("dm_admin_own"); // 读得到自己的
156
+ expect(ids).toContain("dm_admin_foreign_net"); // scope 助手 no-op → 跨网也读得到自己的
157
+ expect(ids).not.toContain("dm_a1"); // 承重:admin 不绕过 user_id 过滤读别人的
158
+ expect(body.messages.every((m: any) => m.user_id === adminUserId)).toBe(true);
159
+ });
160
+
161
+ test("unread 是未读数而非总数(A:4 条里 3 条未读)", async () => {
162
+ const { body } = await get("/api/messages?scope=user", aToken);
163
+ expect(body.messages.length).toBe(4);
164
+ expect(body.unread).toBe(3);
165
+ expect(body.pending_count).toBe(body.unread);
166
+ });
167
+
168
+ test("unacked=1 排掉已读那条", async () => {
169
+ const { body } = await get("/api/messages?scope=user&unacked=1", aToken);
170
+ const ids = body.messages.map((m: any) => m.message_id);
171
+ expect(ids).not.toContain("dm_a_read"); // 承重:集合里确实有已读行才有分辨力
172
+ expect(ids.sort()).toEqual(["dm_a1", "dm_a2", "dm_a_secret"]);
173
+ });
174
+
175
+ test("未鉴权 ⇒ 401(/api 全局闸)", async () => {
176
+ const r = await fetch(`${base}/api/messages?scope=user`);
177
+ expect(r.status).toBe(401);
178
+ });
179
+
180
+ test("🔴 legacy master token 过得了全局闸但没有 user ⇒ 401,不能回一个空列表", async () => {
181
+ // 这条才是分支内 `if (!callerUserId)` 的 witnessed-red:把它换成
182
+ // `?? ""` 后本条从 401 变成 200 + 空列表 —— "看起来正常"的错误答案。
183
+ const { status, body } = await get("/api/messages?scope=user", MASTER_TOKEN);
184
+ expect([status, body.error]).toEqual([401, "auth_required"]);
185
+ });
186
+
187
+ test("alias 分支未受影响(dashboard 依赖它)", async () => {
188
+ const { status, body } = await get("/api/messages", adminToken);
189
+ expect(status).toBe(200);
190
+ expect(body.ok).toBe(true);
191
+ expect(Array.isArray(body.messages)).toBe(true);
192
+ expect(body.unread).toBeUndefined(); // 老分支不该多出新字段
193
+ });
194
+ });
195
+
196
+ describe("#1459 ① P3 POST /api/messages/ack", () => {
197
+ test("ack 自己的消息 ⇒ acked 置位、未读数下降", async () => {
198
+ const before = (await get("/api/messages?scope=user", aToken)).body.unread;
199
+ const { status, body } = await post("/api/messages/ack", aToken, { message_id: "dm_a1" });
200
+ expect(status).toBe(200);
201
+ expect(body.acked).toBe(1);
202
+ const after = (await get("/api/messages?scope=user", aToken)).body.unread;
203
+ expect(after).toBe(before - 1);
204
+ });
205
+
206
+ test("🔴 ack 不了同网同事的消息 —— 改动行数为 0,且对方仍是未读", async () => {
207
+ const { body } = await post("/api/messages/ack", aToken, { message_id: "dm_b_same_net" });
208
+ expect(body.acked).toBe(0);
209
+ const bView = (await get("/api/messages?scope=user", bToken)).body;
210
+ expect(bView.messages.find((m: any) => m.message_id === "dm_b_same_net").acked).toBe(0);
211
+ });
212
+
213
+ test("ack 跨网的消息同样为 0", async () => {
214
+ const { body } = await post("/api/messages/ack", aToken, { message_id: "dm_b1" });
215
+ expect(body.acked).toBe(0);
216
+ });
217
+
218
+ test("缺参 ⇒ 400;未鉴权 ⇒ 401", async () => {
219
+ expect((await post("/api/messages/ack", aToken, {})).status).toBe(400);
220
+ const r = await fetch(`${base}/api/messages/ack`, { method: "POST", body: "{}" });
221
+ expect(r.status).toBe(401);
222
+ });
223
+ });
@@ -0,0 +1,54 @@
1
+ // #1459 ① — user_inbox 建表 + 幂等打底(P1)。零行为变化,只落 schema。
2
+ //
3
+ // 承重属性(SDK马 review 要点):message_id 即主键 → send_desktop_message 因 HTTP
4
+ // 响应丢失被重试时,同一 dm_<uuid> 用 INSERT OR IGNORE 不会插出重复行(前端免去重)。
5
+
6
+ import { describe, expect, test } from "bun:test";
7
+ import { db } from "./db.js";
8
+
9
+ describe("#1459 user_inbox schema (P1)", () => {
10
+ test("table exists with the expected columns", () => {
11
+ const cols = db.all<{ name: string }>("PRAGMA table_info(user_inbox)").map(c => c.name);
12
+ for (const c of ["message_id", "network_id", "user_id", "from_session", "kind", "title", "content", "severity", "meta_json", "acked", "created_at", "acked_at"]) {
13
+ expect(cols).toContain(c);
14
+ }
15
+ });
16
+
17
+ // 注:这条钉的是「主键在我们以为的那一列」(SQLite 行为),**不是**「写入路径幂等」——
18
+ // 后者要 P2 驱动真实 send_desktop_message 两次来证(SDK马 review)。这里只保证主键选对。
19
+ test("message_id is PRIMARY KEY → INSERT OR IGNORE dedups on the same id", () => {
20
+ const mid = `dm_test_${Date.now()}`;
21
+ try {
22
+ const ins = () => db.run(
23
+ `INSERT OR IGNORE INTO user_inbox (message_id, network_id, user_id, from_session, content)
24
+ VALUES (?1, ?2, ?3, ?4, ?5)`,
25
+ [mid, "net_test_1459", "u_test_1459", "sender-a", "hello"],
26
+ );
27
+ ins();
28
+ ins(); // 同 message_id 第二次插入应被 IGNORE
29
+ const n = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM user_inbox WHERE message_id = ?1", mid)?.n ?? 0;
30
+ expect(n).toBe(1);
31
+ } finally {
32
+ db.run("DELETE FROM user_inbox WHERE message_id = ?1", mid);
33
+ }
34
+ });
35
+
36
+ test("defaults: acked=0, kind/severity='info'", () => {
37
+ const mid = `dm_def_${Date.now()}`;
38
+ try {
39
+ db.run(
40
+ // #1493 后 network_id 是 NOT NULL,补上(本测试只验其它列的默认值)
41
+ `INSERT INTO user_inbox (message_id, network_id, user_id, content) VALUES (?1, ?2, ?3, ?4)`,
42
+ [mid, "net_def_1459", "u_def_1459", "body"],
43
+ );
44
+ const row = db.get<{ acked: number; kind: string; severity: string }>(
45
+ "SELECT acked, kind, severity FROM user_inbox WHERE message_id = ?1", mid,
46
+ );
47
+ expect(row?.acked).toBe(0);
48
+ expect(row?.kind).toBe("info");
49
+ expect(row?.severity).toBe("info");
50
+ } finally {
51
+ db.run("DELETE FROM user_inbox WHERE message_id = ?1", mid);
52
+ }
53
+ });
54
+ });