@sleep2agi/commhub-server 0.9.0-preview.7 → 0.9.0-preview.70

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 (162) hide show
  1. package/README.md +125 -7
  2. package/bin/commhub.ts +10 -2
  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 +331 -0
  6. package/src/admin-networks-http.test.ts +88 -0
  7. package/src/agent-access.ts +267 -0
  8. package/src/agent-acl-http.test.ts +510 -0
  9. package/src/agent-acl-slice3-http.test.ts +267 -0
  10. package/src/alias-filter.test.ts +72 -0
  11. package/src/alias-filter.ts +32 -0
  12. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  13. package/src/api-host-supervisors-fallback.test.ts +271 -0
  14. package/src/api-nodes-shape.test.ts +26 -10
  15. package/src/auth-sessions-http.test.ts +250 -0
  16. package/src/auth-validate.test.ts +5 -1
  17. package/src/auth.ts +348 -29
  18. package/src/avatar-validate.test.ts +163 -0
  19. package/src/avatar-validate.ts +103 -0
  20. package/src/blocked-exit-on-reply.test.ts +122 -0
  21. package/src/boot-side-effect.test.ts +124 -0
  22. package/src/config-apply-sec1.test.ts +107 -0
  23. package/src/config-apply-validate.test.ts +92 -0
  24. package/src/config-apply-validate.ts +81 -3
  25. package/src/create-node-blocked-gate.test.ts +356 -0
  26. package/src/create-node-tool-schema.test.ts +39 -0
  27. package/src/create-node-validate.test.ts +89 -1
  28. package/src/create-node-validate.ts +89 -7
  29. package/src/create-node-workdir.test.ts +255 -0
  30. package/src/create-node.ts +51 -1
  31. package/src/dashboard-slash-routing-http.test.ts +107 -0
  32. package/src/db-adapter-guard.test.ts +85 -0
  33. package/src/db-adapter.ts +64 -23
  34. package/src/db-timestamp.test.ts +38 -0
  35. package/src/db-timestamp.ts +15 -0
  36. package/src/db.ts +762 -33
  37. package/src/delivered-without-push.test.ts +140 -0
  38. package/src/desktop-message-seam.test.ts +150 -0
  39. package/src/desktop-message.test.ts +368 -0
  40. package/src/ext-token-shared.test.ts +60 -0
  41. package/src/ext-validation-regression.test.ts +131 -0
  42. package/src/external-schedule-edits-http.test.ts +321 -0
  43. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  44. package/src/external-schedule-edits.ts +327 -0
  45. package/src/file-download-authz-dev-open.test.ts +135 -0
  46. package/src/file-download-authz.test.ts +404 -0
  47. package/src/file-download-date-bucket.test.ts +436 -0
  48. package/src/file-network-scope-dev-open.test.ts +97 -0
  49. package/src/file-network-scope.test.ts +732 -0
  50. package/src/health-redaction.test.ts +642 -0
  51. package/src/http-gzip.test.ts +73 -0
  52. package/src/http-gzip.ts +60 -0
  53. package/src/hub-timestamp.test.ts +38 -0
  54. package/src/hub-timestamp.ts +27 -0
  55. package/src/human-dm.ts +149 -0
  56. package/src/inbox-count-sse.test.ts +310 -0
  57. package/src/inbox-count.ts +29 -0
  58. package/src/index.ts +15 -2331
  59. package/src/lifecycle-guard.ts +50 -0
  60. package/src/mcp-write-network-resolution.test.ts +429 -0
  61. package/src/messages-ack-by-agent-http.test.ts +142 -0
  62. package/src/network-name-http.test.ts +75 -0
  63. package/src/network-scope.ts +224 -0
  64. package/src/node-attrs-validate.ts +88 -0
  65. package/src/node-attrs.test.ts +217 -0
  66. package/src/node-create-request-status-http.test.ts +92 -0
  67. package/src/node-files-transport.test.ts +344 -0
  68. package/src/node-id-alias.test.ts +188 -0
  69. package/src/node-lifecycle-controllable.test.ts +64 -0
  70. package/src/node-lifecycle-controllable.ts +51 -0
  71. package/src/node-logs-transport.test.ts +263 -0
  72. package/src/node-request-retention.test.ts +281 -0
  73. package/src/node-request-retention.ts +120 -0
  74. package/src/node-skills-transport.test.ts +209 -0
  75. package/src/node-token-privilege-http.test.ts +149 -0
  76. package/src/observer-avatar-http.test.ts +429 -0
  77. package/src/observer-push.test.ts +252 -0
  78. package/src/package-version-consistency.test.ts +25 -0
  79. package/src/peer-reply-atomic.test.ts +455 -0
  80. package/src/peer-reply-capability.test.ts +70 -0
  81. package/src/push.ts +204 -7
  82. package/src/redact-tokens.test.ts +65 -0
  83. package/src/redact-tokens.ts +40 -0
  84. package/src/rename.ts +7 -2
  85. package/src/reply-target-derivation.test.ts +96 -0
  86. package/src/report-status-host-schema.test.ts +211 -0
  87. package/src/report-status-resume-id-handover.test.ts +150 -0
  88. package/src/require-explicit-test-db.ts +9 -0
  89. package/src/requirement-tags.ts +16 -0
  90. package/src/requirements-http.test.ts +708 -0
  91. package/src/requirements-mcp.test.ts +142 -0
  92. package/src/requirements-migrate.ts +83 -0
  93. package/src/requirements-priority-lowest.test.ts +114 -0
  94. package/src/requirements-upgrade-e2e.test.ts +126 -0
  95. package/src/requirements-upgrade-migration.test.ts +85 -0
  96. package/src/requirements.ts +865 -0
  97. package/src/resolve-port.test.ts +57 -0
  98. package/src/resolve-port.ts +44 -0
  99. package/src/response-charset.test.ts +130 -0
  100. package/src/rest-explicit-columns-http.test.ts +216 -0
  101. package/src/rest-identity.test.ts +129 -0
  102. package/src/rest-identity.ts +100 -0
  103. package/src/rest-projections.ts +68 -0
  104. package/src/rest-write-network-resolution.test.ts +133 -0
  105. package/src/restricted-files.ts +73 -0
  106. package/src/retention.ts +10 -1
  107. package/src/rules-file-duplicate-alias.test.ts +234 -0
  108. package/src/rules-file-session-target.test.ts +207 -0
  109. package/src/rules-file-transport.test.ts +237 -0
  110. package/src/runtime-label.test.ts +22 -0
  111. package/src/runtime-label.ts +21 -0
  112. package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
  113. package/src/scheduled-run-terminal.test.ts +268 -0
  114. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  115. package/src/scheduled-tasks-http.test.ts +574 -0
  116. package/src/scheduled-tasks.ts +596 -0
  117. package/src/send-dedup-request-id.test.ts +229 -0
  118. package/src/send-reply-agent-warning.test.ts +202 -0
  119. package/src/send-reply-alias-optional.test.ts +131 -0
  120. package/src/send-reply-attachments.test.ts +458 -0
  121. package/src/send_dedup.test.ts +54 -0
  122. package/src/send_dedup.ts +52 -6
  123. package/src/serve-error.test.ts +38 -0
  124. package/src/serve-error.ts +27 -0
  125. package/src/server.ts +4279 -0
  126. package/src/shared/external-schedule-contract.ts +60 -0
  127. package/src/side-thread-command-transport.test.ts +302 -0
  128. package/src/side-thread-command-transport.ts +318 -0
  129. package/src/side-thread-http-integration.test.ts +233 -0
  130. package/src/side-thread-http.ts +504 -0
  131. package/src/side-thread-production.test.ts +72 -0
  132. package/src/side-thread-production.ts +118 -0
  133. package/src/side-thread.test.ts +1067 -0
  134. package/src/side-thread.ts +2495 -0
  135. package/src/skillhub-http.test.ts +92 -0
  136. package/src/start-node.test.ts +109 -0
  137. package/src/stop-delete-node.test.ts +1164 -0
  138. package/src/task-auth-origin-http.test.ts +91 -0
  139. package/src/task-auth-origin.test.ts +24 -0
  140. package/src/task-auth-origin.ts +7 -0
  141. package/src/task-consumption.test.ts +482 -0
  142. package/src/task-diagnostic-http.test.ts +149 -0
  143. package/src/task-diagnostic.test.ts +115 -0
  144. package/src/task-diagnostic.ts +118 -0
  145. package/src/task-idempotency-mcp.test.ts +142 -0
  146. package/src/task-idempotency.test.ts +60 -0
  147. package/src/task-idempotency.ts +75 -0
  148. package/src/task-lifecycle-watcher.test.ts +204 -0
  149. package/src/task-lifecycle-watcher.ts +49 -0
  150. package/src/task-network-resolution-http.test.ts +141 -0
  151. package/src/tasks-pagination-http.test.ts +141 -0
  152. package/src/tools-list-json-schema.test.ts +66 -0
  153. package/src/tools.ts +3445 -257
  154. package/src/update-provider.test.ts +65 -0
  155. package/src/upload-early-reject-guard.test.ts +155 -0
  156. package/src/uploads-http.test.ts +97 -15
  157. package/src/uploads.test.ts +66 -0
  158. package/src/uploads.ts +130 -5
  159. package/src/user-inbox-by-agent-http.test.ts +161 -0
  160. package/src/user-inbox-netid-notnull.test.ts +122 -0
  161. package/src/user-inbox-read-http.test.ts +223 -0
  162. package/src/user-inbox-schema.test.ts +54 -0
@@ -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
+ });