@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,155 @@
1
+ // #503 F3=A static guard: every 4xx return in the /api/upload handler
2
+ // that fires BEFORE `req.formData()` drains the request body MUST be
3
+ // wrapped in `earlyReject` (which sets `Connection: close`). Otherwise
4
+ // the client's still-inbound body poisons the keepalive pool and the
5
+ // next pooled request stalls.
6
+ //
7
+ // Why a STATIC guard (not a runtime one): mutation M9 in
8
+ // docs/tests/p-503-file-network-scope/mutation-matrix.txt showed that
9
+ // under Bun's fetch client in this test setup, removing
10
+ // `Connection: close` does NOT turn any dynamic test red — the pool
11
+ // poisoning is real HTTP-level behavior but the client under test does
12
+ // not reliably reproduce it. Without a guard, deleting the helper or
13
+ // forgetting to wrap a NEW 15th pre-drain return goes silent. This
14
+ // test judges the SOURCE FILE, per lead 7a88ce29 and Constraint 3-1
15
+ // pattern (source-based, not runner-output-based).
16
+ //
17
+ // PRIOR-ART GOTCHA (lead 7a88ce29 踩过): parse boundaries MUST exclude
18
+ // comments. `req.formData()` appears in a doc comment near the top of
19
+ // the handler too; if the boundary matches on that, the pre-drain
20
+ // range collapses to empty and this guard silently becomes vacuously
21
+ // true. Same trap family as "范围为空的全称断言恒真". So this test
22
+ // has an explicit precondition: the parsed pre-drain return count must
23
+ // be ≥ 10 (currently 14). If the count drops to 0, the boundary parser
24
+ // broke, not the code.
25
+
26
+ import { describe, expect, test } from "bun:test";
27
+ import { readFileSync } from "fs";
28
+ import { join } from "path";
29
+
30
+ const SOURCE_PATH = join(import.meta.dirname, "server.ts");
31
+ const RAW = readFileSync(SOURCE_PATH, "utf-8");
32
+ const LINES = RAW.split("\n");
33
+
34
+ // Strip line-comments so we don't match on comment-mentioned tokens
35
+ // like the "req.formData()" doc references at the top of the handler.
36
+ // (Block comments /* */ are rare inside this handler; if you add one
37
+ // that mentions the boundary, extend this helper.)
38
+ //
39
+ // KNOWN LIMITATION (lead 50208c1b): this naive strip cuts on ANY `//`,
40
+ // including one inside a string literal (e.g. `"https://…"`). If a
41
+ // pre-drain line ever contains such a literal, the strip could snip
42
+ // off the return statement after it and produce a false NEGATIVE (miss
43
+ // a genuine unwrapped return). Currently no pre-drain line in the
44
+ // /api/upload handler holds this shape; the precondition floor of 10
45
+ // catches only "range collapsed to empty", not "silently minus one".
46
+ // If someone adds a URL-string literal in this range in the future,
47
+ // upgrade to a real tokenizer — not because block-comment mentions of
48
+ // req.formData() need it, but because the string-literal-with-`//`
49
+ // case is the actual gap.
50
+ function stripLineComment(s: string): string {
51
+ const idx = s.indexOf("//");
52
+ return idx >= 0 ? s.slice(0, idx) : s;
53
+ }
54
+ const CODE = LINES.map(stripLineComment);
55
+
56
+ // Find the /api/upload handler start (guard against a repo rename).
57
+ const handlerStartIdx = CODE.findIndex((line) =>
58
+ line.includes('url.pathname === "/api/upload"') &&
59
+ line.includes('req.method === "POST"'),
60
+ );
61
+
62
+ // Find the true body-drain boundary: `await req.formData()` INSIDE the
63
+ // handler. Search from handler start forward for the first NON-COMMENT
64
+ // occurrence (stripLineComment above already ate any `// ... formData ...`
65
+ // mention).
66
+ let drainIdx = -1;
67
+ if (handlerStartIdx >= 0) {
68
+ for (let i = handlerStartIdx + 1; i < CODE.length; i++) {
69
+ if (CODE[i].includes("req.formData()")) {
70
+ drainIdx = i;
71
+ break;
72
+ }
73
+ }
74
+ }
75
+
76
+ // Collect every `return` statement in the pre-drain range. Classify:
77
+ // earlyReject — the correct wrapping (sets Connection: close)
78
+ // withCors — bare wrapping without Connection: close (regression)
79
+ // other — produces a Response some OTHER way (e.g.
80
+ // `return Response.json(...)` / `return new Response(...)`);
81
+ // this shape ALSO skips earlyReject and MUST fail the guard.
82
+ // Detected via /Response\b/ so `return wrapped;` /
83
+ // `return;` / `return blob;` (helper body, non-response
84
+ // returns) are correctly skipped, but any new Response-
85
+ // producing path (Response.json / new Response / etc.) is
86
+ // caught. Lead 50208c1b: earlier version used implicit-skip
87
+ // which made the third assertion恒真 — it counted only what
88
+ // it recognised, so "unrecognised = 0" was vacuous.
89
+ // Order matters: earlyReject → withCors → other. A line
90
+ // like `return earlyReject(new Response(…))` contains
91
+ // both `earlyReject` AND `Response`, and must classify as
92
+ // earlyReject (already-wrapped), not as other.
93
+ type ReturnHit = { lineNumber: number; text: string; kind: "earlyReject" | "withCors" | "other" };
94
+ const preDrainReturns: ReturnHit[] = [];
95
+ if (handlerStartIdx >= 0 && drainIdx > handlerStartIdx) {
96
+ for (let i = handlerStartIdx; i < drainIdx; i++) {
97
+ const code = CODE[i];
98
+ if (!/\breturn\b/.test(code)) continue;
99
+ if (/\breturn earlyReject\b/.test(code)) {
100
+ preDrainReturns.push({ lineNumber: i + 1, text: LINES[i].trim(), kind: "earlyReject" });
101
+ } else if (/\breturn withCors\b/.test(code)) {
102
+ preDrainReturns.push({ lineNumber: i + 1, text: LINES[i].trim(), kind: "withCors" });
103
+ } else if (/Response\b/.test(code)) {
104
+ // `return new Response(...)` / `return Response.json(...)` / etc.
105
+ // — produces a Response but skips the earlyReject wrapper.
106
+ preDrainReturns.push({ lineNumber: i + 1, text: LINES[i].trim(), kind: "other" });
107
+ }
108
+ // Bare `return wrapped;`, `return;`, `return blob;` are non-response
109
+ // returns and correctly skipped (no `Response` token).
110
+ }
111
+ }
112
+
113
+ describe("#503 F3=A static guard: /api/upload pre-body-drain returns use earlyReject", () => {
114
+ test("precondition: scanner located handler + drain boundary + non-trivial return count (defends against parse collapse)", () => {
115
+ expect(handlerStartIdx).toBeGreaterThanOrEqual(0);
116
+ expect(drainIdx).toBeGreaterThan(handlerStartIdx);
117
+ // If boundary parsing collapses (someone renames req.formData(),
118
+ // or a comment-strip bug lets a doc reference win), this range
119
+ // becomes 0 and the second test would vacuously pass. Anchor
120
+ // the range: at time of writing (85e5c140 + #503) the count is
121
+ // 14; lead 7a88ce29 confirmed 14/14. Floor at 10 to leave room
122
+ // for genuine deletions while still catching a full collapse.
123
+ expect(preDrainReturns.length).toBeGreaterThanOrEqual(10);
124
+ });
125
+
126
+ test("every pre-drain return is wrapped in earlyReject (not bare withCors)", () => {
127
+ const bareWithCors = preDrainReturns.filter((r) => r.kind === "withCors");
128
+ if (bareWithCors.length > 0) {
129
+ const detail = bareWithCors
130
+ .map((r) => ` L${r.lineNumber}: ${r.text}`)
131
+ .join("\n");
132
+ throw new Error(
133
+ `#503 F3=A regression: ${bareWithCors.length} pre-body-drain return(s) in ` +
134
+ `/api/upload use bare withCors() instead of earlyReject():\n${detail}\n\n` +
135
+ `Every early-reject in this range MUST set Connection: close. Otherwise ` +
136
+ `the client's in-flight request body poisons the keepalive pool and the ` +
137
+ `next pooled request stalls. Wrap with the earlyReject() helper defined ` +
138
+ `at the top of the upload handler.`,
139
+ );
140
+ }
141
+ expect(bareWithCors.length).toBe(0);
142
+ });
143
+
144
+ test("no pre-drain return produces a Response without going through earlyReject (catches new response shapes)", () => {
145
+ // Catches `return Response.json(...)` / `return new Response(...)` /
146
+ // any other pre-drain Response-producing path that skips the wrapper.
147
+ // Lead 50208c1b: previous version was 恒真 because unrecognised
148
+ // shapes were silently dropped from `preDrainReturns` — the filter
149
+ // then counted "recognised = recognised" and never failed. Now the
150
+ // collector classifies unrecognised Response-producers as "other";
151
+ // this assertion reports them by line, not just count.
152
+ const unknown = preDrainReturns.filter((r) => r.kind === "other");
153
+ expect(unknown.map((r) => `L${r.lineNumber}: ${r.text}`)).toEqual([]);
154
+ });
155
+ });
@@ -7,27 +7,32 @@
7
7
  // this file already exercises the same code path). The hub binds to
8
8
  // an ephemeral port so multiple test runs don't collide.
9
9
  import { describe, expect, test, beforeAll, afterAll } from "bun:test";
10
+ import "./require-explicit-test-db.js";
10
11
  import { mkdtempSync, rmSync, existsSync } from "fs";
11
12
  import { join } from "path";
12
13
  import { tmpdir } from "os";
13
14
  import { register, login } from "./auth.js";
14
15
 
15
- const SERVER_DB = mkdtempSync(join(tmpdir(), "anet-upload-http-db-")) + "/commhub.db";
16
16
  const UPLOADS_DIR = mkdtempSync(join(tmpdir(), "anet-upload-http-fs-"));
17
- const PORT = 19000 + Math.floor(Math.random() * 1000);
18
- const BASE = `http://127.0.0.1:${PORT}`;
17
+ // #438 corrective: private bootServer({ port: 0 }) instance — the OS
18
+ // assigns the port and BASE is derived from the ACTUAL bound port, so
19
+ // this suite is immune to both the parent-side random-port TOCTOU and
20
+ // the aggregate module-cache no-op (import no longer boots anything).
21
+ let BASE = "";
19
22
 
20
23
  let server: any;
21
24
  let userToken = "";
22
25
  let userNetworkId = "";
23
26
 
27
+ // 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
28
+ // 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
29
+ // (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
30
+ // 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
24
31
  beforeAll(async () => {
25
32
  // Bootstrap a real admin + token on the temp DB so the server can
26
33
  // resolve it via requireAuth (we don't want to short-circuit auth —
27
34
  // we want to validate the production code path).
28
- process.env.COMMHUB_DB = SERVER_DB;
29
35
  process.env.COMMHUB_UPLOADS_DIR = UPLOADS_DIR;
30
- process.env.PORT = String(PORT);
31
36
  process.env.HOST = "127.0.0.1";
32
37
 
33
38
  // Use a unique username + strong password each run so we don't trip
@@ -50,31 +55,40 @@ beforeAll(async () => {
50
55
  }
51
56
  expect(userToken).toBeTruthy();
52
57
 
53
- // Import the server module — its top-level `Bun.serve` call binds
54
- // the port immediately. The import side effect IS the server start.
55
- await import("./index.js");
58
+ // Importing is side-effect-free now; boot a private instance and
59
+ // read the real port back.
60
+ const { bootServer } = await import("./server.js");
61
+ server = bootServer({ port: 0, hostname: "127.0.0.1" });
62
+ BASE = `http://127.0.0.1:${server.port}`;
56
63
  // Tiny settle so the listener is ready.
57
64
  await new Promise((r) => setTimeout(r, 100));
58
- });
65
+ }, 30_000);
59
66
 
60
67
  afterAll(() => {
61
- // Best-effort cleanup. The Bun.serve handle is created at import
62
- // time so we can't easily stop it — process exit handles that.
68
+ try { server?.stop?.(true); } catch {}
63
69
  try { rmSync(UPLOADS_DIR, { recursive: true, force: true }); } catch {}
64
- try { rmSync(SERVER_DB, { recursive: true, force: true }); } catch {}
65
70
  });
66
71
 
67
72
  function authHeaders(): Record<string, string> {
68
73
  return { "Authorization": `Bearer ${userToken}` };
69
74
  }
70
75
 
76
+ // #503 — the bootstrap user is the first account in this suite's fresh
77
+ // DB, so it is a system admin, and admins must name the target network
78
+ // explicitly (attribution is never guessed). uploadNetworkId() keeps
79
+ // that detail in one place.
80
+ function uploadNetworkId(): string {
81
+ if (!userNetworkId) throw new Error("test bootstrap: userNetworkId missing");
82
+ return `?network_id=${encodeURIComponent(userNetworkId)}`;
83
+ }
84
+
71
85
  async function uploadFile(content: Uint8Array, filename: string, mime: string, opts: { skipAuth?: boolean; overrideContentLength?: string | null } = {}): Promise<Response> {
72
86
  const form = new FormData();
73
87
  form.append("file", new Blob([content], { type: mime }), filename);
74
88
  const headers: Record<string, string> = opts.skipAuth ? {} : { ...authHeaders() };
75
89
  // Note: fetch sets Content-Length automatically; only override when
76
90
  // testing the missing-header path.
77
- const res = await fetch(`${BASE}/api/upload`, { method: "POST", body: form, headers });
91
+ const res = await fetch(`${BASE}/api/upload${uploadNetworkId()}`, { method: "POST", body: form, headers });
78
92
  return res;
79
93
  }
80
94
 
@@ -134,7 +148,7 @@ describe("POST /api/upload — 6-item security checklist (HTTP integration)", ()
134
148
  controller.close();
135
149
  },
136
150
  });
137
- const res = await fetch(`${BASE}/api/upload`, {
151
+ const res = await fetch(`${BASE}/api/upload${uploadNetworkId()}`, {
138
152
  method: "POST",
139
153
  body: stream,
140
154
  headers: { ...authHeaders(), "Content-Type": "multipart/form-data; boundary=xxx", "Transfer-Encoding": "chunked" },
@@ -150,7 +164,7 @@ describe("POST /api/upload — 6-item security checklist (HTTP integration)", ()
150
164
  // this body" which is the security property we care about; the
151
165
  // exact code depends on whether Bun's multipart parser bails before
152
166
  // or after our Content-Type pre-check.
153
- const res = await fetch(`${BASE}/api/upload`, {
167
+ const res = await fetch(`${BASE}/api/upload${uploadNetworkId()}`, {
154
168
  method: "POST",
155
169
  body: JSON.stringify({ nope: 1 }),
156
170
  headers: { ...authHeaders(), "Content-Type": "application/json" },
@@ -161,12 +175,18 @@ describe("POST /api/upload — 6-item security checklist (HTTP integration)", ()
161
175
 
162
176
  describe("GET /api/files/:file_id — download safety", () => {
163
177
  let fileId = "";
178
+ let rangeFileId = "";
179
+ const rangeContent = Uint8Array.from({ length: 64 }, (_, i) => i);
164
180
 
165
181
  beforeAll(async () => {
166
182
  const content = new TextEncoder().encode("hello-world-from-#221-integration-test");
167
183
  const res = await uploadFile(content, "greeting.txt", "text/plain");
168
184
  const body = await res.json() as any;
169
185
  fileId = body.file_id;
186
+
187
+ const rangeRes = await uploadFile(rangeContent, "range.bin", "application/octet-stream");
188
+ const rangeBody = await rangeRes.json() as any;
189
+ rangeFileId = rangeBody.file_id;
170
190
  });
171
191
 
172
192
  test("(item 3) download returns the blob with Content-Disposition: attachment + X-Content-Type-Options: nosniff", async () => {
@@ -175,10 +195,72 @@ describe("GET /api/files/:file_id — download safety", () => {
175
195
  expect(res.headers.get("Content-Disposition") ?? "").toMatch(/^attachment(;|$)/i);
176
196
  expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
177
197
  expect(res.headers.get("Content-Type")).toBe("application/octet-stream");
198
+ expect(res.headers.get("Accept-Ranges")).toBe("bytes");
178
199
  const text = await res.text();
179
200
  expect(text).toBe("hello-world-from-#221-integration-test");
180
201
  });
181
202
 
203
+ test("#514 Range bytes=a-b returns exact 206 partial content, not a tolerant 200/full body", async () => {
204
+ const res = await fetch(`${BASE}/api/files/${rangeFileId}`, {
205
+ headers: { ...authHeaders(), Range: "bytes=3-8" },
206
+ });
207
+ expect(res.status).toBe(206);
208
+ expect(res.headers.get("Content-Range")).toBe("bytes 3-8/64");
209
+ expect(res.headers.get("Content-Length")).toBe("6");
210
+ expect(res.headers.get("Accept-Ranges")).toBe("bytes");
211
+ const body = new Uint8Array(await res.arrayBuffer());
212
+ expect(body.length).toBe(6);
213
+ expect([...body]).toEqual([...rangeContent.slice(3, 9)]);
214
+ });
215
+
216
+ test("#514 Range bytes=N- returns the exact open-ended tail", async () => {
217
+ const res = await fetch(`${BASE}/api/files/${rangeFileId}`, {
218
+ headers: { ...authHeaders(), Range: "bytes=60-" },
219
+ });
220
+ expect(res.status).toBe(206);
221
+ expect(res.headers.get("Content-Range")).toBe("bytes 60-63/64");
222
+ expect(res.headers.get("Content-Length")).toBe("4");
223
+ const body = new Uint8Array(await res.arrayBuffer());
224
+ expect(body.length).toBe(4);
225
+ expect([...body]).toEqual([...rangeContent.slice(60)]);
226
+ });
227
+
228
+ test("#514 Range bytes=-N returns the exact suffix", async () => {
229
+ const res = await fetch(`${BASE}/api/files/${rangeFileId}`, {
230
+ headers: { ...authHeaders(), Range: "bytes=-7" },
231
+ });
232
+ expect(res.status).toBe(206);
233
+ expect(res.headers.get("Content-Range")).toBe("bytes 57-63/64");
234
+ expect(res.headers.get("Content-Length")).toBe("7");
235
+ const body = new Uint8Array(await res.arrayBuffer());
236
+ expect(body.length).toBe(7);
237
+ expect([...body]).toEqual([...rangeContent.slice(57)]);
238
+ });
239
+
240
+ test("#514 unsatisfiable Range returns 416 with concrete Content-Range size", async () => {
241
+ const res = await fetch(`${BASE}/api/files/${rangeFileId}`, {
242
+ headers: { ...authHeaders(), Range: "bytes=64-70" },
243
+ });
244
+ expect(res.status).toBe(416);
245
+ expect(res.headers.get("Content-Range")).toBe("bytes */64");
246
+ expect(res.headers.get("Content-Length")).toBe("0");
247
+ const body = new Uint8Array(await res.arrayBuffer());
248
+ expect(body.length).toBe(0);
249
+ });
250
+
251
+ test("#514 HEAD Range mirrors GET status and range headers without a body", async () => {
252
+ const res = await fetch(`${BASE}/api/files/${rangeFileId}`, {
253
+ method: "HEAD",
254
+ headers: { ...authHeaders(), Range: "bytes=3-8" },
255
+ });
256
+ expect(res.status).toBe(206);
257
+ expect(res.headers.get("Content-Range")).toBe("bytes 3-8/64");
258
+ expect(res.headers.get("Content-Length")).toBe("6");
259
+ expect(res.headers.get("Accept-Ranges")).toBe("bytes");
260
+ const body = new Uint8Array(await res.arrayBuffer());
261
+ expect(body.length).toBe(0);
262
+ });
263
+
182
264
  test("anonymous download is 401", async () => {
183
265
  const res = await fetch(`${BASE}/api/files/${fileId}`);
184
266
  expect(res.status).toBe(401);
package/src/uploads.ts CHANGED
@@ -53,6 +53,17 @@ export const UPLOAD_RATE_MAX_ENTRIES = 50_000;
53
53
  // traversal.
54
54
  export const FILE_ID_REGEX = /^[A-Za-z0-9_-]{8,64}$/;
55
55
 
56
+ // Stored extension tokens have one invariant at every path boundary:
57
+ // either no extension, or a leading dot followed by 1-16 ASCII
58
+ // alphanumeric characters. Keep this separate from sanitizeExt(), which
59
+ // parses a client filename and therefore deliberately has different
60
+ // capture/anchoring semantics.
61
+ const EXT_TOKEN_REGEX = /^\.[A-Za-z0-9]{1,16}$/;
62
+
63
+ function isValidExtToken(ext: unknown): ext is string {
64
+ return typeof ext === "string" && (ext === "" || EXT_TOKEN_REGEX.test(ext));
65
+ }
66
+
56
67
  export type GeneratedFile = {
57
68
  fileId: string;
58
69
  ext: string;
@@ -94,14 +105,51 @@ export function sanitizeExt(filename: string | undefined | null): string {
94
105
  return match ? "." + match[1].toLowerCase() : "";
95
106
  }
96
107
 
108
+ // YYYY-MM-DD bucket string regex — matches what getDateBucket produces
109
+ // and what an index entry's `date_bucket` field is validated against.
110
+ const DATE_BUCKET_REGEX = /^\d{4}-\d{2}-\d{2}$/;
111
+
112
+ /**
113
+ * Validate a date_bucket string against BOTH shape (YYYY-MM-DD regex)
114
+ * AND real-calendar semantics: rejects "2026-02-30", "2026-13-01",
115
+ * "2026-00-15", leap-day-on-non-leap-year, etc. Shape-only check is not
116
+ * enough because the regex accepts any digit combination in each
117
+ * position.
118
+ *
119
+ * Used as a pre-check by the download handler before it calls into
120
+ * pathForExistingBlob — a non-calendar bucket is a 404 (invalid input
121
+ * rejected at the boundary), NOT a 500 (uncaught throw from deep in
122
+ * the read path). Distinguishing these matters: 500 says "server
123
+ * failed"; 404 says "we know your request is bad, refused early".
124
+ */
125
+ export function isValidCalendarBucket(bucket: unknown): bucket is string {
126
+ if (typeof bucket !== "string") return false;
127
+ if (!DATE_BUCKET_REGEX.test(bucket)) return false;
128
+ // Round-trip via Date to reject non-calendar shapes like "2026-02-30"
129
+ // or "2026-13-01". `Date.UTC` coerces overflow ("2026-02-30" becomes
130
+ // "2026-03-02") — if the ISO round-trip doesn't equal the input, the
131
+ // caller supplied a non-calendar bucket.
132
+ const [yStr, mStr, dStr] = bucket.split("-");
133
+ const y = Number(yStr), m = Number(mStr), d = Number(dStr);
134
+ if (m < 1 || m > 12) return false;
135
+ if (d < 1 || d > 31) return false;
136
+ const roundtrip = new Date(Date.UTC(y, m - 1, d)).toISOString().slice(0, 10);
137
+ return roundtrip === bucket;
138
+ }
139
+
97
140
  /**
98
- * Build the per-upload storage path. The file_id and ext are validated
99
- * against strict regexes; any path-separator or `..` segment from a
100
- * malicious client gets rejected at the regex layer before any I/O.
141
+ * Build the storage path for a NEW upload. Computes today's date bucket
142
+ * from the runtime clock (or `opts.now` for tests). Use ONLY for creating
143
+ * a fresh blob; for reading an existing blob whose bucket is already
144
+ * recorded in the index, use pathForExistingBlob() instead.
145
+ *
146
+ * The file_id and ext are validated against strict regexes; any
147
+ * path-separator or `..` segment from a malicious client gets rejected
148
+ * at the regex layer before any I/O.
101
149
  */
102
150
  export function buildStoragePath(fileId: string, ext: string, opts: { uploadsRoot?: string; now?: Date } = {}): GeneratedFile {
103
151
  if (!FILE_ID_REGEX.test(fileId)) throw new Error(`file_id "${fileId}" is invalid`);
104
- if (ext && !/^\.[A-Za-z0-9]{1,16}$/.test(ext)) throw new Error(`ext "${ext}" is invalid`);
152
+ if (!isValidExtToken(ext)) throw new Error(`ext "${ext}" is invalid`);
105
153
  const dateBucket = getDateBucket(opts.now);
106
154
  const root = opts.uploadsRoot ?? getUploadsRoot();
107
155
  const relativePath = join(dateBucket, fileId + ext);
@@ -109,6 +157,32 @@ export function buildStoragePath(fileId: string, ext: string, opts: { uploadsRoo
109
157
  return { fileId, ext, dateBucket, relativePath, absolutePath };
110
158
  }
111
159
 
160
+ /**
161
+ * Build the storage path for an EXISTING blob, using the caller-supplied
162
+ * `dateBucket` (typically read from the index entry). Does NOT compute
163
+ * a date from the runtime clock — that is the write-side concern.
164
+ *
165
+ * This split exists because sharing one date-computing function between
166
+ * write and read semantics caused #509: yesterday's file downloaded
167
+ * today would look under today's bucket → blob_missing → 404, even
168
+ * though the blob was still sitting in yesterday's directory.
169
+ *
170
+ * The file_id, ext and dateBucket are validated against strict regexes
171
+ * before any I/O; a poisoned index bucket (e.g. `../etc`) is rejected
172
+ * here just as file_id path-escape attempts are.
173
+ */
174
+ export function pathForExistingBlob(dateBucket: string, fileId: string, ext: string, opts: { uploadsRoot?: string } = {}): GeneratedFile {
175
+ if (!FILE_ID_REGEX.test(fileId)) throw new Error(`file_id "${fileId}" is invalid`);
176
+ if (!isValidExtToken(ext)) throw new Error(`ext "${ext}" is invalid`);
177
+ if (typeof dateBucket !== "string" || !DATE_BUCKET_REGEX.test(dateBucket)) {
178
+ throw new Error(`date_bucket "${dateBucket}" is invalid`);
179
+ }
180
+ const root = opts.uploadsRoot ?? getUploadsRoot();
181
+ const relativePath = join(dateBucket, fileId + ext);
182
+ const absolutePath = join(root, relativePath);
183
+ return { fileId, ext, dateBucket, relativePath, absolutePath };
184
+ }
185
+
112
186
  export type UploadIndexEntry = {
113
187
  file_id: string;
114
188
  date_bucket: string; // YYYY-MM-DD
@@ -117,9 +191,24 @@ export type UploadIndexEntry = {
117
191
  mime: string; // best-effort mime from the multipart Content-Type
118
192
  size: number; // byte length of the binary
119
193
  owner?: string; // upload token id / username, for traceability
194
+ // Written by the upload handler as `authCtx?.userId ?? null`, so `null`
195
+ // is a real on-disk value — the type must say so rather than pretend
196
+ // the field is `string | undefined`.
197
+ owner_id?: string | null;
198
+ // #503 — network the upload is attributed to. The key is OMITTED (not
199
+ // written as null) when there is no attribution, so "absent" and
200
+ // "unattributed" are the same on-disk shape and legacy entries written
201
+ // before #503 are indistinguishable from new unattributed ones.
202
+ network_id?: string;
203
+ // 私信文件:上传时声明 `?purpose=dm`(App 的私信输入框这么传)。第一次用途就是私信 ——
204
+ // 只有上传者、收到/发出过带它的私信的人和管理员能下载(server.ts authorizeFileDownload)。
205
+ // 不是私信的文件不写这个键;以后被转进私信也不会补写,原来谁能看还是谁能看。
206
+ scope?: typeof DM_FILE_SCOPE;
120
207
  uploaded_at: string; // ISO 8601
121
208
  };
122
209
 
210
+ export const DM_FILE_SCOPE = "dm";
211
+
123
212
  /** Path to the index entry file for a given file_id. Lives in
124
213
  * `<uploadsRoot>/.index/<file_id>.json` so /api/files/:file_id can do
125
214
  * an O(1) lookup without scanning every date bucket. */
@@ -135,10 +224,18 @@ export function indexEntryPath(fileId: string, opts: { uploadsRoot?: string } =
135
224
  export function validateIndexEntry(entry: unknown): entry is UploadIndexEntry {
136
225
  if (!entry || typeof entry !== "object") return false;
137
226
  const e: any = entry;
227
+ // #503 — if the network_id key is present it must be a non-empty string.
228
+ // An absent key is valid (legacy entries + unattributed uploads); that
229
+ // keeps this in sync with the writer, which omits the key entirely
230
+ // rather than writing null. owner_id is deliberately NOT constrained
231
+ // here — existing entries legitimately carry owner_id=null.
232
+ if ("network_id" in e && !(typeof e.network_id === "string" && e.network_id.length > 0)) return false;
233
+ // 未知的 scope 值不当成「没有 scope」放行(那等于把私信文件退回全网可见):整条判坏,下载 404。
234
+ if ("scope" in e && e.scope !== DM_FILE_SCOPE) return false;
138
235
  return (
139
236
  typeof e.file_id === "string" && FILE_ID_REGEX.test(e.file_id) &&
140
237
  typeof e.date_bucket === "string" && /^\d{4}-\d{2}-\d{2}$/.test(e.date_bucket) &&
141
- typeof e.ext === "string" && (e.ext === "" || /^\.[A-Za-z0-9]{1,16}$/.test(e.ext)) &&
238
+ isValidExtToken(e.ext) &&
142
239
  typeof e.size === "number" && e.size >= 0 && e.size <= MAX_UPLOAD_BYTES
143
240
  );
144
241
  }
@@ -247,7 +344,7 @@ export type TaskAttachment = {
247
344
  * returns `{file_id, path}` where `path` is a hub-machine-local
248
345
  * absolute path. Senders sometimes echo `path` back into meta.attachments.
249
346
  * When the receiving agent runs on a different host (Vincent 2026-06-30
250
- * toodadev2 case), that path string is meaningless. agent-node's
347
+ * a partner-team host case), that path string is meaningless. agent-node's
251
348
  * fetch-attachment.ts will fetch via `file_id` regardless, but leaving
252
349
  * the stale `path` in the JSON misleads any tooling that reads it.
253
350
  *
@@ -0,0 +1,74 @@
1
+ import { expect, test } from "bun:test";
2
+ import { register, login, createNetworkTokenForNode, addNetworkMember } from "./auth.js";
3
+ import { db } from "./db.js";
4
+
5
+ // One sequential HTTP scenario: fail a prerequisite before running the next layer.
6
+ // Docker supplies an isolated DB; never import this against a production DB.
7
+ test("human avatar: auth → save/read/login → member visibility → isolation/rejection → clear", async () => {
8
+ const a = register("avatar_alice", "AvatarPassw0rd!");
9
+ const b = register("avatar_bob", "AvatarPassw0rd!");
10
+ const stranger = register("avatar_stranger", "AvatarPassw0rd!");
11
+ expect(a.ok && b.ok && stranger.ok).toBe(true);
12
+ expect(addNetworkMember(a.network_id!, b.user!.user_id, "member").ok).toBe(true);
13
+ const node = createNetworkTokenForNode(a.user!.user_id, a.network_id!, "avatar_alice", "avatar_node");
14
+ expect(node.ok).toBe(true);
15
+ const { bootServer } = await import("./server.js");
16
+ const hub = bootServer({ port: 0, hostname: "127.0.0.1" });
17
+ const base = `http://127.0.0.1:${hub.port}`;
18
+ const call = async (token: string, method: string, path: string, body?: unknown) => {
19
+ const res = await fetch(base + path, { method,
20
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
21
+ body: body === undefined ? undefined : JSON.stringify(body) });
22
+ return { status: res.status, body: await res.json() as any };
23
+ };
24
+ const put = (token: string, body: unknown) => call(token, "PUT", "/api/auth/me/avatar", body);
25
+ const me = (token: string) => call(token, "GET", "/api/auth/me");
26
+ try {
27
+ expect((await me(a.token!)).status).toBe(200);
28
+ expect((await me(a.token!)).body.user.avatar_url).toBeNull();
29
+ expect((await put("invalid", { avatar_url: null })).status).toBe(401);
30
+ expect((await put(node.token!, { avatar_url: null })).status).toBe(403);
31
+
32
+ const avatar = "/avatars/avatar-03.webp";
33
+ const saved = await put(a.token!, { avatar_url: avatar });
34
+ expect(saved.status).toBe(200);
35
+ expect(saved.body).toEqual({ ok: true, user_id: a.user!.user_id, avatar_url: avatar });
36
+ expect((await me(a.token!)).body.user.avatar_url).toBe(avatar);
37
+ const secondDevice = login("avatar_alice", "AvatarPassw0rd!");
38
+ expect(secondDevice.ok).toBe(true);
39
+ expect(secondDevice.user!.avatar_url).toBe(avatar);
40
+ expect((await me(secondDevice.token!)).body.user.avatar_url).toBe(avatar);
41
+ // An ordinary profile edit must preserve and return the saved avatar.
42
+ const profile = await call(a.token!, "PUT", "/api/auth/me", { display_name: "Alice" });
43
+ expect(profile.status).toBe(200);
44
+ expect(profile.body.user.avatar_url).toBe(avatar);
45
+ const people = await call(b.token!, "GET", `/api/networks/${a.network_id}/humans`);
46
+ expect(people.status).toBe(200);
47
+ expect(people.body.humans.find((u: any) => u.user_id === a.user!.user_id).avatar_url).toBe(avatar);
48
+ expect((await call(stranger.token!, "GET", `/api/networks/${a.network_id}/humans`)).status).toBe(403);
49
+ expect((await me(b.token!)).body.user.avatar_url).toBeNull();
50
+
51
+ for (const body of [null, [], {}, { avatar_url: avatar, user_id: b.user!.user_id },
52
+ ...[123, {}, "javascript:alert(1)", "file:///tmp/photo.png", "data:image/png;base64,AAAA",
53
+ "//evil.example/a.png", "/avatars/../x.png", "https://user:secret@example.com/x.png", "x".repeat(3000)]
54
+ .map((avatar_url) => ({ avatar_url }))]) {
55
+ expect((await put(a.token!, body)).status).toBe(400);
56
+ expect((await me(a.token!)).body.user.avatar_url).toBe(avatar);
57
+ }
58
+ const malformed = await fetch(base + "/api/auth/me/avatar", { method: "PUT",
59
+ headers: { Authorization: `Bearer ${a.token}`, "Content-Type": "application/json" }, body: "{" });
60
+ expect(malformed.status).toBe(400);
61
+ expect((await put(node.token!, { avatar_url: "/avatars/avatar-09.webp" })).status).toBe(403);
62
+ expect((await me(a.token!)).body.user.avatar_url).toBe(avatar);
63
+ expect((await me(b.token!)).body.user.avatar_url).toBeNull();
64
+ expect(db.get<any>("SELECT avatar_url FROM nodes WHERE node_id = ?1", "avatar_node")?.avatar_url ?? null).toBeNull();
65
+
66
+ const remote = "https://example.invalid/photo.png";
67
+ expect((await put(a.token!, { avatar_url: remote })).status).toBe(200);
68
+ expect((await me(a.token!)).body.user.avatar_url).toBe(remote);
69
+ expect((await put(a.token!, { avatar_url: null })).status).toBe(200);
70
+ expect((await me(secondDevice.token!)).body.user.avatar_url).toBeNull();
71
+ expect((await put(a.token!, { avatar_url: avatar })).status).toBe(200);
72
+ expect((await put(a.token!, { avatar_url: " " })).body.avatar_url).toBeNull();
73
+ } finally { hub.stop(true); }
74
+ }, 30_000);
@@ -0,0 +1,25 @@
1
+ import { expect, test } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+
4
+ test("existing users survive additive avatar migration and next process preserves it", async () => {
5
+ const path = process.env.COMMHUB_DB!;
6
+ expect(typeof path).toBe("string");
7
+ const old = new Database(path);
8
+ old.exec(`CREATE TABLE users (
9
+ user_id TEXT PRIMARY KEY, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL,
10
+ email TEXT, display_name TEXT, role TEXT DEFAULT 'user',
11
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
12
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
13
+ ); INSERT INTO users (user_id, username, password_hash) VALUES ('old_user', 'old_user', 'test-only-hash');`);
14
+ old.close();
15
+ const { db } = await import("./db.js");
16
+ expect(db.get<any>("SELECT avatar_url, password_hash FROM users WHERE user_id = ?1", "old_user"))
17
+ .toEqual({ avatar_url: null, password_hash: "test-only-hash" });
18
+ db.run("UPDATE users SET avatar_url = ?1 WHERE user_id = ?2", ["/avatars/avatar-03.webp", "old_user"]);
19
+ const child = Bun.spawn([process.execPath, "-e", `
20
+ const { db } = await import('./src/db.ts');
21
+ const row = db.get('SELECT avatar_url, password_hash FROM users WHERE user_id = ?1', 'old_user');
22
+ if (row.avatar_url !== '/avatars/avatar-03.webp' || row.password_hash !== 'test-only-hash') process.exit(1);
23
+ `], { cwd: new URL("..", import.meta.url).pathname, env: process.env, stdout: "pipe", stderr: "pipe" });
24
+ expect(await child.exited).toBe(0);
25
+ });