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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (317) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +99 -11
  3. package/package.json +7 -7
  4. package/src/ack-create-request-transport.test.ts +182 -0
  5. package/src/ack-create-request.test.ts +50 -7
  6. package/src/admin-delete-network-http.test.ts +228 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/adoption-candidates-http.test.ts +137 -0
  9. package/src/adoption-candidates.ts +158 -0
  10. package/src/agent-access-batch.test.ts +210 -0
  11. package/src/agent-access.ts +487 -0
  12. package/src/agent-acl-groups-http.test.ts +247 -0
  13. package/src/agent-acl-http.test.ts +659 -0
  14. package/src/agent-acl-slice3-http.test.ts +267 -0
  15. package/src/agent-groups-perf.test.ts +223 -0
  16. package/src/agent-notice.ts +48 -0
  17. package/src/agent-team-whoami-http.test.ts +98 -0
  18. package/src/agent-teams-http.test.ts +323 -0
  19. package/src/agent-teams.ts +244 -0
  20. package/src/alias-filter.test.ts +72 -0
  21. package/src/alias-filter.ts +42 -0
  22. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  23. package/src/api-host-supervisors-fallback.test.ts +271 -0
  24. package/src/api-host-supervisors-runtime-readiness.test.ts +195 -0
  25. package/src/api-nodes-shape.test.ts +26 -10
  26. package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
  27. package/src/auth-sessions-http.test.ts +250 -0
  28. package/src/auth-validate.test.ts +5 -1
  29. package/src/auth-weak-password-login.test.ts +176 -0
  30. package/src/auth.ts +598 -49
  31. package/src/avatar-validate.test.ts +163 -0
  32. package/src/avatar-validate.ts +103 -0
  33. package/src/binding-generation-http.test.ts +121 -0
  34. package/src/blocked-exit-on-reply.test.ts +122 -0
  35. package/src/boot-side-effect.test.ts +124 -0
  36. package/src/clone-node.test.ts +248 -0
  37. package/src/clone-node.ts +289 -0
  38. package/src/codex-config-flags.ts +77 -0
  39. package/src/codex-fork-contract.ts +23 -0
  40. package/src/codex-fork-migration.test.ts +38 -0
  41. package/src/commhub-bin-args.test.ts +85 -0
  42. package/src/config-apply-sec1.test.ts +107 -0
  43. package/src/config-apply-validate.test.ts +134 -1
  44. package/src/config-apply-validate.ts +111 -5
  45. package/src/create-node-blocked-gate.test.ts +356 -0
  46. package/src/create-node-network-id.test.ts +79 -0
  47. package/src/create-node-tool-schema.test.ts +48 -0
  48. package/src/create-node-unicode-name.test.ts +120 -0
  49. package/src/create-node-validate.test.ts +193 -7
  50. package/src/create-node-validate.ts +227 -15
  51. package/src/create-node-workdir.test.ts +289 -0
  52. package/src/create-node.ts +77 -23
  53. package/src/daemon-provider-snapshots.test.ts +421 -0
  54. package/src/daemon-provider-snapshots.ts +327 -0
  55. package/src/daemon-token-owner-http.test.ts +236 -0
  56. package/src/dashboard-slash-routing-http.test.ts +107 -0
  57. package/src/db-adapter-guard.test.ts +148 -0
  58. package/src/db-adapter-translate.test.ts +128 -0
  59. package/src/db-adapter.ts +323 -87
  60. package/src/db-timestamp.test.ts +38 -0
  61. package/src/db-timestamp.ts +15 -0
  62. package/src/db.ts +930 -35
  63. package/src/delivered-without-push.test.ts +140 -0
  64. package/src/department-groups-http.test.ts +209 -0
  65. package/src/department-groups-sync-http.test.ts +328 -0
  66. package/src/department-groups.ts +434 -0
  67. package/src/department-heads-http.test.ts +330 -0
  68. package/src/department-heads.ts +226 -0
  69. package/src/departments-http.test.ts +185 -0
  70. package/src/departments.ts +260 -0
  71. package/src/desktop-message-seam.test.ts +150 -0
  72. package/src/desktop-message.test.ts +368 -0
  73. package/src/dm-file-scope-http.test.ts +178 -0
  74. package/src/ext-token-shared.test.ts +60 -0
  75. package/src/ext-validation-regression.test.ts +131 -0
  76. package/src/external-schedule-edits-http.test.ts +321 -0
  77. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  78. package/src/external-schedule-edits.ts +327 -0
  79. package/src/file-download-authz-dev-open.test.ts +135 -0
  80. package/src/file-download-authz.test.ts +404 -0
  81. package/src/file-download-date-bucket.test.ts +436 -0
  82. package/src/file-network-scope-dev-open.test.ts +97 -0
  83. package/src/file-network-scope.test.ts +732 -0
  84. package/src/group-membership-events-http.test.ts +264 -0
  85. package/src/group-messages-http.test.ts +482 -0
  86. package/src/group-messages.ts +297 -0
  87. package/src/health-redaction.test.ts +642 -0
  88. package/src/http-gzip.test.ts +94 -0
  89. package/src/http-gzip.ts +112 -0
  90. package/src/hub-provider-config.test.ts +139 -0
  91. package/src/hub-provider-config.ts +105 -0
  92. package/src/hub-timestamp.test.ts +38 -0
  93. package/src/hub-timestamp.ts +27 -0
  94. package/src/human-dm.ts +215 -0
  95. package/src/inbox-caller.ts +49 -0
  96. package/src/inbox-count-sse.test.ts +310 -0
  97. package/src/inbox-count.ts +29 -0
  98. package/src/index.ts +15 -2362
  99. package/src/lifecycle-guard.ts +1 -1
  100. package/src/mcp-context-cost-http.test.ts +123 -0
  101. package/src/mcp-projects-events-http.test.ts +162 -0
  102. package/src/mcp-route-label-http.test.ts +120 -0
  103. package/src/mcp-write-network-resolution.test.ts +432 -0
  104. package/src/member-audit-network-id-http.test.ts +93 -0
  105. package/src/member-presence-http.test.ts +210 -0
  106. package/src/messages-ack-by-agent-http.test.ts +142 -0
  107. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  108. package/src/migrate-sqlite-to-pg.ts +263 -0
  109. package/src/model-auth-notify-http.test.ts +181 -0
  110. package/src/model-auth-notify.ts +116 -0
  111. package/src/network-name-http.test.ts +75 -0
  112. package/src/network-scope.ts +276 -0
  113. package/src/network-token-membership-http.test.ts +139 -0
  114. package/src/node-alias-control.ts +12 -0
  115. package/src/node-attrs-validate.ts +88 -0
  116. package/src/node-attrs.test.ts +217 -0
  117. package/src/node-caller-identity-http.test.ts +781 -0
  118. package/src/node-create-request-status-http.test.ts +105 -0
  119. package/src/node-daemon-bindings.test.ts +132 -0
  120. package/src/node-daemon-bindings.ts +130 -0
  121. package/src/node-files-transport.test.ts +344 -0
  122. package/src/node-health-codex-login.test.ts +120 -0
  123. package/src/node-health-dispatch-http.test.ts +279 -0
  124. package/src/node-health-guard.ts +105 -0
  125. package/src/node-health-passthrough.test.ts +115 -0
  126. package/src/node-health-store.ts +101 -0
  127. package/src/node-id-alias.test.ts +190 -0
  128. package/src/node-identity-conflict-http.test.ts +269 -0
  129. package/src/node-identity-conflict.ts +142 -0
  130. package/src/node-identity-mismatch.test.ts +74 -0
  131. package/src/node-lifecycle-controllable.test.ts +64 -0
  132. package/src/node-lifecycle-controllable.ts +53 -0
  133. package/src/node-lifecycle-read-http.test.ts +245 -0
  134. package/src/node-lifecycle-read.ts +121 -0
  135. package/src/node-logs-transport.test.ts +263 -0
  136. package/src/node-permissions-http.test.ts +276 -0
  137. package/src/node-permissions.ts +285 -0
  138. package/src/node-request-retention.test.ts +281 -0
  139. package/src/node-request-retention.ts +120 -0
  140. package/src/node-skills-transport.test.ts +209 -0
  141. package/src/node-token-ownership.ts +51 -0
  142. package/src/node-token-privilege-http.test.ts +158 -0
  143. package/src/observer-avatar-http.test.ts +431 -0
  144. package/src/observer-push.test.ts +252 -0
  145. package/src/opencode-v2-catalog.test.ts +268 -0
  146. package/src/opencode-v2-catalog.ts +352 -0
  147. package/src/package-version-consistency.test.ts +25 -0
  148. package/src/peer-reply-atomic.test.ts +455 -0
  149. package/src/peer-reply-capability.test.ts +70 -0
  150. package/src/pg-worker.ts +87 -0
  151. package/src/project-dir-warn-clear-http.test.ts +132 -0
  152. package/src/project-viewer-can-http.test.ts +114 -0
  153. package/src/push.ts +429 -10
  154. package/src/redact-tokens.test.ts +65 -0
  155. package/src/redact-tokens.ts +40 -0
  156. package/src/rename.ts +22 -2
  157. package/src/reply-target-derivation.test.ts +96 -0
  158. package/src/report-status-handover-capabilities-http.test.ts +124 -0
  159. package/src/report-status-host-schema.test.ts +235 -0
  160. package/src/report-status-resume-id-handover.test.ts +150 -0
  161. package/src/require-explicit-test-db.ts +9 -0
  162. package/src/requirement-due-reminders-http.test.ts +620 -0
  163. package/src/requirement-due-reminders.ts +416 -0
  164. package/src/requirement-events-http.test.ts +268 -0
  165. package/src/requirement-events.test.ts +45 -0
  166. package/src/requirement-events.ts +144 -0
  167. package/src/requirement-notify.ts +165 -0
  168. package/src/requirement-participant-notify-http.test.ts +309 -0
  169. package/src/requirement-tag-ops-http.test.ts +266 -0
  170. package/src/requirement-tags.ts +89 -0
  171. package/src/requirements-abandoned-http.test.ts +236 -0
  172. package/src/requirements-comment-http.test.ts +167 -0
  173. package/src/requirements-completed-at-migration.test.ts +70 -0
  174. package/src/requirements-errors-mcp-http.test.ts +164 -0
  175. package/src/requirements-errors.ts +143 -0
  176. package/src/requirements-etag-http.test.ts +91 -0
  177. package/src/requirements-http.test.ts +709 -0
  178. package/src/requirements-last-event-http.test.ts +349 -0
  179. package/src/requirements-list-mcp-slim-http.test.ts +155 -0
  180. package/src/requirements-list-slim-http.test.ts +319 -0
  181. package/src/requirements-mcp.test.ts +163 -0
  182. package/src/requirements-migrate.ts +259 -0
  183. package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
  184. package/src/requirements-people-http.test.ts +114 -0
  185. package/src/requirements-people-mcp-http.test.ts +225 -0
  186. package/src/requirements-people.ts +127 -0
  187. package/src/requirements-priority-lowest.test.ts +114 -0
  188. package/src/requirements-search-http.test.ts +182 -0
  189. package/src/requirements-search.test.ts +32 -0
  190. package/src/requirements-search.ts +145 -0
  191. package/src/requirements-seq-http.test.ts +193 -0
  192. package/src/requirements-seq-migration.test.ts +63 -0
  193. package/src/requirements-start-date.test.ts +125 -0
  194. package/src/requirements-stats-http.test.ts +288 -0
  195. package/src/requirements-stats.ts +167 -0
  196. package/src/requirements-status-participants-http.test.ts +222 -0
  197. package/src/requirements-upgrade-e2e.test.ts +126 -0
  198. package/src/requirements-upgrade-migration.test.ts +98 -0
  199. package/src/requirements.ts +1806 -0
  200. package/src/resolve-port.test.ts +57 -0
  201. package/src/resolve-port.ts +44 -0
  202. package/src/response-charset.test.ts +130 -0
  203. package/src/rest-explicit-columns-http.test.ts +222 -0
  204. package/src/rest-identity.test.ts +129 -0
  205. package/src/rest-identity.ts +125 -0
  206. package/src/rest-projections.ts +68 -0
  207. package/src/rest-write-network-resolution.test.ts +133 -0
  208. package/src/restricted-files.ts +76 -0
  209. package/src/retention.ts +15 -3
  210. package/src/route-callers-http.test.ts +120 -0
  211. package/src/route-timing.test.ts +211 -0
  212. package/src/route-timing.ts +258 -0
  213. package/src/rules-file-duplicate-alias.test.ts +234 -0
  214. package/src/rules-file-session-target.test.ts +207 -0
  215. package/src/rules-file-transport.test.ts +237 -0
  216. package/src/runtime-label.test.ts +22 -0
  217. package/src/runtime-label.ts +21 -0
  218. package/src/runtime-readiness.ts +90 -0
  219. package/src/schedule-agent-mcp-http.test.ts +381 -0
  220. package/src/schedule-agent.ts +192 -0
  221. package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
  222. package/src/scheduled-failures-http.test.ts +233 -0
  223. package/src/scheduled-failures.ts +179 -0
  224. package/src/scheduled-offline.test.ts +131 -0
  225. package/src/scheduled-offline.ts +66 -0
  226. package/src/scheduled-reply-unread-http.test.ts +212 -0
  227. package/src/scheduled-run-terminal.test.ts +268 -0
  228. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  229. package/src/scheduled-stuck-http.test.ts +238 -0
  230. package/src/scheduled-stuck.ts +153 -0
  231. package/src/scheduled-tasks-http.test.ts +638 -0
  232. package/src/scheduled-tasks.ts +766 -0
  233. package/src/scoped-secrets.ts +124 -0
  234. package/src/send-dedup-request-id.test.ts +229 -0
  235. package/src/send-reply-agent-warning.test.ts +202 -0
  236. package/src/send-reply-alias-optional.test.ts +131 -0
  237. package/src/send-reply-attachments.test.ts +458 -0
  238. package/src/send_dedup.test.ts +54 -0
  239. package/src/send_dedup.ts +52 -6
  240. package/src/serve-error.test.ts +38 -0
  241. package/src/serve-error.ts +27 -0
  242. package/src/server.ts +5257 -0
  243. package/src/session-task-on-dispatch.ts +14 -0
  244. package/src/session-token-ownership-http.test.ts +122 -0
  245. package/src/shared/codex-inventory.ts +41 -0
  246. package/src/shared/daemon-provider-login.ts +31 -0
  247. package/src/shared/daemon-provider-snapshot.ts +52 -0
  248. package/src/shared/external-schedule-contract.ts +60 -0
  249. package/src/shared/node-name.test.ts +62 -0
  250. package/src/shared/node-name.ts +161 -0
  251. package/src/shared/provider-application.ts +29 -0
  252. package/src/shared/provider-collection.ts +14 -0
  253. package/src/shared/provider-initialization.ts +8 -0
  254. package/src/shared/reserved-env-drift.test.ts +10 -0
  255. package/src/shared/scoped-secret-contract.ts +5 -0
  256. package/src/shared/task-content-limit-drift.test.ts +28 -0
  257. package/src/shared/task-content-limit.ts +25 -0
  258. package/src/side-thread-command-transport.test.ts +312 -0
  259. package/src/side-thread-command-transport.ts +318 -0
  260. package/src/side-thread-http-integration.test.ts +233 -0
  261. package/src/side-thread-http.ts +504 -0
  262. package/src/side-thread-production.test.ts +72 -0
  263. package/src/side-thread-production.ts +118 -0
  264. package/src/side-thread.test.ts +1067 -0
  265. package/src/side-thread.ts +2499 -0
  266. package/src/skillhub-http.test.ts +108 -0
  267. package/src/sqlite-close-checkpoint.test.ts +78 -0
  268. package/src/start-node.test.ts +264 -0
  269. package/src/status-alias-filter-http.test.ts +106 -0
  270. package/src/status-alias-resolver-http.test.ts +163 -0
  271. package/src/status-alias-resolver.ts +19 -0
  272. package/src/status-node-id-filter-http.test.ts +161 -0
  273. package/src/status-read-cache-http.test.ts +229 -0
  274. package/src/status-read-cache.ts +122 -0
  275. package/src/stop-delete-node.test.ts +509 -1
  276. package/src/task-access-http.test.ts +402 -0
  277. package/src/task-access.ts +420 -0
  278. package/src/task-auth-origin-http.test.ts +91 -0
  279. package/src/task-auth-origin.test.ts +24 -0
  280. package/src/task-auth-origin.ts +7 -0
  281. package/src/task-consumption.test.ts +482 -0
  282. package/src/task-diagnostic-http.test.ts +149 -0
  283. package/src/task-diagnostic.test.ts +115 -0
  284. package/src/task-diagnostic.ts +118 -0
  285. package/src/task-expiry-notice-http.test.ts +287 -0
  286. package/src/task-expiry-notice.ts +315 -0
  287. package/src/task-idempotency-mcp.test.ts +142 -0
  288. package/src/task-idempotency.test.ts +60 -0
  289. package/src/task-idempotency.ts +75 -0
  290. package/src/task-late-replies.ts +54 -0
  291. package/src/task-late-reply-http.test.ts +152 -0
  292. package/src/task-lifecycle-watcher.test.ts +204 -0
  293. package/src/task-lifecycle-watcher.ts +49 -0
  294. package/src/task-network-resolution-http.test.ts +141 -0
  295. package/src/task-orphan-http.test.ts +287 -0
  296. package/src/task-orphan.ts +119 -0
  297. package/src/task-queue-ahead-http.test.ts +309 -0
  298. package/src/task-queue-ahead-scale.test.ts +236 -0
  299. package/src/task-queue-ahead.ts +186 -0
  300. package/src/task-rejection-log-http.test.ts +160 -0
  301. package/src/task-rejection-log.ts +49 -0
  302. package/src/task-stale-open-http.test.ts +290 -0
  303. package/src/task-stale-open.ts +103 -0
  304. package/src/tasks-pagination-http.test.ts +141 -0
  305. package/src/tool-audience-http.test.ts +220 -0
  306. package/src/tool-audience.ts +118 -0
  307. package/src/tools-list-json-schema.test.ts +67 -0
  308. package/src/tools.ts +3927 -323
  309. package/src/upload-early-reject-guard.test.ts +155 -0
  310. package/src/uploads-http.test.ts +97 -15
  311. package/src/uploads.ts +103 -6
  312. package/src/user-avatar-http.test.ts +74 -0
  313. package/src/user-avatar-migration.test.ts +25 -0
  314. package/src/user-inbox-by-agent-http.test.ts +161 -0
  315. package/src/user-inbox-netid-notnull.test.ts +122 -0
  316. package/src/user-inbox-read-http.test.ts +223 -0
  317. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,250 @@
1
+ // 登录会话:闲置过期 + 「登录设备」列表 + 退出其他设备。
2
+ //
3
+ // 生产上 user-login 令牌一次登录一条、从不过期也不回收,泄漏任何一条就等于那个账号(多为管理员)。
4
+ // 这里的每一条在 main 上都是红的:main 没有闲置过期(旧令牌永远有效)、登录响应不带 token_id、
5
+ // /api/auth/sessions* 端点不存在。正控:在用的会话、显式 API 令牌、节点令牌照常可用。
6
+
7
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
8
+ import { mkdtempSync, rmSync } from "fs";
9
+ import { join } from "path";
10
+ import { tmpdir } from "os";
11
+ import { register, createNetworkTokenForNode, createToken } from "./auth.js";
12
+ import { db, hashToken } from "./db.js";
13
+
14
+ const DIR = mkdtempSync(join(tmpdir(), "anet-sessions-"));
15
+ let BASE = "";
16
+ let hub: any = null;
17
+ const PW = "SessionsPassw0rd!";
18
+ let USER = "", userId = "", NET = "", ntok = "", apiToken = "";
19
+
20
+ const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
21
+ async function call(token: string, method: string, path: string, body?: unknown, extra: Record<string, string> = {}) {
22
+ const res = await fetch(`${BASE}${path}`, {
23
+ method,
24
+ headers: { ...(token ? auth(token) : {}), ...(body === undefined ? {} : { "Content-Type": "application/json" }), ...extra },
25
+ body: body === undefined ? undefined : JSON.stringify(body),
26
+ });
27
+ return { status: res.status, body: await res.json().catch(() => null) as any };
28
+ }
29
+ async function loginAs(label?: string, ua = "anet-test-agent/1.0") {
30
+ const r = await call("", "POST", "/api/auth/login", { username: USER, password: PW, ...(label ? { client_label: label } : {}) }, { "User-Agent": ua });
31
+ expect(r.status).toBe(200);
32
+ return r.body as { token: string; token_id: string };
33
+ }
34
+ // 把某条令牌的「最近使用」拨回 days 天前(created_at 一起拨,模拟一条很早签发、之后没再用过的令牌)。
35
+ function ageToken(token: string, days: number) {
36
+ db.run(
37
+ "UPDATE api_tokens SET last_used_at = datetime('now', ?1), created_at = datetime('now', ?1) WHERE token_hash = ?2",
38
+ [`-${days} days`, hashToken(token)],
39
+ );
40
+ }
41
+ function neverUsed(token: string, days: number) {
42
+ db.run("UPDATE api_tokens SET last_used_at = NULL, created_at = datetime('now', ?1) WHERE token_hash = ?2", [`-${days} days`, hashToken(token)]);
43
+ }
44
+
45
+ beforeAll(async () => {
46
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
47
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
48
+ process.env.HOST = "127.0.0.1";
49
+ // 这里每个用例都真走 POST /api/auth/login 签发会话;默认的每 IP 登录限流会在第 ~20 次开始 429。
50
+ process.env.COMMHUB_LOGIN_IP_MAX = "1000";
51
+ delete process.env.COMMHUB_SESSION_IDLE_DAYS;
52
+ USER = `sess_user_${Date.now()}`;
53
+ const reg = register(USER, PW);
54
+ userId = reg.user!.user_id; NET = reg.network_id!;
55
+ const minted = createNetworkTokenForNode(userId, NET, "sess-node", "node_sess_1");
56
+ expect(minted.ok).toBe(true);
57
+ ntok = minted.token!;
58
+ const api = createToken(userId, "ci-script");
59
+ expect(api.ok).toBe(true);
60
+ apiToken = api.token!;
61
+ const mod: any = await import("./server.js");
62
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
63
+ BASE = `http://127.0.0.1:${hub.port}`;
64
+ }, 30_000);
65
+
66
+ afterAll(() => {
67
+ try { hub?.stop?.(true); } catch {}
68
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
69
+ });
70
+
71
+ describe("login / register identify the issued token", () => {
72
+ test("login returns token_id of the token it returned (and stays backward-compatible)", async () => {
73
+ const r = await call("", "POST", "/api/auth/login", { username: USER, password: PW });
74
+ expect(r.status).toBe(200);
75
+ expect(r.body.ok).toBe(true);
76
+ expect(typeof r.body.token).toBe("string");
77
+ expect(r.body.token_id).toMatch(/^tok_/);
78
+ const row = db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash = ?1", hashToken(r.body.token));
79
+ expect(row.token_id).toBe(r.body.token_id);
80
+ });
81
+ test("register returns token_id", async () => {
82
+ const r = await call("", "POST", "/api/auth/register", { username: `sess_reg_${Date.now()}`, password: PW });
83
+ expect(r.status).toBe(200);
84
+ expect(r.body.token_id).toMatch(/^tok_/);
85
+ const row = db.get<any>("SELECT token_id, name FROM api_tokens WHERE token_hash = ?1", hashToken(r.body.token));
86
+ expect(row.token_id).toBe(r.body.token_id);
87
+ expect(row.name).toBe("user-login");
88
+ });
89
+ });
90
+
91
+ describe("sliding idle expiry (default 30 days)", () => {
92
+ test("a login token idle past the window is rejected with token_expired", async () => {
93
+ const s = await loginAs();
94
+ ageToken(s.token, 31);
95
+ const r = await call(s.token, "GET", "/api/auth/me");
96
+ expect(r.status).toBe(401);
97
+ expect(r.body.error).toBe("token_expired");
98
+ // 同一条 401 改写也覆盖走统一 requireAuth 的 REST 端点
99
+ const r2 = await call(s.token, "GET", "/api/sessions");
100
+ expect(r2.status).toBe(401);
101
+ expect(r2.body.error).toBe("token_expired");
102
+ });
103
+ test("a login token that was never used counts idle time from created_at", async () => {
104
+ const s = await loginAs();
105
+ neverUsed(s.token, 45);
106
+ const r = await call(s.token, "GET", "/api/auth/me");
107
+ expect(r.status).toBe(401);
108
+ expect(r.body.error).toBe("token_expired");
109
+ });
110
+ test("an old but recently used login token stays valid (the window slides)", async () => {
111
+ const s = await loginAs();
112
+ // 90 天前签发,2 天前还在用
113
+ db.run("UPDATE api_tokens SET created_at = datetime('now', '-90 days'), last_used_at = datetime('now', '-2 days') WHERE token_hash = ?1", [hashToken(s.token)]);
114
+ const r = await call(s.token, "GET", "/api/auth/me");
115
+ expect(r.status).toBe(200);
116
+ // 使用把 last_used_at 推到现在(超过节流间隔,必须写)
117
+ const row = db.get<any>("SELECT last_used_at >= datetime('now', '-60 seconds') AS fresh FROM api_tokens WHERE token_hash = ?1", hashToken(s.token));
118
+ expect(row.fresh).toBe(1);
119
+ });
120
+ test("last_used_at is written at most once per hour per token", async () => {
121
+ const s = await loginAs();
122
+ db.run("UPDATE api_tokens SET last_used_at = datetime('now', '-10 minutes') WHERE token_hash = ?1", [hashToken(s.token)]);
123
+ const before = db.get<any>("SELECT last_used_at FROM api_tokens WHERE token_hash = ?1", hashToken(s.token)).last_used_at;
124
+ expect((await call(s.token, "GET", "/api/auth/me")).status).toBe(200);
125
+ const after = db.get<any>("SELECT last_used_at FROM api_tokens WHERE token_hash = ?1", hashToken(s.token)).last_used_at;
126
+ expect(after).toBe(before);
127
+ });
128
+ test("a truly invalid token still says invalid, not expired", async () => {
129
+ const r = await call("utok_00000000000000000000000000000000", "GET", "/api/auth/me");
130
+ expect(r.status).toBe(401);
131
+ expect(r.body.error).not.toBe("token_expired");
132
+ });
133
+ test("node tokens and explicit API tokens never idle-expire", async () => {
134
+ neverUsed(ntok, 400);
135
+ neverUsed(apiToken, 400);
136
+ const n = await call(ntok, "GET", "/api/auth/me");
137
+ expect(n.status).toBe(200);
138
+ expect(n.body.credential.kind).toBe("node");
139
+ expect((await call(apiToken, "GET", "/api/auth/me")).status).toBe(200);
140
+ });
141
+ test("COMMHUB_SESSION_IDLE_DAYS configures the window; 0 disables it", async () => {
142
+ const s = await loginAs();
143
+ ageToken(s.token, 10);
144
+ process.env.COMMHUB_SESSION_IDLE_DAYS = "7";
145
+ try {
146
+ expect((await call(s.token, "GET", "/api/auth/me")).body.error).toBe("token_expired");
147
+ process.env.COMMHUB_SESSION_IDLE_DAYS = "0";
148
+ expect((await call(s.token, "GET", "/api/auth/me")).status).toBe(200);
149
+ } finally {
150
+ delete process.env.COMMHUB_SESSION_IDLE_DAYS;
151
+ }
152
+ });
153
+ });
154
+
155
+ describe("GET /api/auth/sessions", () => {
156
+ test("lists my live login sessions with label, user agent and is_current", async () => {
157
+ const a = await loginAs("Android · 0.2.150", "okhttp/4");
158
+ const b = await loginAs("macOS · 0.2.150", "Mozilla/5.0 (Macintosh)");
159
+ const dead = await loginAs("stale");
160
+ ageToken(dead.token, 40);
161
+ const r = await call(b.token, "GET", "/api/auth/sessions");
162
+ expect(r.status).toBe(200);
163
+ expect(r.body.idle_timeout_days).toBe(30);
164
+ expect(r.body.current_token_id).toBe(b.token_id);
165
+ const ids = r.body.sessions.map((x: any) => x.token_id);
166
+ expect(ids).toContain(a.token_id);
167
+ expect(ids).toContain(b.token_id);
168
+ expect(ids).not.toContain(dead.token_id);
169
+ const me = r.body.sessions.find((x: any) => x.token_id === b.token_id);
170
+ expect(me.is_current).toBe(true);
171
+ expect(me.client_label).toBe("macOS · 0.2.150");
172
+ expect(me.user_agent).toBe("Mozilla/5.0 (Macintosh)");
173
+ expect(typeof me.created_at).toBe("string");
174
+ const other = r.body.sessions.find((x: any) => x.token_id === a.token_id);
175
+ expect(other.is_current).toBe(false);
176
+ expect(other.client_label).toBe("Android · 0.2.150");
177
+ // 不泄漏令牌本身或哈希;API 令牌和节点令牌不在列表里
178
+ expect(JSON.stringify(r.body)).not.toContain("token_hash");
179
+ expect(JSON.stringify(r.body)).not.toContain(b.token);
180
+ expect(r.body.sessions.every((x: any) => x.name !== "ci-script" && !String(x.name).startsWith("node:"))).toBe(true);
181
+ });
182
+ test("node tokens get 403 user_token_required", async () => {
183
+ const r = await call(ntok, "GET", "/api/auth/sessions");
184
+ expect(r.status).toBe(403);
185
+ expect(r.body.error).toBe("user_token_required");
186
+ });
187
+ test("no token → 401", async () => {
188
+ expect((await call("", "GET", "/api/auth/sessions")).status).toBe(401);
189
+ });
190
+ });
191
+
192
+ describe("revoking sessions", () => {
193
+ test("revoke-others keeps the current token and leaves API and node tokens alone", async () => {
194
+ const keep = await loginAs("keep");
195
+ const other1 = await loginAs("other1");
196
+ const other2 = await loginAs("other2");
197
+ const r = await call(keep.token, "POST", "/api/auth/sessions/revoke-others");
198
+ expect(r.status).toBe(200);
199
+ expect(r.body.kept_token_id).toBe(keep.token_id);
200
+ expect(r.body.revoked).toBeGreaterThanOrEqual(2);
201
+ expect((await call(keep.token, "GET", "/api/auth/me")).status).toBe(200);
202
+ expect((await call(other1.token, "GET", "/api/auth/me")).status).toBe(401);
203
+ expect((await call(other2.token, "GET", "/api/auth/me")).status).toBe(401);
204
+ expect((await call(ntok, "GET", "/api/auth/me")).status).toBe(200);
205
+ expect((await call(apiToken, "GET", "/api/auth/me")).status).toBe(200);
206
+ const list = await call(keep.token, "GET", "/api/auth/sessions");
207
+ expect(list.body.sessions.map((x: any) => x.token_id)).toEqual([keep.token_id]);
208
+ const audit = db.get<any>("SELECT detail FROM audit_log WHERE action = 'sessions_revoked_others' AND target_id = ?1", keep.token_id);
209
+ expect(audit?.detail).toMatch(/^revoked=\d+$/);
210
+ });
211
+ test("revoke-others refuses node tokens and non-session tokens", async () => {
212
+ const r = await call(ntok, "POST", "/api/auth/sessions/revoke-others");
213
+ expect(r.status).toBe(403);
214
+ expect(r.body.error).toBe("user_token_required");
215
+ const s = await loginAs("survivor");
216
+ const viaApi = await call(apiToken, "POST", "/api/auth/sessions/revoke-others");
217
+ expect(viaApi.status).toBe(403);
218
+ expect(viaApi.body.error).toBe("session_token_required");
219
+ expect((await call(s.token, "GET", "/api/auth/me")).status).toBe(200);
220
+ });
221
+ test("DELETE /api/auth/sessions/:id revokes one other session", async () => {
222
+ const me = await loginAs("me");
223
+ const victim = await loginAs("victim");
224
+ const r = await call(me.token, "DELETE", `/api/auth/sessions/${victim.token_id}`);
225
+ expect(r.status).toBe(200);
226
+ expect(r.body.was_current).toBe(false);
227
+ expect((await call(victim.token, "GET", "/api/auth/me")).status).toBe(401);
228
+ expect((await call(me.token, "GET", "/api/auth/me")).status).toBe(200);
229
+ expect(db.get<any>("SELECT 1 AS x FROM audit_log WHERE action = 'session_revoked' AND target_id = ?1", victim.token_id)?.x).toBe(1);
230
+ });
231
+ test("DELETE of my own session is a logout", async () => {
232
+ const me = await loginAs("logout");
233
+ const r = await call(me.token, "DELETE", `/api/auth/sessions/${me.token_id}`);
234
+ expect(r.status).toBe(200);
235
+ expect(r.body.was_current).toBe(true);
236
+ expect((await call(me.token, "GET", "/api/auth/me")).status).toBe(401);
237
+ });
238
+ test("DELETE cannot reach API tokens, node tokens, or another user's session", async () => {
239
+ const me = await loginAs("guard");
240
+ const apiRow = db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash = ?1", hashToken(apiToken));
241
+ const ntokRow = db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash = ?1", hashToken(ntok));
242
+ expect((await call(me.token, "DELETE", `/api/auth/sessions/${apiRow.token_id}`)).status).toBe(404);
243
+ expect((await call(me.token, "DELETE", `/api/auth/sessions/${ntokRow.token_id}`)).status).toBe(404);
244
+ const stranger = register(`sess_stranger_${Date.now()}`, PW);
245
+ expect((await call(me.token, "DELETE", `/api/auth/sessions/${stranger.token_id}`)).status).toBe(404);
246
+ expect((await call(stranger.token!, "GET", "/api/auth/me")).status).toBe(200);
247
+ expect((await call(ntok, "DELETE", `/api/auth/sessions/${me.token_id}`)).status).toBe(403);
248
+ expect((await call(me.token, "GET", "/api/auth/me")).status).toBe(200);
249
+ });
250
+ });
@@ -10,13 +10,17 @@ import { register } from "./auth.js";
10
10
 
11
11
  // First user gets RELAXED rules (admin bootstrap allows 4-char "anethub" etc.)
12
12
  // To test full-strength rules, seed a dummy admin first.
13
+ // 30s 而不是 bun 默认的 5s:这个 hook 跑 register()(KDF)+bootServer(),
14
+ // 本机空载基线 ~620ms,而 2026-08-17 与 2026-08-31 两次 CI 上它被 5s 上限打死
15
+ // (0 pass / 1 fail / Ran 1 test —— 见 #928、#1627)。5247ms 是**被截断的下界**,
16
+ // 不是测量值,所以余量按基线的 ~48x 取,而不是按那个数取。
13
17
  beforeAll(() => {
14
18
  // Idempotent: if DB file already has users (from a previous run on the
15
19
  // same temp path), this errors with "username already taken" → that's
16
20
  // fine, the goal is just "ensure at least one user exists so subsequent
17
21
  // registers hit the strict path".
18
22
  register("_seed_admin", "BootstrapPw1", undefined, "seed");
19
- });
23
+ }, 30_000);
20
24
 
21
25
  describe("register — username rules", () => {
22
26
  it("rejects empty username", () => {
@@ -0,0 +1,176 @@
1
+ // #649: a successful login with a weak password flags the account.
2
+ //
3
+ // Accounts created before #261 (2026-06-28) added `must_change_password` sit at 0 even when they
4
+ // still use the bootstrap default. Login is the one place the plaintext is in hand, so a login that
5
+ // succeeds with a password failing validatePasswordStrength (too short / in WEAK_PASSWORDS) sets the
6
+ // flag and returns `must_change_password: true`. Changing the password clears it.
7
+ //
8
+ // Every case goes through the real HTTP route (bootServer + fetch POST /api/auth/login), not the
9
+ // module function, so the response shape is the one clients see. The aggregate runner gives this
10
+ // file its own COMMHUB_DB / HOME / TMPDIR; the hub listens on port 0.
11
+ // Mutation: drop the new block in login() and the weak cases go red (flag stays 0, field absent).
12
+
13
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
14
+ import { mkdtempSync, rmSync } from "fs";
15
+ import { join } from "path";
16
+ import { tmpdir } from "os";
17
+ import { createHash } from "crypto";
18
+ import { register } from "./auth.js";
19
+ import { db, hashPassword } from "./db.js";
20
+ import { WEAK_PASSWORDS } from "./password-dict.js";
21
+
22
+ const DIR = mkdtempSync(join(tmpdir(), "anet-weakpw-"));
23
+ let BASE = "";
24
+ let hub: any = null;
25
+ const STRONG = "Str0ng-Fixture-Passw0rd!";
26
+ // Fixture plaintexts only (not anyone's real password).
27
+ const TOO_SHORT = "abc1234"; // 7 chars: fails the length rule (same shape as an old bootstrap default)
28
+ const IN_LIST = "password1"; // ≥ 8 chars but in WEAK_PASSWORDS
29
+
30
+ async function post(path: string, body: unknown, token = "") {
31
+ const res = await fetch(`${BASE}${path}`, {
32
+ method: "POST",
33
+ headers: { "Content-Type": "application/json", ...(token ? { Authorization: `Bearer ${token}` } : {}) },
34
+ body: JSON.stringify(body),
35
+ });
36
+ return { status: res.status, body: await res.json().catch(() => null) as any };
37
+ }
38
+ const login = (username: string, password: string) => post("/api/auth/login", { username, password });
39
+ const flag = (userId: string) => db.get<any>("SELECT must_change_password AS f FROM users WHERE user_id = ?1", userId)?.f;
40
+
41
+ let seq = 0;
42
+ /** A user created the normal way (strong password), then rewound to an "old account" state:
43
+ * its stored hash replaced by `password` and the flag forced to 0. */
44
+ function oldAccount(password: string, opts: { legacySha256?: boolean } = {}) {
45
+ const username = `weakpw_${Date.now()}_${seq++}`;
46
+ const reg = register(username, STRONG, undefined, undefined, { issueTokens: false });
47
+ expect(reg.ok).toBe(true);
48
+ const userId = reg.user!.user_id;
49
+ const hash = opts.legacySha256 ? createHash("sha256").update(`anet:${password}`).digest("hex") : hashPassword(password); // legacy = pre-A1 `anet:`-prefixed sha256 (auth-kdf-migration.test.ts)
50
+ db.run("UPDATE users SET password_hash = ?1, must_change_password = 0 WHERE user_id = ?2", [hash, userId]);
51
+ expect(flag(userId)).toBe(0);
52
+ return { username, userId };
53
+ }
54
+
55
+ beforeAll(async () => {
56
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
57
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
58
+ process.env.HOST = "127.0.0.1";
59
+ process.env.COMMHUB_LOGIN_IP_MAX = "1000";
60
+ const mod: any = await import("./server.js");
61
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
62
+ BASE = `http://127.0.0.1:${hub.port}`;
63
+ }, 30_000);
64
+
65
+ afterAll(() => {
66
+ try { hub?.stop?.(true); } catch {}
67
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
68
+ });
69
+
70
+ describe("fixtures are what they claim", () => {
71
+ test("TOO_SHORT is < 8 chars and IN_LIST is >= 8 chars and in WEAK_PASSWORDS; STRONG is neither", () => {
72
+ expect(TOO_SHORT.length).toBeLessThan(8);
73
+ expect(IN_LIST.length).toBeGreaterThanOrEqual(8);
74
+ expect(WEAK_PASSWORDS.has(IN_LIST)).toBe(true);
75
+ expect(STRONG.length).toBeGreaterThanOrEqual(8);
76
+ expect(WEAK_PASSWORDS.has(STRONG.toLowerCase())).toBe(false);
77
+ });
78
+ });
79
+
80
+ describe("successful login with a weak password sets must_change_password", () => {
81
+ test("too-short password, flag forced to 0 → login → flag 1 and response says must_change_password: true", async () => {
82
+ const u = oldAccount(TOO_SHORT);
83
+ const r = await login(u.username, TOO_SHORT);
84
+ expect(r.status).toBe(200);
85
+ expect(r.body.ok).toBe(true);
86
+ expect(r.body.must_change_password).toBe(true);
87
+ expect(flag(u.userId)).toBe(1);
88
+ });
89
+
90
+ test("password in WEAK_PASSWORDS (length OK) → flagged", async () => {
91
+ const u = oldAccount(IN_LIST);
92
+ const r = await login(u.username, IN_LIST);
93
+ expect(r.status).toBe(200);
94
+ expect(r.body.must_change_password).toBe(true);
95
+ expect(flag(u.userId)).toBe(1);
96
+ });
97
+
98
+ test("weak-list match is case-insensitive like validatePasswordStrength", async () => {
99
+ const upper = IN_LIST.toUpperCase();
100
+ const u = oldAccount(upper);
101
+ const r = await login(u.username, upper);
102
+ expect(r.status).toBe(200);
103
+ expect(r.body.must_change_password).toBe(true);
104
+ expect(flag(u.userId)).toBe(1);
105
+ });
106
+
107
+ test("legacy sha256 hash + weak password → login succeeds, hash upgraded, flag set", async () => {
108
+ const u = oldAccount(TOO_SHORT, { legacySha256: true });
109
+ const r = await login(u.username, TOO_SHORT);
110
+ expect(r.status).toBe(200);
111
+ expect(r.body.must_change_password).toBe(true);
112
+ expect(flag(u.userId)).toBe(1);
113
+ const stored = db.get<any>("SELECT password_hash FROM users WHERE user_id = ?1", u.userId).password_hash;
114
+ expect(stored.startsWith("scrypt$")).toBe(true);
115
+ });
116
+ });
117
+
118
+ describe("no flag where it does not belong", () => {
119
+ test("strong password → flag stays 0 and the field is absent", async () => {
120
+ const u = oldAccount(STRONG);
121
+ const r = await login(u.username, STRONG);
122
+ expect(r.status).toBe(200);
123
+ expect(r.body.ok).toBe(true);
124
+ expect("must_change_password" in r.body).toBe(false);
125
+ expect(flag(u.userId)).toBe(0);
126
+ });
127
+
128
+ test("failed login against a weak-password account does not touch the flag", async () => {
129
+ const u = oldAccount(TOO_SHORT);
130
+ const r = await login(u.username, "wrong-password-xyz");
131
+ expect(r.status).toBe(401);
132
+ expect(r.body.must_change_password).toBeUndefined();
133
+ expect(flag(u.userId)).toBe(0);
134
+ });
135
+
136
+ test("an already-flagged account with a strong password still reports the flag (#261 behaviour kept)", async () => {
137
+ const u = oldAccount(STRONG);
138
+ db.run("UPDATE users SET must_change_password = 1 WHERE user_id = ?1", [u.userId]);
139
+ const r = await login(u.username, STRONG);
140
+ expect(r.body.must_change_password).toBe(true);
141
+ expect(flag(u.userId)).toBe(1);
142
+ });
143
+ });
144
+
145
+ describe("changing the password clears the flag", () => {
146
+ test("weak login flags → POST /api/auth/password to a strong one → flag 0 → next login has no field", async () => {
147
+ const u = oldAccount(TOO_SHORT);
148
+ const first = await login(u.username, TOO_SHORT);
149
+ expect(first.body.must_change_password).toBe(true);
150
+ const changed = await post("/api/auth/password", { old_password: TOO_SHORT, new_password: STRONG }, first.body.token);
151
+ expect(changed.status).toBe(200);
152
+ expect(changed.body.ok).toBe(true);
153
+ expect(flag(u.userId)).toBe(0);
154
+ const again = await login(u.username, STRONG);
155
+ expect(again.status).toBe(200);
156
+ expect("must_change_password" in again.body).toBe(false);
157
+ });
158
+ });
159
+
160
+ describe("the plaintext is never logged", () => {
161
+ test("console output during a weak-password login does not contain the password", async () => {
162
+ const u = oldAccount(IN_LIST);
163
+ const seen: string[] = [];
164
+ const orig = { log: console.log, warn: console.warn, error: console.error, info: console.info };
165
+ for (const k of Object.keys(orig) as (keyof typeof orig)[]) {
166
+ (console as any)[k] = (...a: unknown[]) => { seen.push(a.map(String).join(" ")); };
167
+ }
168
+ try {
169
+ const r = await login(u.username, IN_LIST);
170
+ expect(r.body.must_change_password).toBe(true);
171
+ } finally {
172
+ Object.assign(console, orig);
173
+ }
174
+ expect(seen.some(l => l.includes(IN_LIST))).toBe(false);
175
+ });
176
+ });