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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (318) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +99 -11
  3. package/package.json +7 -7
  4. package/src/ack-create-request-transport.test.ts +182 -0
  5. package/src/ack-create-request.test.ts +50 -7
  6. package/src/admin-delete-network-http.test.ts +228 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/adoption-candidates-http.test.ts +137 -0
  9. package/src/adoption-candidates.ts +158 -0
  10. package/src/agent-access-batch.test.ts +210 -0
  11. package/src/agent-access.ts +487 -0
  12. package/src/agent-acl-groups-http.test.ts +247 -0
  13. package/src/agent-acl-http.test.ts +659 -0
  14. package/src/agent-acl-slice3-http.test.ts +267 -0
  15. package/src/agent-groups-perf.test.ts +223 -0
  16. package/src/agent-notice.ts +48 -0
  17. package/src/agent-team-whoami-http.test.ts +98 -0
  18. package/src/agent-teams-http.test.ts +323 -0
  19. package/src/agent-teams.ts +244 -0
  20. package/src/alias-filter.test.ts +72 -0
  21. package/src/alias-filter.ts +42 -0
  22. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  23. package/src/api-host-supervisors-fallback.test.ts +271 -0
  24. package/src/api-host-supervisors-runtime-readiness.test.ts +195 -0
  25. package/src/api-nodes-shape.test.ts +26 -10
  26. package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
  27. package/src/auth-sessions-http.test.ts +250 -0
  28. package/src/auth-validate.test.ts +5 -1
  29. package/src/auth-weak-password-login.test.ts +176 -0
  30. package/src/auth.ts +598 -49
  31. package/src/avatar-validate.test.ts +163 -0
  32. package/src/avatar-validate.ts +103 -0
  33. package/src/binding-generation-http.test.ts +121 -0
  34. package/src/blocked-exit-on-reply.test.ts +122 -0
  35. package/src/boot-side-effect.test.ts +124 -0
  36. package/src/clone-node.test.ts +248 -0
  37. package/src/clone-node.ts +289 -0
  38. package/src/codex-config-flags.ts +77 -0
  39. package/src/codex-fork-contract.ts +23 -0
  40. package/src/codex-fork-migration.test.ts +38 -0
  41. package/src/commhub-bin-args.test.ts +85 -0
  42. package/src/config-apply-sec1.test.ts +107 -0
  43. package/src/config-apply-validate.test.ts +134 -1
  44. package/src/config-apply-validate.ts +111 -5
  45. package/src/create-node-blocked-gate.test.ts +356 -0
  46. package/src/create-node-network-id.test.ts +79 -0
  47. package/src/create-node-tool-schema.test.ts +48 -0
  48. package/src/create-node-unicode-name.test.ts +120 -0
  49. package/src/create-node-validate.test.ts +193 -7
  50. package/src/create-node-validate.ts +227 -15
  51. package/src/create-node-workdir.test.ts +289 -0
  52. package/src/create-node.ts +77 -23
  53. package/src/daemon-provider-snapshots.test.ts +421 -0
  54. package/src/daemon-provider-snapshots.ts +327 -0
  55. package/src/daemon-token-owner-http.test.ts +236 -0
  56. package/src/dashboard-slash-routing-http.test.ts +107 -0
  57. package/src/db-adapter-guard.test.ts +148 -0
  58. package/src/db-adapter-translate.test.ts +128 -0
  59. package/src/db-adapter.ts +323 -87
  60. package/src/db-timestamp.test.ts +38 -0
  61. package/src/db-timestamp.ts +15 -0
  62. package/src/db.ts +933 -36
  63. package/src/delivered-without-push.test.ts +140 -0
  64. package/src/department-groups-http.test.ts +209 -0
  65. package/src/department-groups-sync-http.test.ts +328 -0
  66. package/src/department-groups.ts +434 -0
  67. package/src/department-heads-http.test.ts +330 -0
  68. package/src/department-heads.ts +226 -0
  69. package/src/departments-http.test.ts +185 -0
  70. package/src/departments.ts +260 -0
  71. package/src/desktop-message-seam.test.ts +150 -0
  72. package/src/desktop-message.test.ts +368 -0
  73. package/src/dm-file-scope-http.test.ts +178 -0
  74. package/src/ext-token-shared.test.ts +60 -0
  75. package/src/ext-validation-regression.test.ts +131 -0
  76. package/src/external-schedule-edits-http.test.ts +321 -0
  77. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  78. package/src/external-schedule-edits.ts +327 -0
  79. package/src/file-download-authz-dev-open.test.ts +135 -0
  80. package/src/file-download-authz.test.ts +404 -0
  81. package/src/file-download-date-bucket.test.ts +436 -0
  82. package/src/file-network-scope-dev-open.test.ts +97 -0
  83. package/src/file-network-scope.test.ts +732 -0
  84. package/src/group-membership-events-http.test.ts +264 -0
  85. package/src/group-messages-http.test.ts +482 -0
  86. package/src/group-messages.ts +297 -0
  87. package/src/health-redaction.test.ts +642 -0
  88. package/src/http-gzip.test.ts +94 -0
  89. package/src/http-gzip.ts +112 -0
  90. package/src/hub-provider-config.test.ts +139 -0
  91. package/src/hub-provider-config.ts +105 -0
  92. package/src/hub-timestamp.test.ts +38 -0
  93. package/src/hub-timestamp.ts +27 -0
  94. package/src/human-dm.ts +215 -0
  95. package/src/inbox-caller.ts +49 -0
  96. package/src/inbox-count-sse.test.ts +310 -0
  97. package/src/inbox-count.ts +29 -0
  98. package/src/index.ts +15 -2362
  99. package/src/lifecycle-guard.ts +1 -1
  100. package/src/mcp-context-cost-http.test.ts +123 -0
  101. package/src/mcp-projects-events-http.test.ts +162 -0
  102. package/src/mcp-route-label-http.test.ts +120 -0
  103. package/src/mcp-write-network-resolution.test.ts +432 -0
  104. package/src/member-audit-network-id-http.test.ts +93 -0
  105. package/src/member-presence-http.test.ts +210 -0
  106. package/src/messages-ack-by-agent-http.test.ts +142 -0
  107. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  108. package/src/migrate-sqlite-to-pg.ts +263 -0
  109. package/src/model-auth-notify-http.test.ts +181 -0
  110. package/src/model-auth-notify.ts +116 -0
  111. package/src/network-name-http.test.ts +75 -0
  112. package/src/network-scope.ts +276 -0
  113. package/src/network-token-membership-http.test.ts +139 -0
  114. package/src/node-alias-control.ts +12 -0
  115. package/src/node-attrs-validate.ts +88 -0
  116. package/src/node-attrs.test.ts +217 -0
  117. package/src/node-caller-identity-http.test.ts +781 -0
  118. package/src/node-create-request-status-http.test.ts +105 -0
  119. package/src/node-daemon-bindings.test.ts +132 -0
  120. package/src/node-daemon-bindings.ts +130 -0
  121. package/src/node-files-transport.test.ts +344 -0
  122. package/src/node-health-codex-login.test.ts +120 -0
  123. package/src/node-health-dispatch-http.test.ts +279 -0
  124. package/src/node-health-guard.ts +105 -0
  125. package/src/node-health-passthrough.test.ts +115 -0
  126. package/src/node-health-store.ts +101 -0
  127. package/src/node-id-alias.test.ts +190 -0
  128. package/src/node-identity-conflict-http.test.ts +269 -0
  129. package/src/node-identity-conflict.ts +142 -0
  130. package/src/node-identity-mismatch.test.ts +74 -0
  131. package/src/node-lifecycle-controllable.test.ts +64 -0
  132. package/src/node-lifecycle-controllable.ts +53 -0
  133. package/src/node-lifecycle-read-http.test.ts +245 -0
  134. package/src/node-lifecycle-read.ts +121 -0
  135. package/src/node-logs-transport.test.ts +263 -0
  136. package/src/node-permissions-http.test.ts +276 -0
  137. package/src/node-permissions.ts +285 -0
  138. package/src/node-request-retention.test.ts +281 -0
  139. package/src/node-request-retention.ts +120 -0
  140. package/src/node-skills-transport.test.ts +209 -0
  141. package/src/node-token-ownership.ts +51 -0
  142. package/src/node-token-privilege-http.test.ts +158 -0
  143. package/src/observer-avatar-http.test.ts +431 -0
  144. package/src/observer-push.test.ts +252 -0
  145. package/src/opencode-v2-catalog.test.ts +268 -0
  146. package/src/opencode-v2-catalog.ts +352 -0
  147. package/src/package-version-consistency.test.ts +25 -0
  148. package/src/peer-reply-atomic.test.ts +455 -0
  149. package/src/peer-reply-capability.test.ts +70 -0
  150. package/src/pg-worker.ts +87 -0
  151. package/src/project-dir-warn-clear-http.test.ts +132 -0
  152. package/src/project-viewer-can-http.test.ts +114 -0
  153. package/src/push.ts +429 -10
  154. package/src/redact-tokens.test.ts +65 -0
  155. package/src/redact-tokens.ts +40 -0
  156. package/src/rename.ts +22 -2
  157. package/src/reply-target-derivation.test.ts +96 -0
  158. package/src/report-status-handover-capabilities-http.test.ts +124 -0
  159. package/src/report-status-host-schema.test.ts +235 -0
  160. package/src/report-status-resume-id-handover.test.ts +150 -0
  161. package/src/require-explicit-test-db.ts +9 -0
  162. package/src/requirement-due-reminders-http.test.ts +620 -0
  163. package/src/requirement-due-reminders.ts +416 -0
  164. package/src/requirement-events-http.test.ts +268 -0
  165. package/src/requirement-events.test.ts +45 -0
  166. package/src/requirement-events.ts +144 -0
  167. package/src/requirement-notify.ts +165 -0
  168. package/src/requirement-participant-notify-http.test.ts +309 -0
  169. package/src/requirement-tag-ops-http.test.ts +266 -0
  170. package/src/requirement-tags.ts +89 -0
  171. package/src/requirements-abandoned-http.test.ts +236 -0
  172. package/src/requirements-comment-http.test.ts +167 -0
  173. package/src/requirements-completed-at-migration.test.ts +70 -0
  174. package/src/requirements-errors-mcp-http.test.ts +164 -0
  175. package/src/requirements-errors.ts +143 -0
  176. package/src/requirements-etag-http.test.ts +91 -0
  177. package/src/requirements-http.test.ts +709 -0
  178. package/src/requirements-last-event-http.test.ts +349 -0
  179. package/src/requirements-list-mcp-slim-http.test.ts +155 -0
  180. package/src/requirements-list-slim-http.test.ts +319 -0
  181. package/src/requirements-mcp.test.ts +163 -0
  182. package/src/requirements-migrate.ts +259 -0
  183. package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
  184. package/src/requirements-people-http.test.ts +114 -0
  185. package/src/requirements-people-mcp-http.test.ts +225 -0
  186. package/src/requirements-people.ts +127 -0
  187. package/src/requirements-priority-lowest.test.ts +114 -0
  188. package/src/requirements-search-http.test.ts +182 -0
  189. package/src/requirements-search.test.ts +32 -0
  190. package/src/requirements-search.ts +145 -0
  191. package/src/requirements-seq-http.test.ts +193 -0
  192. package/src/requirements-seq-migration.test.ts +63 -0
  193. package/src/requirements-start-date.test.ts +125 -0
  194. package/src/requirements-stats-http.test.ts +288 -0
  195. package/src/requirements-stats.ts +167 -0
  196. package/src/requirements-status-participants-http.test.ts +222 -0
  197. package/src/requirements-upgrade-e2e.test.ts +126 -0
  198. package/src/requirements-upgrade-migration.test.ts +98 -0
  199. package/src/requirements.ts +1806 -0
  200. package/src/resolve-port.test.ts +57 -0
  201. package/src/resolve-port.ts +44 -0
  202. package/src/response-charset.test.ts +130 -0
  203. package/src/rest-explicit-columns-http.test.ts +222 -0
  204. package/src/rest-identity.test.ts +130 -0
  205. package/src/rest-identity.ts +139 -0
  206. package/src/rest-projections.ts +68 -0
  207. package/src/rest-write-network-resolution.test.ts +133 -0
  208. package/src/restricted-files.ts +76 -0
  209. package/src/retention.ts +15 -3
  210. package/src/route-callers-http.test.ts +120 -0
  211. package/src/route-timing.test.ts +211 -0
  212. package/src/route-timing.ts +258 -0
  213. package/src/rules-file-duplicate-alias.test.ts +234 -0
  214. package/src/rules-file-session-target.test.ts +207 -0
  215. package/src/rules-file-transport.test.ts +237 -0
  216. package/src/runtime-label.test.ts +22 -0
  217. package/src/runtime-label.ts +21 -0
  218. package/src/runtime-readiness.ts +90 -0
  219. package/src/schedule-agent-mcp-http.test.ts +381 -0
  220. package/src/schedule-agent.ts +192 -0
  221. package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
  222. package/src/scheduled-failures-http.test.ts +233 -0
  223. package/src/scheduled-failures.ts +179 -0
  224. package/src/scheduled-offline.test.ts +131 -0
  225. package/src/scheduled-offline.ts +66 -0
  226. package/src/scheduled-reply-unread-http.test.ts +212 -0
  227. package/src/scheduled-run-terminal.test.ts +268 -0
  228. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  229. package/src/scheduled-stuck-http.test.ts +238 -0
  230. package/src/scheduled-stuck.ts +153 -0
  231. package/src/scheduled-tasks-http.test.ts +638 -0
  232. package/src/scheduled-tasks.ts +766 -0
  233. package/src/scoped-secrets.ts +124 -0
  234. package/src/send-dedup-request-id.test.ts +229 -0
  235. package/src/send-reply-agent-warning.test.ts +202 -0
  236. package/src/send-reply-alias-optional.test.ts +131 -0
  237. package/src/send-reply-attachments.test.ts +458 -0
  238. package/src/send_dedup.test.ts +54 -0
  239. package/src/send_dedup.ts +52 -6
  240. package/src/serve-error.test.ts +38 -0
  241. package/src/serve-error.ts +27 -0
  242. package/src/server.ts +5257 -0
  243. package/src/session-task-on-dispatch.ts +14 -0
  244. package/src/session-token-ownership-http.test.ts +122 -0
  245. package/src/shared/codex-inventory.ts +41 -0
  246. package/src/shared/daemon-provider-login.ts +31 -0
  247. package/src/shared/daemon-provider-snapshot.ts +52 -0
  248. package/src/shared/external-schedule-contract.ts +60 -0
  249. package/src/shared/node-name.test.ts +62 -0
  250. package/src/shared/node-name.ts +161 -0
  251. package/src/shared/provider-application.ts +29 -0
  252. package/src/shared/provider-collection.ts +14 -0
  253. package/src/shared/provider-initialization.ts +8 -0
  254. package/src/shared/reserved-env-drift.test.ts +10 -0
  255. package/src/shared/scoped-secret-contract.ts +5 -0
  256. package/src/shared/task-content-limit-drift.test.ts +28 -0
  257. package/src/shared/task-content-limit.ts +25 -0
  258. package/src/side-thread-command-transport.test.ts +312 -0
  259. package/src/side-thread-command-transport.ts +318 -0
  260. package/src/side-thread-http-integration.test.ts +233 -0
  261. package/src/side-thread-http.ts +504 -0
  262. package/src/side-thread-production.test.ts +72 -0
  263. package/src/side-thread-production.ts +118 -0
  264. package/src/side-thread.test.ts +1067 -0
  265. package/src/side-thread.ts +2499 -0
  266. package/src/skillhub-http.test.ts +108 -0
  267. package/src/sqlite-close-checkpoint.test.ts +78 -0
  268. package/src/start-node.test.ts +264 -0
  269. package/src/status-alias-filter-http.test.ts +106 -0
  270. package/src/status-alias-resolver-http.test.ts +163 -0
  271. package/src/status-alias-resolver.ts +19 -0
  272. package/src/status-node-id-filter-http.test.ts +161 -0
  273. package/src/status-read-cache-http.test.ts +229 -0
  274. package/src/status-read-cache.ts +122 -0
  275. package/src/stop-delete-node.test.ts +509 -1
  276. package/src/task-access-http.test.ts +402 -0
  277. package/src/task-access.ts +420 -0
  278. package/src/task-auth-origin-http.test.ts +93 -0
  279. package/src/task-auth-origin.test.ts +24 -0
  280. package/src/task-auth-origin.ts +7 -0
  281. package/src/task-consumption.test.ts +482 -0
  282. package/src/task-diagnostic-http.test.ts +149 -0
  283. package/src/task-diagnostic.test.ts +115 -0
  284. package/src/task-diagnostic.ts +118 -0
  285. package/src/task-expiry-notice-http.test.ts +287 -0
  286. package/src/task-expiry-notice.ts +315 -0
  287. package/src/task-idempotency-mcp.test.ts +142 -0
  288. package/src/task-idempotency.test.ts +60 -0
  289. package/src/task-idempotency.ts +75 -0
  290. package/src/task-late-replies.ts +54 -0
  291. package/src/task-late-reply-http.test.ts +152 -0
  292. package/src/task-lifecycle-watcher.test.ts +204 -0
  293. package/src/task-lifecycle-watcher.ts +49 -0
  294. package/src/task-network-resolution-http.test.ts +140 -0
  295. package/src/task-orphan-http.test.ts +287 -0
  296. package/src/task-orphan.ts +119 -0
  297. package/src/task-queue-ahead-http.test.ts +309 -0
  298. package/src/task-queue-ahead-scale.test.ts +236 -0
  299. package/src/task-queue-ahead.ts +186 -0
  300. package/src/task-rejection-log-http.test.ts +160 -0
  301. package/src/task-rejection-log.ts +49 -0
  302. package/src/task-sender-http.test.ts +143 -0
  303. package/src/task-stale-open-http.test.ts +290 -0
  304. package/src/task-stale-open.ts +103 -0
  305. package/src/tasks-pagination-http.test.ts +141 -0
  306. package/src/tool-audience-http.test.ts +220 -0
  307. package/src/tool-audience.ts +118 -0
  308. package/src/tools-list-json-schema.test.ts +67 -0
  309. package/src/tools.ts +3940 -325
  310. package/src/upload-early-reject-guard.test.ts +155 -0
  311. package/src/uploads-http.test.ts +97 -15
  312. package/src/uploads.ts +103 -6
  313. package/src/user-avatar-http.test.ts +74 -0
  314. package/src/user-avatar-migration.test.ts +25 -0
  315. package/src/user-inbox-by-agent-http.test.ts +161 -0
  316. package/src/user-inbox-netid-notnull.test.ts +122 -0
  317. package/src/user-inbox-read-http.test.ts +223 -0
  318. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,368 @@
1
+ // Desktop user push contract.
2
+ //
3
+ // Pins the new user-keyed SSE path and send_desktop_message MCP tool:
4
+ // - user streams are keyed by networkId:userId, never alias
5
+ // - ntok callers cannot cross network by supplying a foreign to_user_id/network_id
6
+ // - target ambiguity/miss is fail-closed before audit/push
7
+
8
+ import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test";
9
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
+ import { db } from "./db.js";
11
+ import { registerTools } from "./tools.js";
12
+ import {
13
+ __resetSSEClientsForTest,
14
+ createUserEventStream,
15
+ pushUserEvent,
16
+ } from "./push.js";
17
+
18
+ const NET_A = "net_desktop_a";
19
+ const NET_B = "net_desktop_b";
20
+ const NODE_USER = "u_desktop_node_owner";
21
+ const SENDER_USER = "u_desktop_sender";
22
+ const TARGET_A = "u_desktop_target_a";
23
+ const TARGET_B = "u_desktop_target_b";
24
+ const TARGET_BOTH = "u_desktop_target_both";
25
+ const ALL_USERS = [NODE_USER, SENDER_USER, TARGET_A, TARGET_B, TARGET_BOTH];
26
+
27
+ type ToolHandler = (args: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
28
+
29
+ function cleanup() {
30
+ try { db.run("DELETE FROM audit_log WHERE action = 'send_desktop_message' OR network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {}
31
+ try { db.run("DELETE FROM network_members WHERE network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {}
32
+ try { db.run("DELETE FROM networks WHERE network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {}
33
+ for (const u of ALL_USERS) {
34
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [u]); } catch {}
35
+ }
36
+ }
37
+
38
+ function seed() {
39
+ for (const u of ALL_USERS) {
40
+ db.run(
41
+ `INSERT INTO users (user_id, username, password_hash, role, created_at)
42
+ VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
43
+ [u, `${u}_name`],
44
+ );
45
+ }
46
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET_A, SENDER_USER]);
47
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET_B, TARGET_B]);
48
+ const member = (userId: string, networkId: string, role: string) =>
49
+ db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, ?3, datetime('now'))`, [userId, networkId, role]);
50
+ member(NODE_USER, NET_A, "member");
51
+ member(SENDER_USER, NET_A, "owner");
52
+ member(TARGET_A, NET_A, "member");
53
+ member(TARGET_B, NET_B, "member");
54
+ member(TARGET_BOTH, NET_A, "member");
55
+ member(TARGET_BOTH, NET_B, "member");
56
+ }
57
+
58
+ beforeEach(() => { cleanup(); seed(); });
59
+ afterEach(() => { __resetSSEClientsForTest(); });
60
+ afterAll(cleanup);
61
+
62
+ function buildHandlers(opts: { netId?: string | null; userId?: string | null; alias?: string | null; isNetwork?: boolean }) {
63
+ const server = new McpServer({ name: "test", version: "0" }) as any;
64
+ const tools: Record<string, ToolHandler> = {};
65
+ const origTool = server.tool.bind(server);
66
+ server.tool = (name: string, _desc: string, schema: any, handler: ToolHandler) => {
67
+ tools[name] = handler;
68
+ return origTool(name, _desc, schema, handler);
69
+ };
70
+ registerTools(
71
+ server,
72
+ undefined,
73
+ opts.netId ?? null,
74
+ opts.userId ?? null,
75
+ opts.alias ?? opts.userId ?? null,
76
+ opts.isNetwork ?? false,
77
+ "tok_desktop_test",
78
+ );
79
+ return tools;
80
+ }
81
+
82
+ async function call(handler: ToolHandler, args: any): Promise<Record<string, any>> {
83
+ const r = await handler(args);
84
+ return JSON.parse(r.content[0].text);
85
+ }
86
+
87
+ async function readFrame(
88
+ reader: ReadableStreamDefaultReader<Uint8Array>,
89
+ timeoutMs = 2_000,
90
+ ): Promise<Record<string, any>> {
91
+ const decoder = new TextDecoder();
92
+ let buf = "";
93
+ const deadline = Date.now() + timeoutMs;
94
+ while (Date.now() < deadline) {
95
+ const result = await Promise.race([
96
+ reader.read(),
97
+ new Promise<never>((_, rej) =>
98
+ setTimeout(() => rej(new Error(`no SSE frame within ${timeoutMs}ms`)), deadline - Date.now()),
99
+ ),
100
+ ]);
101
+ if (result.done) throw new Error("SSE stream ended unexpectedly");
102
+ buf += decoder.decode(result.value, { stream: true });
103
+ const sep = buf.indexOf("\n\n");
104
+ if (sep === -1) continue;
105
+ const rawFrame = buf.slice(0, sep);
106
+ buf = buf.slice(sep + 2);
107
+ const dataLine = rawFrame.split("\n").find((l) => l.startsWith("data: "));
108
+ if (!dataLine) continue;
109
+ return JSON.parse(dataLine.slice(6));
110
+ }
111
+ throw new Error(`no SSE frame within ${timeoutMs}ms`);
112
+ }
113
+
114
+ function auditCount() {
115
+ return db.get<{ n: number }>("SELECT COUNT(*) AS n FROM audit_log WHERE action = 'send_desktop_message'")?.n ?? 0;
116
+ }
117
+
118
+ describe("send_desktop_message", () => {
119
+ test("ntok caller can push to a user in the same bound network", async () => {
120
+ const stream = createUserEventStream(NET_A, TARGET_A);
121
+ const reader = stream.body!.getReader();
122
+ await readFrame(reader);
123
+
124
+ const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
125
+ const result = await call(tools.send_desktop_message, {
126
+ to_user_id: TARGET_A,
127
+ message: "desktop hello",
128
+ severity: "success",
129
+ });
130
+
131
+ expect(result.ok).toBe(true);
132
+ expect(result.message_id).toStartWith("dm_");
133
+ expect(auditCount()).toBe(1);
134
+ const evt = await readFrame(reader);
135
+ expect(evt.type).toBe("desktop_message");
136
+ expect(evt.message_id).toBe(result.message_id);
137
+ expect(evt.message).toBe("desktop hello");
138
+ expect(evt.from).toBe("node-a");
139
+ expect(evt.network_id).toBe(NET_A);
140
+ expect(evt.user_id).toBe(TARGET_A);
141
+ expect(evt.scope).toBe("user");
142
+ await reader.cancel();
143
+ });
144
+
145
+ test("utok caller can push with explicit network_id to a member user", async () => {
146
+ const stream = createUserEventStream(NET_A, TARGET_A);
147
+ const reader = stream.body!.getReader();
148
+ await readFrame(reader);
149
+
150
+ const tools = buildHandlers({ userId: SENDER_USER, alias: "sender-user" });
151
+ const result = await call(tools.send_desktop_message, {
152
+ to_username: `${TARGET_A}_name`,
153
+ network_id: NET_A,
154
+ title: "Heads up",
155
+ message: "from user token",
156
+ });
157
+
158
+ expect(result.ok).toBe(true);
159
+ const evt = await readFrame(reader);
160
+ expect(evt.title).toBe("Heads up");
161
+ expect(evt.message).toBe("from user token");
162
+ expect(evt.network_id).toBe(NET_A);
163
+ await reader.cancel();
164
+ });
165
+
166
+ test("ntok caller cannot cross network with foreign network_id and to_user_id; no audit or push happens first", async () => {
167
+ const stream = createUserEventStream(NET_B, TARGET_B);
168
+ const reader = stream.body!.getReader();
169
+ await readFrame(reader);
170
+
171
+ const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
172
+ const result = await call(tools.send_desktop_message, {
173
+ to_user_id: TARGET_B,
174
+ network_id: NET_B,
175
+ message: "must not cross",
176
+ });
177
+
178
+ expect(result).toEqual({ ok: false, error: "desktop_target_not_in_network", network_id: NET_A });
179
+ expect(auditCount()).toBe(0);
180
+
181
+ pushUserEvent(NET_B, TARGET_B, { type: "desktop_message", message_id: "marker_after_denied" });
182
+ expect((await readFrame(reader)).message_id).toBe("marker_after_denied");
183
+ await reader.cancel();
184
+ });
185
+
186
+ test("missing target is fail-closed before audit", async () => {
187
+ const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
188
+ const result = await call(tools.send_desktop_message, { message: "nobody" });
189
+ expect(result).toEqual({
190
+ ok: false,
191
+ error: "desktop_target_required",
192
+ message: "to_user_id or to_username is required",
193
+ });
194
+ expect(auditCount()).toBe(0);
195
+ });
196
+
197
+ test("conflicting to_user_id and to_username is fail-closed before audit", async () => {
198
+ const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
199
+ const result = await call(tools.send_desktop_message, {
200
+ to_user_id: TARGET_A,
201
+ to_username: `${TARGET_BOTH}_name`,
202
+ message: "conflict",
203
+ });
204
+ expect(result).toEqual({
205
+ ok: false,
206
+ error: "desktop_target_mismatch",
207
+ to_user_id: TARGET_A,
208
+ to_username: `${TARGET_BOTH}_name`,
209
+ });
210
+ expect(auditCount()).toBe(0);
211
+ });
212
+
213
+ test("unknown target is fail-closed before audit", async () => {
214
+ const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
215
+ const result = await call(tools.send_desktop_message, {
216
+ to_username: "not_a_real_user",
217
+ message: "lost",
218
+ });
219
+ expect(result).toEqual({ ok: false, error: "desktop_target_not_found", field: "to_username" });
220
+ expect(auditCount()).toBe(0);
221
+ });
222
+ });
223
+
224
+ // #1459 —— 返回值必须反映真实投递态。
225
+ //
226
+ // send_desktop_message 是 fire-and-forget:只 INSERT audit_log(不写 inbox),
227
+ // 再 pushUserEvent 扇出;而 pushUserEvent 在没有订阅者时静默 return。
228
+ // 用户 dashboard 离线/重连时消息永久丢(无 DB 行 ⇒ 无从 reconcile,
229
+ // /api/messages 只 SELECT ... FROM inbox),而调用方拿到的是 ok:true。
230
+ //
231
+ // 本 PR 只做「让丢失可见」这一半:不碰 schema,返回值说实话。
232
+ // 持久化 + 重连补投(让丢失不发生)是另一条,方向已定为按 user_id 寻址的新表。
233
+ describe("#1459 send_desktop_message 返回值反映真实投递态", () => {
234
+ test("🔴 无活跃订阅者 ⇒ delivered:false + 可判读的 reason,而不是笼统 ok:true", async () => {
235
+ // 故意不建 SSE 流:这正是用户 dashboard 关着的情形
236
+ const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
237
+ const before = auditCount();
238
+ const result = await call(tools.send_desktop_message, {
239
+ to_user_id: TARGET_A,
240
+ message: "nobody is listening",
241
+ severity: "info",
242
+ });
243
+
244
+ // ok 仍为 true:调用被接受、审计已落库 —— 这一格没变
245
+ expect(result.ok).toBe(true);
246
+ expect(result.message_id).toStartWith("dm_");
247
+ expect(auditCount()).toBe(before + 1);
248
+
249
+ // 新增的那一格:投递态
250
+ expect(result.delivered).toBe(false);
251
+ expect(result.reason).toBe("no_live_subscriber");
252
+ });
253
+
254
+ test("有活跃订阅者 ⇒ delivered:true 且不带 reason", async () => {
255
+ const stream = createUserEventStream(NET_A, TARGET_A);
256
+ const reader = stream.body!.getReader();
257
+ await readFrame(reader);
258
+
259
+ const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
260
+ const result = await call(tools.send_desktop_message, {
261
+ to_user_id: TARGET_A,
262
+ message: "someone is listening",
263
+ severity: "info",
264
+ });
265
+
266
+ expect(result.delivered).toBe(true);
267
+ expect(result.reason).toBeUndefined();
268
+ // 前提断言:这一条真的送到了订阅者手上,否则 delivered:true 就是空话
269
+ const evt = await readFrame(reader);
270
+ expect(evt.message).toBe("someone is listening");
271
+ await reader.cancel();
272
+ });
273
+
274
+ test("🔴 两种情形必须给出不同读数 —— 防止判据退化成恒真", async () => {
275
+ const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
276
+ const offline = await call(tools.send_desktop_message, { to_user_id: TARGET_A, message: "off", severity: "info" });
277
+
278
+ const stream = createUserEventStream(NET_A, TARGET_A);
279
+ const reader = stream.body!.getReader();
280
+ await readFrame(reader);
281
+ const online = await call(tools.send_desktop_message, { to_user_id: TARGET_A, message: "on", severity: "info" });
282
+ await reader.cancel();
283
+
284
+ expect(offline.delivered).not.toBe(online.delivered);
285
+ });
286
+ });
287
+
288
+ // #1459 ① P2 —— 写入持久化。
289
+ describe("#1459 ① P2 send_desktop_message 持久化", () => {
290
+ const rowsFor = (mid: string) =>
291
+ db.get<{ n: number }>("SELECT COUNT(*) AS n FROM user_inbox WHERE message_id = ?1", mid)?.n ?? 0;
292
+
293
+ test("🔴 发一条消息 ⇒ user_inbox 落一行,字段与事件一致", async () => {
294
+ const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
295
+ const r = await call(tools.send_desktop_message, {
296
+ to_user_id: TARGET_A, message: "persist me", severity: "warning", title: "T", kind: "agent_message",
297
+ });
298
+ expect(r.ok).toBe(true);
299
+ expect(r.persisted).toBe(true);
300
+ const row = db.get<any>("SELECT * FROM user_inbox WHERE message_id = ?1", r.message_id);
301
+ expect(row).toBeTruthy();
302
+ expect(row.user_id).toBe(TARGET_A);
303
+ expect(row.content).toBe("persist me");
304
+ expect(row.severity).toBe("warning");
305
+ expect(row.title).toBe("T");
306
+ expect(row.from_session).toBe("node-a");
307
+ expect(row.network_id).toBe(NET_A);
308
+ expect(row.acked).toBe(0);
309
+ });
310
+
311
+ test("🔴 没有活订阅者时也要落库 —— 「丢了」变成「待补投」的那一格", async () => {
312
+ const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
313
+ const r = await call(tools.send_desktop_message, { to_user_id: TARGET_A, message: "offline body" });
314
+ expect(r.delivered).toBe(false);
315
+ expect(r.reason).toBe("no_live_subscriber");
316
+ expect(r.persisted).toBe(true);
317
+ expect(rowsFor(r.message_id)).toBe(1);
318
+ });
319
+
320
+ test("🔴 zod 默认值不可依赖:直接调 handler 不传 kind/severity 也必须落库", async () => {
321
+ // 测试与未来的内部调用方绕过 MCP 的 zod .default(),拿到的是 undefined。
322
+ // 列上虽有 DEFAULT,但显式传 NULL 会覆盖它 —— 所以写入侧要自己兜。
323
+ const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
324
+ const r = await call(tools.send_desktop_message, { to_user_id: TARGET_A, message: "no kind given" });
325
+ expect(rowsFor(r.message_id)).toBe(1);
326
+ const row = db.get<any>("SELECT kind, severity FROM user_inbox WHERE message_id = ?1", r.message_id);
327
+ expect(row.kind).toBe("agent_message");
328
+ expect(row.severity).toBe("info");
329
+ });
330
+
331
+ test("audit_log 与 user_inbox 同生共死(同一事务,条数一致)", async () => {
332
+ const before = auditCount();
333
+ const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
334
+ const r = await call(tools.send_desktop_message, { to_user_id: TARGET_A, message: "paired" });
335
+ expect(auditCount()).toBe(before + 1);
336
+ expect(rowsFor(r.message_id)).toBe(1);
337
+ });
338
+ });
339
+
340
+ // #1459 ① P2 —— 幂等写法的选择本身要被钉住。
341
+ //
342
+ // 🔴 实现时踩到的:最初用 `INSERT OR IGNORE`,它对「重投同一 message_id」是对的,
343
+ // 但**连 NOT NULL 违规也一起吞掉** —— kind 为 undefined 时整行被静默丢弃,
344
+ // 而工具照样返回 persisted:true。**一条消息凭空消失且报告成功**,正是本
345
+ // issue 要消灭的那个形状。改成 ON CONFLICT(message_id) DO NOTHING:
346
+ // 只赦免主键冲突,别的约束照常抛。
347
+ describe("#1459 ① P2 幂等写法:赦免重复,但不吞约束错误", () => {
348
+ const stmt = `INSERT INTO user_inbox
349
+ (message_id, network_id, user_id, from_session, kind, title, content, severity, meta_json)
350
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)
351
+ ON CONFLICT(message_id) DO NOTHING`;
352
+
353
+ test("重复的 message_id ⇒ 不抛错、也不产生第二行", () => {
354
+ const mid = `dm_dup_${Date.now()}`;
355
+ const ins = () => db.run(stmt, [mid, NET_A, TARGET_A, "node-a", "info", null, "body", "info", null]);
356
+ ins();
357
+ expect(() => ins()).not.toThrow();
358
+ expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM user_inbox WHERE message_id = ?1", mid)?.n).toBe(1);
359
+ });
360
+
361
+ test("🔴 NOT NULL 违规必须抛出来 —— 不能像 OR IGNORE 那样静默丢行", () => {
362
+ const mid = `dm_bad_${Date.now()}`;
363
+ expect(() =>
364
+ db.run(stmt, [mid, NET_A, TARGET_A, "node-a", null, null, "body", "info", null]),
365
+ ).toThrow();
366
+ expect(db.get<{ n: number }>("SELECT COUNT(*) AS n FROM user_inbox WHERE message_id = ?1", mid)?.n).toBe(0);
367
+ });
368
+ });
@@ -0,0 +1,178 @@
1
+ // 人与人私信的文件只给私信里的人看(owner 2026-09-30「给人好像发不了图片」的后续)。
2
+ //
3
+ // 规则:文件上传时带 `?purpose=dm`(App 私信输入框这么传)= 它第一次用途就是私信 → 私信文件(index scope=dm)。
4
+ // 私信文件只有上传者、带着它的私信的收发双方、管理员能下载;同网络的其他成员和节点令牌(Agent)都 404
5
+ // (与其它拒绝同一个形状,不泄露文件存在)。不带 purpose 上传的文件 —— 例如先在 agent 会话里发过 —— 永远不会
6
+ // 变成私信文件,之后转进私信也照旧全网成员可见。私信文件只能由看得见它的人再转发。
7
+
8
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
9
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
10
+ import { join } from "path";
11
+ import { tmpdir } from "os";
12
+ import { register, addNetworkMember } from "./auth.js";
13
+ import { db } from "./db.js";
14
+
15
+ const DIR = mkdtempSync(join(tmpdir(), "anet-dm-file-scope-"));
16
+ const PW = "DmFileScopePassw0rd!";
17
+ let BASE = "";
18
+ let hub: any = null;
19
+ let NET = "";
20
+ type U = { token: string; id: string };
21
+ let admin: U, alice: U, bob: U, carol: U, dave: U;
22
+ let adminNtok = "";
23
+
24
+ const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
25
+ async function upload(token: string, body: string, query: string) {
26
+ const form = new FormData();
27
+ form.append("file", new Blob([new TextEncoder().encode(body)], { type: "image/png" }), "shot.png");
28
+ const res = await fetch(`${BASE}/api/upload?network_id=${NET}${query}`, { method: "POST", body: form, headers: auth(token) });
29
+ return { status: res.status, body: await res.json().catch(() => null) as any };
30
+ }
31
+ async function uploadId(token: string, body: string, query = "") {
32
+ const r = await upload(token, body, query);
33
+ expect(r.status).toBe(200);
34
+ return r.body.file_id as string;
35
+ }
36
+ async function dm(from: U, to: U, fileId: string, message = "") {
37
+ const res = await fetch(`${BASE}/api/dm`, {
38
+ method: "POST",
39
+ headers: { ...auth(from.token), "Content-Type": "application/json" },
40
+ body: JSON.stringify({ network_id: NET, to_user_id: to.id, message, attachments: [{ type: "file", file_id: fileId, name: "shot.png", mime: "image/png" }] }),
41
+ });
42
+ return { status: res.status, body: await res.json().catch(() => null) as any };
43
+ }
44
+ const fileStatus = async (token: string, fileId: string, method = "GET") =>
45
+ (await fetch(`${BASE}/api/files/${fileId}`, { method, headers: auth(token) })).status;
46
+ const entry = (fileId: string) => JSON.parse(readFileSync(join(DIR, "uploads", ".index", `${fileId}.json`), "utf8"));
47
+
48
+ beforeAll(async () => {
49
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
50
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
51
+ process.env.HOST = "127.0.0.1";
52
+ const stamp = Date.now();
53
+ const a = register(`dmfs_admin_${stamp}`, PW);
54
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [a.user!.user_id]);
55
+ admin = { token: a.token!, id: a.user!.user_id };
56
+ adminNtok = a.network_token!;
57
+ NET = a.network_id!;
58
+ // alice / bob / carol see every agent (agent_access=all) — the members for whom a network-scoped file was readable
59
+ // before, i.e. where the gap was. dave is a restricted member (only granted agents).
60
+ const mk = (name: string, agentAccess: "all" | "granted"): U => {
61
+ const r = register(`${name}_${stamp}`, PW);
62
+ expect(addNetworkMember(NET, r.user!.user_id, "member", admin.id, { agentAccess }).ok).toBe(true);
63
+ return { token: r.token!, id: r.user!.user_id };
64
+ };
65
+ alice = mk("dmfs_alice", "all"); bob = mk("dmfs_bob", "all"); carol = mk("dmfs_carol", "all"); dave = mk("dmfs_dave", "granted");
66
+ const mod: any = await import("./server.js");
67
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
68
+ BASE = `http://127.0.0.1:${hub.port}`;
69
+ }, 30_000);
70
+
71
+ afterAll(() => {
72
+ try { hub?.stop?.(true); } catch {}
73
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
74
+ });
75
+
76
+ describe("upload ?purpose=dm", () => {
77
+ test("marks the index entry; absent purpose writes no scope key (old shape)", async () => {
78
+ expect(entry(await uploadId(alice.token, "p1", "&purpose=dm")).scope).toBe("dm");
79
+ expect("scope" in entry(await uploadId(alice.token, "p2"))).toBe(false);
80
+ });
81
+ test("an unknown purpose is refused, not silently treated as network scope", async () => {
82
+ const r = await upload(alice.token, "p3", "&purpose=DM");
83
+ expect(r.status).toBe(400);
84
+ expect(r.body.error).toBe("bad_purpose");
85
+ });
86
+ });
87
+
88
+ describe("a DM file is readable only by the people in the DM", () => {
89
+ test("alice → bob: alice and bob read it; carol (same network, not in the DM) and agents get 404; admin reads", async () => {
90
+ const fid = await uploadId(alice.token, "alice-to-bob", "&purpose=dm");
91
+ expect((await dm(alice, bob, fid)).status).toBe(200);
92
+ expect(await fileStatus(alice.token, fid)).toBe(200);
93
+ expect(await fileStatus(bob.token, fid)).toBe(200);
94
+ expect(await fileStatus(carol.token, fid)).toBe(404);
95
+ expect(await fileStatus(carol.token, fid, "HEAD")).toBe(404);
96
+ expect(await fileStatus(adminNtok, fid)).toBe(404);
97
+ expect(await fileStatus(admin.token, fid)).toBe(200);
98
+ });
99
+ test("bob → alice (other direction): same rule", async () => {
100
+ const fid = await uploadId(bob.token, "bob-to-alice", "&purpose=dm");
101
+ expect((await dm(bob, alice, fid)).status).toBe(200);
102
+ expect(await fileStatus(bob.token, fid)).toBe(200);
103
+ expect(await fileStatus(alice.token, fid)).toBe(200);
104
+ expect(await fileStatus(carol.token, fid)).toBe(404);
105
+ });
106
+ test("a restricted member in the DM reads it both ways; out of it, not", async () => {
107
+ const toDave = await uploadId(alice.token, "alice-to-dave", "&purpose=dm");
108
+ expect((await dm(alice, dave, toDave)).status).toBe(200);
109
+ expect(await fileStatus(dave.token, toDave)).toBe(200);
110
+ const fromDave = await uploadId(dave.token, "dave-to-alice", "&purpose=dm");
111
+ expect((await dm(dave, alice, fromDave)).status).toBe(200);
112
+ expect(await fileStatus(alice.token, fromDave)).toBe(200);
113
+ expect(await fileStatus(bob.token, fromDave)).toBe(404);
114
+ const other = await uploadId(alice.token, "alice-to-bob-2", "&purpose=dm");
115
+ await dm(alice, bob, other);
116
+ expect(await fileStatus(dave.token, other)).toBe(404);
117
+ });
118
+ test("before it is sent, only the uploader reads it", async () => {
119
+ const fid = await uploadId(alice.token, "draft", "&purpose=dm");
120
+ expect(await fileStatus(alice.token, fid)).toBe(200);
121
+ expect(await fileStatus(bob.token, fid)).toBe(404);
122
+ });
123
+ test("the DM thread still returns the attachment to both sides", async () => {
124
+ const fid = await uploadId(alice.token, "thread", "&purpose=dm");
125
+ await dm(alice, bob, fid, "看图");
126
+ const t = await (await fetch(`${BASE}/api/dm?network_id=${NET}&with=${alice.id}`, { headers: auth(bob.token) })).json() as any;
127
+ expect(JSON.parse(t.messages[0].meta_json).attachments[0].file_id).toBe(fid);
128
+ });
129
+ });
130
+
131
+ describe("nobody unlocks a DM file by putting its id into their own DM", () => {
132
+ test("carol DMs alice's DM file to bob → 403, and she still can't read it", async () => {
133
+ const fid = await uploadId(alice.token, "secret", "&purpose=dm");
134
+ expect((await dm(alice, bob, fid)).status).toBe(200);
135
+ const r = await dm(carol, bob, fid);
136
+ expect(r.status).toBe(403);
137
+ expect(r.body.error).toBe("attachment_not_accessible");
138
+ expect(await fileStatus(carol.token, fid)).toBe(404);
139
+ });
140
+ test("bob (who received it) may forward it to carol, and then carol reads it", async () => {
141
+ const fid = await uploadId(alice.token, "forwardable", "&purpose=dm");
142
+ expect((await dm(alice, bob, fid)).status).toBe(200);
143
+ expect((await dm(bob, carol, fid)).status).toBe(200);
144
+ expect(await fileStatus(carol.token, fid)).toBe(200);
145
+ });
146
+ });
147
+
148
+ describe("files whose first use was not a DM keep their scope", () => {
149
+ test("uploaded in an agent chat, then forwarded into a DM → still readable by every member", async () => {
150
+ const fid = await uploadId(alice.token, "agent-chat-first");
151
+ // first use: an agent task (what ChatScreen does: upload without purpose, send_task with the file id)
152
+ db.run(
153
+ `INSERT INTO tasks (task_id, from_name, to_name, status, content, network_id, meta_json) VALUES (?1, ?2, 'dmfs-agent', 'delivered', 'see image', ?3, ?4)`,
154
+ [`t_dmfs_${Date.now()}`, "dmfs_alice", NET, JSON.stringify({ attachments: [{ type: "file", file_id: fid }] })],
155
+ );
156
+ expect(await fileStatus(carol.token, fid)).toBe(200);
157
+ expect((await dm(alice, bob, fid)).status).toBe(200);
158
+ expect("scope" in entry(fid)).toBe(false);
159
+ expect(await fileStatus(carol.token, fid)).toBe(200);
160
+ expect(await fileStatus(bob.token, fid)).toBe(200);
161
+ expect(await fileStatus(adminNtok, fid)).toBe(200);
162
+ });
163
+ test("an old app's DM upload (no purpose) behaves exactly as before", async () => {
164
+ const fid = await uploadId(alice.token, "old-app");
165
+ expect((await dm(alice, bob, fid)).status).toBe(200);
166
+ expect(await fileStatus(carol.token, fid)).toBe(200);
167
+ });
168
+ });
169
+
170
+ describe("index entry integrity", () => {
171
+ test("a hand-edited unknown scope makes the entry invalid (404), never network-wide", async () => {
172
+ const fid = await uploadId(alice.token, "tamper", "&purpose=dm");
173
+ const path = join(DIR, "uploads", ".index", `${fid}.json`);
174
+ writeFileSync(path, JSON.stringify({ ...entry(fid), scope: "public" }));
175
+ expect(await fileStatus(alice.token, fid)).toBe(404);
176
+ expect(await fileStatus(carol.token, fid)).toBe(404);
177
+ });
178
+ });
@@ -0,0 +1,60 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ buildStoragePath,
4
+ pathForExistingBlob,
5
+ validateIndexEntry,
6
+ } from "./uploads.js";
7
+
8
+ const fileId = "0123456789abcdef0123456789abcdef";
9
+ const dateBucket = "2026-08-09";
10
+ const uploadsRoot = "/srv/uploads";
11
+
12
+ const malicious = [
13
+ "../x",
14
+ "/abs/x",
15
+ ".png/../../y",
16
+ ".p ng",
17
+ ".p\0ng",
18
+ ".\\..\\x",
19
+ "." + "a".repeat(17),
20
+ ".tar.gz",
21
+ ];
22
+
23
+ const legal = ["", ".png", ".PNG", ".mp4", "." + "a".repeat(16)];
24
+
25
+ describe("#527 shared extension-token invariant", () => {
26
+ test("new-upload path rejects every malformed token and accepts the shared legal set", () => {
27
+ for (const ext of malicious) {
28
+ expect(() => buildStoragePath(fileId, ext, { uploadsRoot })).toThrow(/ext .* is invalid/);
29
+ }
30
+ for (const ext of legal) {
31
+ expect(buildStoragePath(fileId, ext, { uploadsRoot }).ext).toBe(ext);
32
+ }
33
+ });
34
+
35
+ test("existing-blob path rejects every malformed token and accepts the shared legal set", () => {
36
+ for (const ext of malicious) {
37
+ expect(() => pathForExistingBlob(dateBucket, fileId, ext, { uploadsRoot })).toThrow(/ext .* is invalid/);
38
+ }
39
+ for (const ext of legal) {
40
+ expect(pathForExistingBlob(dateBucket, fileId, ext, { uploadsRoot }).ext).toBe(ext);
41
+ }
42
+ });
43
+
44
+ test("stored-index gate rejects every malformed token and accepts the shared legal set", () => {
45
+ for (const ext of malicious) {
46
+ expect(validateIndexEntry({ file_id: fileId, date_bucket: dateBucket, ext, size: 0 })).toBe(false);
47
+ }
48
+ for (const ext of legal) {
49
+ expect(validateIndexEntry({ file_id: fileId, date_bucket: dateBucket, ext, size: 0 })).toBe(true);
50
+ }
51
+ });
52
+
53
+ test("all three boundaries fail closed on non-string runtime values", () => {
54
+ for (const ext of [undefined, null, 7, {}, []]) {
55
+ expect(() => buildStoragePath(fileId, ext as never, { uploadsRoot })).toThrow(/ext .* is invalid/);
56
+ expect(() => pathForExistingBlob(dateBucket, fileId, ext as never, { uploadsRoot })).toThrow(/ext .* is invalid/);
57
+ expect(validateIndexEntry({ file_id: fileId, date_bucket: dateBucket, ext, size: 0 })).toBe(false);
58
+ }
59
+ });
60
+ });