@sleep2agi/commhub-server 0.9.0-preview.0 → 0.9.0-preview.100

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 (273) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +92 -11
  3. package/package.json +8 -7
  4. package/src/ack-create-request-transport.test.ts +159 -0
  5. package/src/ack-create-request.test.ts +331 -0
  6. package/src/admin-delete-network-http.test.ts +228 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/agent-access-batch.test.ts +210 -0
  9. package/src/agent-access.ts +487 -0
  10. package/src/agent-acl-groups-http.test.ts +247 -0
  11. package/src/agent-acl-http.test.ts +520 -0
  12. package/src/agent-acl-slice3-http.test.ts +267 -0
  13. package/src/agent-groups-perf.test.ts +190 -0
  14. package/src/agent-notice.ts +48 -0
  15. package/src/alias-filter.test.ts +72 -0
  16. package/src/alias-filter.ts +42 -0
  17. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  18. package/src/api-host-supervisors-fallback.test.ts +271 -0
  19. package/src/api-nodes-shape.test.ts +188 -0
  20. package/src/auth-kdf-migration.test.ts +378 -0
  21. package/src/auth-sessions-http.test.ts +250 -0
  22. package/src/auth-tokens.test.ts +18 -9
  23. package/src/auth-validate.test.ts +5 -1
  24. package/src/auth.ts +554 -38
  25. package/src/avatar-validate.test.ts +163 -0
  26. package/src/avatar-validate.ts +103 -0
  27. package/src/blocked-exit-on-reply.test.ts +122 -0
  28. package/src/boot-side-effect.test.ts +124 -0
  29. package/src/commhub-bin-args.test.ts +85 -0
  30. package/src/config-apply-sec1.test.ts +830 -0
  31. package/src/config-apply-validate.test.ts +352 -0
  32. package/src/config-apply-validate.ts +249 -0
  33. package/src/create-node-blocked-gate.test.ts +356 -0
  34. package/src/create-node-tool-schema.test.ts +39 -0
  35. package/src/create-node-validate.test.ts +275 -0
  36. package/src/create-node-validate.ts +288 -0
  37. package/src/create-node-workdir.test.ts +255 -0
  38. package/src/create-node.test.ts +354 -0
  39. package/src/create-node.ts +349 -0
  40. package/src/cross-tenant-injection.test.ts +110 -0
  41. package/src/dashboard-slash-routing-http.test.ts +107 -0
  42. package/src/db-adapter-guard.test.ts +148 -0
  43. package/src/db-adapter-translate.test.ts +128 -0
  44. package/src/db-adapter.ts +323 -87
  45. package/src/db-timestamp.test.ts +38 -0
  46. package/src/db-timestamp.ts +15 -0
  47. package/src/db.ts +1268 -42
  48. package/src/delivered-without-push.test.ts +140 -0
  49. package/src/department-groups-http.test.ts +209 -0
  50. package/src/department-groups-sync-http.test.ts +328 -0
  51. package/src/department-groups.ts +434 -0
  52. package/src/department-heads-http.test.ts +329 -0
  53. package/src/department-heads.ts +226 -0
  54. package/src/departments-http.test.ts +185 -0
  55. package/src/departments.ts +260 -0
  56. package/src/desktop-message-seam.test.ts +150 -0
  57. package/src/desktop-message.test.ts +368 -0
  58. package/src/dm-file-scope-http.test.ts +178 -0
  59. package/src/ext-token-shared.test.ts +60 -0
  60. package/src/ext-validation-regression.test.ts +131 -0
  61. package/src/external-schedule-edits-http.test.ts +321 -0
  62. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  63. package/src/external-schedule-edits.ts +327 -0
  64. package/src/file-download-authz-dev-open.test.ts +135 -0
  65. package/src/file-download-authz.test.ts +404 -0
  66. package/src/file-download-date-bucket.test.ts +436 -0
  67. package/src/file-network-scope-dev-open.test.ts +97 -0
  68. package/src/file-network-scope.test.ts +732 -0
  69. package/src/group-membership-events-http.test.ts +264 -0
  70. package/src/group-messages-http.test.ts +482 -0
  71. package/src/group-messages.ts +297 -0
  72. package/src/health-redaction.test.ts +642 -0
  73. package/src/http-gzip.test.ts +94 -0
  74. package/src/http-gzip.ts +112 -0
  75. package/src/hub-timestamp.test.ts +38 -0
  76. package/src/hub-timestamp.ts +27 -0
  77. package/src/human-dm.ts +215 -0
  78. package/src/inbox-count-sse.test.ts +310 -0
  79. package/src/inbox-count.ts +29 -0
  80. package/src/index.ts +15 -2128
  81. package/src/lifecycle-guard.ts +50 -0
  82. package/src/list-host-supervisors.test.ts +314 -0
  83. package/src/mcp-context-cost-http.test.ts +119 -0
  84. package/src/mcp-projects-events-http.test.ts +162 -0
  85. package/src/mcp-route-label-http.test.ts +120 -0
  86. package/src/mcp-write-network-resolution.test.ts +429 -0
  87. package/src/member-audit-network-id-http.test.ts +93 -0
  88. package/src/member-presence-http.test.ts +206 -0
  89. package/src/messages-ack-by-agent-http.test.ts +142 -0
  90. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  91. package/src/migrate-sqlite-to-pg.ts +263 -0
  92. package/src/model-auth-notify-http.test.ts +181 -0
  93. package/src/model-auth-notify.ts +116 -0
  94. package/src/network-name-http.test.ts +75 -0
  95. package/src/network-scope.ts +224 -0
  96. package/src/network-token-membership-http.test.ts +139 -0
  97. package/src/node-attrs-validate.ts +88 -0
  98. package/src/node-attrs.test.ts +217 -0
  99. package/src/node-create-request-status-http.test.ts +92 -0
  100. package/src/node-files-transport.test.ts +344 -0
  101. package/src/node-health-dispatch-http.test.ts +279 -0
  102. package/src/node-health-guard.ts +105 -0
  103. package/src/node-health-passthrough.test.ts +115 -0
  104. package/src/node-health-store.ts +72 -0
  105. package/src/node-id-alias.test.ts +188 -0
  106. package/src/node-identity-conflict-http.test.ts +269 -0
  107. package/src/node-identity-conflict.ts +142 -0
  108. package/src/node-identity-mismatch.test.ts +74 -0
  109. package/src/node-lifecycle-controllable.test.ts +64 -0
  110. package/src/node-lifecycle-controllable.ts +51 -0
  111. package/src/node-logs-transport.test.ts +263 -0
  112. package/src/node-permissions-http.test.ts +276 -0
  113. package/src/node-permissions.ts +242 -0
  114. package/src/node-request-retention.test.ts +281 -0
  115. package/src/node-request-retention.ts +120 -0
  116. package/src/node-skills-transport.test.ts +209 -0
  117. package/src/node-token-privilege-http.test.ts +149 -0
  118. package/src/observer-avatar-http.test.ts +431 -0
  119. package/src/observer-push.test.ts +252 -0
  120. package/src/package-version-consistency.test.ts +25 -0
  121. package/src/peer-reply-atomic.test.ts +455 -0
  122. package/src/peer-reply-capability.test.ts +70 -0
  123. package/src/pg-worker.ts +87 -0
  124. package/src/probe-validate.test.ts +221 -0
  125. package/src/probe-validate.ts +104 -0
  126. package/src/probe.test.ts +80 -0
  127. package/src/probe.ts +200 -0
  128. package/src/project-viewer-can-http.test.ts +114 -0
  129. package/src/push.ts +429 -10
  130. package/src/redact-tokens.test.ts +65 -0
  131. package/src/redact-tokens.ts +40 -0
  132. package/src/rename.ts +7 -2
  133. package/src/reply-target-derivation.test.ts +96 -0
  134. package/src/report-status-host-schema.test.ts +211 -0
  135. package/src/report-status-resume-id-handover.test.ts +150 -0
  136. package/src/require-explicit-test-db.ts +9 -0
  137. package/src/requirement-due-reminders-http.test.ts +390 -0
  138. package/src/requirement-due-reminders.ts +310 -0
  139. package/src/requirement-events-http.test.ts +257 -0
  140. package/src/requirement-events.test.ts +45 -0
  141. package/src/requirement-events.ts +144 -0
  142. package/src/requirement-notify.ts +165 -0
  143. package/src/requirement-participant-notify-http.test.ts +309 -0
  144. package/src/requirement-tag-ops-http.test.ts +266 -0
  145. package/src/requirement-tags.ts +89 -0
  146. package/src/requirements-comment-http.test.ts +165 -0
  147. package/src/requirements-completed-at-migration.test.ts +70 -0
  148. package/src/requirements-errors-mcp-http.test.ts +164 -0
  149. package/src/requirements-errors.ts +143 -0
  150. package/src/requirements-etag-http.test.ts +91 -0
  151. package/src/requirements-http.test.ts +709 -0
  152. package/src/requirements-last-event-http.test.ts +344 -0
  153. package/src/requirements-list-mcp-slim-http.test.ts +155 -0
  154. package/src/requirements-list-slim-http.test.ts +319 -0
  155. package/src/requirements-mcp.test.ts +163 -0
  156. package/src/requirements-migrate.ts +225 -0
  157. package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
  158. package/src/requirements-people-http.test.ts +114 -0
  159. package/src/requirements-people-mcp-http.test.ts +225 -0
  160. package/src/requirements-people.ts +127 -0
  161. package/src/requirements-priority-lowest.test.ts +114 -0
  162. package/src/requirements-search-http.test.ts +182 -0
  163. package/src/requirements-search.test.ts +32 -0
  164. package/src/requirements-search.ts +145 -0
  165. package/src/requirements-seq-http.test.ts +193 -0
  166. package/src/requirements-seq-migration.test.ts +63 -0
  167. package/src/requirements-start-date.test.ts +125 -0
  168. package/src/requirements-stats-http.test.ts +288 -0
  169. package/src/requirements-stats.ts +164 -0
  170. package/src/requirements-status-participants-http.test.ts +220 -0
  171. package/src/requirements-upgrade-e2e.test.ts +126 -0
  172. package/src/requirements-upgrade-migration.test.ts +98 -0
  173. package/src/requirements.ts +1761 -0
  174. package/src/resolve-port.test.ts +57 -0
  175. package/src/resolve-port.ts +44 -0
  176. package/src/response-charset.test.ts +130 -0
  177. package/src/rest-explicit-columns-http.test.ts +222 -0
  178. package/src/rest-identity.test.ts +129 -0
  179. package/src/rest-identity.ts +100 -0
  180. package/src/rest-projections.ts +68 -0
  181. package/src/rest-write-network-resolution.test.ts +133 -0
  182. package/src/restricted-files.ts +73 -0
  183. package/src/retention.test.ts +450 -0
  184. package/src/retention.ts +281 -0
  185. package/src/route-callers-http.test.ts +120 -0
  186. package/src/route-timing.test.ts +211 -0
  187. package/src/route-timing.ts +258 -0
  188. package/src/rules-file-duplicate-alias.test.ts +234 -0
  189. package/src/rules-file-session-target.test.ts +207 -0
  190. package/src/rules-file-transport.test.ts +237 -0
  191. package/src/runtime-label.test.ts +22 -0
  192. package/src/runtime-label.ts +21 -0
  193. package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
  194. package/src/scheduled-reply-unread-http.test.ts +212 -0
  195. package/src/scheduled-run-terminal.test.ts +268 -0
  196. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  197. package/src/scheduled-stuck-http.test.ts +238 -0
  198. package/src/scheduled-stuck.ts +153 -0
  199. package/src/scheduled-tasks-http.test.ts +638 -0
  200. package/src/scheduled-tasks.ts +641 -0
  201. package/src/send-dedup-request-id.test.ts +229 -0
  202. package/src/send-reply-agent-warning.test.ts +202 -0
  203. package/src/send-reply-alias-optional.test.ts +131 -0
  204. package/src/send-reply-attachments.test.ts +458 -0
  205. package/src/send_dedup.test.ts +54 -0
  206. package/src/send_dedup.ts +52 -6
  207. package/src/serve-error.test.ts +38 -0
  208. package/src/serve-error.ts +27 -0
  209. package/src/server.ts +5038 -0
  210. package/src/shared/external-schedule-contract.ts +60 -0
  211. package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
  212. package/src/shared/probe-host-allowlist.ts +128 -0
  213. package/src/shared/reserved-env-drift.test.ts +43 -0
  214. package/src/shared/reserved-env.test.ts +35 -0
  215. package/src/shared/reserved-env.ts +31 -0
  216. package/src/side-thread-command-transport.test.ts +312 -0
  217. package/src/side-thread-command-transport.ts +318 -0
  218. package/src/side-thread-http-integration.test.ts +233 -0
  219. package/src/side-thread-http.ts +504 -0
  220. package/src/side-thread-production.test.ts +72 -0
  221. package/src/side-thread-production.ts +118 -0
  222. package/src/side-thread.test.ts +1067 -0
  223. package/src/side-thread.ts +2499 -0
  224. package/src/skillhub-http.test.ts +108 -0
  225. package/src/sqlite-close-checkpoint.test.ts +78 -0
  226. package/src/stale-sweeper.test.ts +181 -0
  227. package/src/stale-sweeper.ts +114 -0
  228. package/src/start-node.test.ts +109 -0
  229. package/src/status-alias-filter-http.test.ts +106 -0
  230. package/src/status-alias-resolver-http.test.ts +163 -0
  231. package/src/status-alias-resolver.ts +19 -0
  232. package/src/status-node-id-filter-http.test.ts +161 -0
  233. package/src/status-read-cache-http.test.ts +229 -0
  234. package/src/status-read-cache.ts +122 -0
  235. package/src/stop-delete-node.test.ts +1164 -0
  236. package/src/task-access-http.test.ts +379 -0
  237. package/src/task-access.ts +413 -0
  238. package/src/task-auth-origin-http.test.ts +91 -0
  239. package/src/task-auth-origin.test.ts +24 -0
  240. package/src/task-auth-origin.ts +7 -0
  241. package/src/task-consumption.test.ts +482 -0
  242. package/src/task-diagnostic-http.test.ts +149 -0
  243. package/src/task-diagnostic.test.ts +115 -0
  244. package/src/task-diagnostic.ts +118 -0
  245. package/src/task-expiry-notice-http.test.ts +287 -0
  246. package/src/task-expiry-notice.ts +313 -0
  247. package/src/task-idempotency-mcp.test.ts +142 -0
  248. package/src/task-idempotency.test.ts +60 -0
  249. package/src/task-idempotency.ts +75 -0
  250. package/src/task-lifecycle-watcher.test.ts +204 -0
  251. package/src/task-lifecycle-watcher.ts +49 -0
  252. package/src/task-network-resolution-http.test.ts +141 -0
  253. package/src/task-queue-ahead-http.test.ts +309 -0
  254. package/src/task-queue-ahead-scale.test.ts +211 -0
  255. package/src/task-queue-ahead.ts +186 -0
  256. package/src/task-stale-open-http.test.ts +290 -0
  257. package/src/task-stale-open.ts +103 -0
  258. package/src/tasks-pagination-http.test.ts +141 -0
  259. package/src/tool-audience-http.test.ts +195 -0
  260. package/src/tool-audience.ts +109 -0
  261. package/src/tools-list-json-schema.test.ts +67 -0
  262. package/src/tools.ts +5426 -275
  263. package/src/update-provider.test.ts +234 -0
  264. package/src/upload-early-reject-guard.test.ts +155 -0
  265. package/src/uploads-http.test.ts +97 -15
  266. package/src/uploads.test.ts +66 -0
  267. package/src/uploads.ts +138 -5
  268. package/src/user-inbox-by-agent-http.test.ts +161 -0
  269. package/src/user-inbox-netid-notnull.test.ts +122 -0
  270. package/src/user-inbox-read-http.test.ts +223 -0
  271. package/src/user-inbox-schema.test.ts +54 -0
  272. package/src/vault.test.ts +200 -0
  273. package/src/vault.ts +156 -0
@@ -0,0 +1,188 @@
1
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
2
+ import { db } from "./db.js";
3
+ import { parseStoredTags } from "./node-attrs-validate.js";
4
+
5
+ // Regression test for SELECT * fragility on GET /api/nodes.
6
+ //
7
+ // Prior behavior: `SELECT * FROM nodes` returned whatever columns the
8
+ // table happened to have. When RFC-024 added config_revision +
9
+ // config_snapshot columns (read by the per-node GET /api/nodes/:id/config
10
+ // endpoint, NOT meant for the list endpoint), they silently leaked into
11
+ // the list response and broke V3 Networks tests that asserted on the
12
+ // nodes-list row shape.
13
+ //
14
+ // Current behavior: explicit column list — the response contract is:
15
+ // node_id, node_name, alias, runtime, model, config_path, channels,
16
+ // server, hostname, network_id, created_at, updated_at
17
+ // This test runs the same SQL the handler uses, on a row that DOES have
18
+ // config_revision + config_snapshot populated, and asserts those fields
19
+ // do NOT appear in the returned row. If anyone reverts the handler to
20
+ // SELECT *, or adds an internal column to the SELECT, this test breaks.
21
+
22
+ const TEST_NETWORK = "net_test_shape";
23
+ const TEST_NODE = "node_test_shape_abc";
24
+
25
+ beforeEach(() => {
26
+ // Best-effort cleanup (test runs in shared test DB; rows are isolated by id)
27
+ try { db.run("DELETE FROM nodes WHERE node_id = ?1", [TEST_NODE]); } catch {}
28
+ });
29
+ afterAll(() => {
30
+ try { db.run("DELETE FROM nodes WHERE node_id = ?1", [TEST_NODE]); } catch {}
31
+ });
32
+
33
+ // Helper: mirror handler's role-extraction pass to mimic the production
34
+ // response shape exactly. Keeps the test the source of truth for the
35
+ // contract; if the handler changes, the assertions still lock what
36
+ // the dashboard / external clients see.
37
+ function mapRow(r: Record<string, unknown>): Record<string, unknown> {
38
+ let role: string | null = null;
39
+ const snap = r.config_snapshot;
40
+ if (snap) {
41
+ try { role = (typeof snap === "string" ? JSON.parse(snap) : snap)?.role ?? null; }
42
+ catch { /* malformed */ }
43
+ }
44
+ const { config_snapshot, ...rest } = r;
45
+ return {
46
+ ...rest,
47
+ tags: parseStoredTags(r.tags),
48
+ attrs_revision: Number(r.attrs_revision ?? 0),
49
+ role,
50
+ };
51
+ }
52
+
53
+ describe("RFC-024 regression — GET /api/nodes response shape (no SELECT *)", () => {
54
+ test("explicit columns + role extracted: config_snapshot NOT in response, role IS", () => {
55
+ // Insert a row with the leak-prone columns populated. Use INSERT OR
56
+ // REPLACE so concurrent test runs don't trip on PRIMARY KEY.
57
+ db.run(
58
+ `INSERT OR REPLACE INTO nodes (
59
+ node_id, node_name, alias, runtime, model, config_path,
60
+ channels, server, hostname, network_id,
61
+ config_revision, config_snapshot
62
+ ) VALUES (
63
+ ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12
64
+ )`,
65
+ [TEST_NODE, "test-shape", "test-shape", "claude-agent-sdk", "claude-opus",
66
+ "/tmp/cfg.json", "[]", "localhost", "test-host", TEST_NETWORK,
67
+ 7, JSON.stringify({ model: "claude-opus", flags: { x: true }, role: "host_supervisor" })],
68
+ );
69
+
70
+ // Mirror handler SQL + mapping
71
+ const raw = db.all<Record<string, unknown>>(
72
+ `SELECT node_id, node_name, alias, runtime, model,
73
+ config_path, channels, server, hostname,
74
+ network_id, created_at, updated_at,
75
+ config_snapshot, display_name, team, tags, attrs_revision
76
+ FROM nodes WHERE node_id = ?1`,
77
+ TEST_NODE,
78
+ );
79
+ const rows = raw.map(mapRow);
80
+
81
+ expect(rows).toHaveLength(1);
82
+ const row = rows[0]!;
83
+ // Expected fields present (13 = 12 contract + new role)
84
+ for (const k of ["node_id", "node_name", "alias", "runtime", "model",
85
+ "config_path", "channels", "server", "hostname",
86
+ "network_id", "created_at", "updated_at", "role"]) {
87
+ expect(row).toHaveProperty(k);
88
+ }
89
+ // Internal fields NOT present
90
+ expect(row).not.toHaveProperty("config_revision");
91
+ expect(row).not.toHaveProperty("config_snapshot");
92
+ // role is EXTRACTED from config_snapshot JSON, not the raw blob
93
+ expect(row.role).toBe("host_supervisor");
94
+ // Belt: the key set is exactly the dashboard-facing contract.
95
+ // NOTE: this file MIRRORS the handler's SQL rather than calling it, so
96
+ // it can drift — it already lagged behind `lifecycle_state` /
97
+ // `avatar_url`, which the handler selects but this mirror does not.
98
+ // Treat a diff here as "re-read the handler", not as proof of the wire
99
+ // shape; node-attrs.test.ts asserts the real HTTP response.
100
+ expect(Object.keys(row).sort()).toEqual([
101
+ "alias", "attrs_revision", "channels", "config_path", "created_at",
102
+ "display_name", "hostname", "model", "network_id", "node_id",
103
+ "node_name", "role", "runtime", "server", "tags", "team",
104
+ "updated_at",
105
+ ]);
106
+ });
107
+
108
+ test("role is null when config_snapshot is empty / missing / malformed", () => {
109
+ // Empty snapshot → null
110
+ db.run(
111
+ `INSERT OR REPLACE INTO nodes (node_id, node_name, alias, network_id, config_snapshot)
112
+ VALUES (?1, ?2, ?3, ?4, NULL)`,
113
+ [TEST_NODE, "test-shape", "test-shape", TEST_NETWORK],
114
+ );
115
+ let row = mapRow(db.get<Record<string, unknown>>(
116
+ `SELECT node_id, node_name, alias, runtime, model, config_path, channels,
117
+ server, hostname, network_id, created_at, updated_at, config_snapshot,
118
+ display_name, team, tags, attrs_revision
119
+ FROM nodes WHERE node_id = ?1`,
120
+ TEST_NODE,
121
+ )!);
122
+ expect(row.role).toBeNull();
123
+ expect(row).not.toHaveProperty("config_snapshot");
124
+
125
+ // Malformed JSON → null (handler doesn't throw, just leaves role null)
126
+ db.run(`UPDATE nodes SET config_snapshot = ?1 WHERE node_id = ?2`, ["not-json{", TEST_NODE]);
127
+ row = mapRow(db.get<Record<string, unknown>>(
128
+ `SELECT node_id, node_name, alias, runtime, model, config_path, channels,
129
+ server, hostname, network_id, created_at, updated_at, config_snapshot,
130
+ display_name, team, tags, attrs_revision
131
+ FROM nodes WHERE node_id = ?1`,
132
+ TEST_NODE,
133
+ )!);
134
+ expect(row.role).toBeNull();
135
+
136
+ // Snapshot without role key → null
137
+ db.run(`UPDATE nodes SET config_snapshot = ?1 WHERE node_id = ?2`,
138
+ [JSON.stringify({ model: "x", flags: {} }), TEST_NODE]);
139
+ row = mapRow(db.get<Record<string, unknown>>(
140
+ `SELECT node_id, node_name, alias, runtime, model, config_path, channels,
141
+ server, hostname, network_id, created_at, updated_at, config_snapshot,
142
+ display_name, team, tags, attrs_revision
143
+ FROM nodes WHERE node_id = ?1`,
144
+ TEST_NODE,
145
+ )!);
146
+ expect(row.role).toBeNull();
147
+ });
148
+
149
+ test("daemon discovery use-case: dashboard finds role=host_supervisor without prefix heuristic", () => {
150
+ // Daemon registered with arbitrary node_id (NO `node_daemon_` prefix)
151
+ db.run(
152
+ `INSERT OR REPLACE INTO nodes (node_id, node_name, alias, network_id, config_snapshot)
153
+ VALUES (?1, ?2, ?3, ?4, ?5)`,
154
+ [TEST_NODE, "my-daemon", "my-daemon", TEST_NETWORK,
155
+ JSON.stringify({ role: "host_supervisor", runtime: "claude-agent-sdk" })],
156
+ );
157
+ const rows = db.all<Record<string, unknown>>(
158
+ `SELECT node_id, node_name, alias, runtime, model, config_path, channels,
159
+ server, hostname, network_id, created_at, updated_at, config_snapshot,
160
+ display_name, team, tags, attrs_revision
161
+ FROM nodes WHERE network_id = ?1`,
162
+ TEST_NETWORK,
163
+ ).map(mapRow);
164
+ // Dashboard's lookup: find first node with role==='host_supervisor'
165
+ const daemon = rows.find(r => r.role === "host_supervisor");
166
+ expect(daemon).toBeTruthy();
167
+ expect(daemon!.node_id).toBe(TEST_NODE);
168
+ // Even though node_id does NOT start with `node_daemon_` (the prior heuristic)
169
+ expect(String(daemon!.node_id).startsWith("node_daemon_")).toBe(false);
170
+ });
171
+
172
+ test("internal columns are still readable via dedicated query (config snapshot endpoint path)", () => {
173
+ db.run(
174
+ `INSERT OR REPLACE INTO nodes (node_id, node_name, alias, network_id,
175
+ config_revision, config_snapshot)
176
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6)`,
177
+ [TEST_NODE, "test-shape", "test-shape", TEST_NETWORK,
178
+ 42, JSON.stringify({ model: "claude-opus" })],
179
+ );
180
+ // Mirrors the SELECT in GET /api/nodes/:id/config (server/src/index.ts):
181
+ const cfgRow = db.get<{ config_revision: number; config_snapshot: string }>(
182
+ "SELECT config_revision, config_snapshot FROM nodes WHERE node_id = ?1",
183
+ TEST_NODE,
184
+ );
185
+ expect(cfgRow?.config_revision).toBe(42);
186
+ expect(cfgRow?.config_snapshot).toContain("claude-opus");
187
+ });
188
+ });
@@ -0,0 +1,378 @@
1
+ // Round-6 A1 — salted KDF password hash + lazy migration.
2
+ //
3
+ // Pins the 5 behaviours called out in the dispatch:
4
+ // 1. old-format hash verifies + auto-rehashes to new on login
5
+ // 2. new-format hash round-trips
6
+ // 3. wrong password rejected (both old and new format paths)
7
+ // 4. after a successful login against a legacy hash, the legacy
8
+ // hash NO LONGER EXISTS in the DB (single source of truth)
9
+ // 5. bootstrap admin (register flow) writes new format from row 1
10
+ //
11
+ // Plus regression pins:
12
+ // - timing-safe verify path doesn't throw on adversarial input
13
+ // - malformed new-format string is rejected (not silently accepted)
14
+ //
15
+ // All COMMHUB_SCRYPT_N=10 to keep the suite fast (~5ms/scrypt vs
16
+ // 50ms at production N=14).
17
+
18
+ import { describe, expect, test, beforeAll, beforeEach } from "bun:test";
19
+ import { db, hashPassword, verifyPassword } from "./db.js";
20
+ import { register, login } from "./auth.js";
21
+
22
+ // Fast scrypt for tests — 2^10 = 1024 iter is ~5ms. Production stays at N=14.
23
+ beforeAll(() => {
24
+ process.env.COMMHUB_SCRYPT_N = "10";
25
+ });
26
+
27
+ beforeEach(() => {
28
+ db.run("DELETE FROM users");
29
+ db.run("DELETE FROM api_tokens");
30
+ db.run("DELETE FROM network_members");
31
+ db.run("DELETE FROM networks");
32
+ db.run("DELETE FROM audit_log");
33
+ });
34
+
35
+ // Mimic the pre-A1 unsalted SHA-256 path. Used to seed legacy rows.
36
+ function legacyHash(plain: string): string {
37
+ return new Bun.CryptoHasher("sha256").update(`anet:${plain}`).digest("hex");
38
+ }
39
+
40
+ function getStoredHash(username: string): string | null {
41
+ const row = db.get<{ password_hash: string }>(
42
+ "SELECT password_hash FROM users WHERE username = ?1",
43
+ username
44
+ );
45
+ return row?.password_hash ?? null;
46
+ }
47
+
48
+ function insertLegacyUser(username: string, plain: string): string {
49
+ const userId = `usr_${username}`;
50
+ db.run(
51
+ `INSERT INTO users (user_id, username, password_hash, role)
52
+ VALUES (?1, ?2, ?3, 'user')`,
53
+ [userId, username, legacyHash(plain)]
54
+ );
55
+ return userId;
56
+ }
57
+
58
+ describe("hashPassword — new format shape", () => {
59
+ test("produces scrypt$N$salt$hash with non-zero salt and hash", () => {
60
+ const stored = hashPassword("PasswordWith8+");
61
+ const parts = stored.split("$");
62
+ expect(parts.length).toBe(4);
63
+ expect(parts[0]).toBe("scrypt");
64
+ const N = parseInt(parts[1], 10);
65
+ expect(N).toBeGreaterThanOrEqual(8);
66
+ expect(N).toBeLessThanOrEqual(20);
67
+ expect(Buffer.from(parts[2], "base64").length).toBeGreaterThan(0);
68
+ expect(Buffer.from(parts[3], "base64").length).toBe(64); // SCRYPT_KEYLEN
69
+ });
70
+
71
+ test("salt + hash differ on every call (no static derivation)", () => {
72
+ const a = hashPassword("same");
73
+ const b = hashPassword("same");
74
+ const c = hashPassword("same");
75
+ expect(a).not.toBe(b);
76
+ expect(b).not.toBe(c);
77
+ expect(a).not.toBe(c);
78
+ });
79
+ });
80
+
81
+ describe("verifyPassword — new format round-trip", () => {
82
+ test("hashPassword + verifyPassword round-trips, needsRehash=false", () => {
83
+ const stored = hashPassword("MyPassw0rd!");
84
+ const v = verifyPassword("MyPassw0rd!", stored);
85
+ expect(v.ok).toBe(true);
86
+ expect(v.needsRehash).toBe(false);
87
+ });
88
+
89
+ test("wrong password rejected, needsRehash=false", () => {
90
+ const stored = hashPassword("MyPassw0rd!");
91
+ const v = verifyPassword("wrong", stored);
92
+ expect(v.ok).toBe(false);
93
+ expect(v.needsRehash).toBe(false);
94
+ });
95
+
96
+ test("malformed new-format strings rejected (not silently passed)", () => {
97
+ // Each of these tries to abuse the parser. None should yield ok=true.
98
+ const bad = [
99
+ "scrypt$$salt$hash", // missing N
100
+ "scrypt$14$$hash", // empty salt
101
+ "scrypt$14$c2FsdA==$", // empty hash
102
+ "scrypt$abc$c2FsdA==$aGFzaA==", // non-numeric N
103
+ "scrypt$14$c2FsdA==$aGFzaA==$extra", // 5 parts
104
+ "scrypt$14$c2FsdA==", // 3 parts
105
+ "argon2$1$c2FsdA==$aGFzaA==", // unknown scheme
106
+ "scrypt$5$c2FsdA==$aGFzaA==", // N below floor (8)
107
+ "scrypt$30$c2FsdA==$aGFzaA==", // N above ceiling (20)
108
+ ];
109
+ for (const s of bad) {
110
+ const v = verifyPassword("anything", s);
111
+ expect(v.ok).toBe(false);
112
+ }
113
+ });
114
+ });
115
+
116
+ describe("verifyPassword — legacy format compatibility (A1's load-bearing claim)", () => {
117
+ test("legacy unsalted SHA-256 hash verifies", () => {
118
+ const plain = "OldUserPassw0rd";
119
+ const stored = legacyHash(plain);
120
+ const v = verifyPassword(plain, stored);
121
+ expect(v.ok).toBe(true);
122
+ expect(v.needsRehash).toBe(true); // ← KEY: caller MUST rehash
123
+ });
124
+
125
+ test("legacy wrong-password rejected, needsRehash=false", () => {
126
+ const stored = legacyHash("OldUserPassw0rd");
127
+ const v = verifyPassword("wrong", stored);
128
+ expect(v.ok).toBe(false);
129
+ expect(v.needsRehash).toBe(false);
130
+ });
131
+
132
+ test("legacy hash with wrong length rejected (not crash)", () => {
133
+ const v = verifyPassword("anything", "deadbeef"); // 8 hex chars, not 64
134
+ expect(v.ok).toBe(false);
135
+ expect(v.needsRehash).toBe(false);
136
+ });
137
+ });
138
+
139
+ describe("login() — lazy migration on success", () => {
140
+ test("legacy user logs in successfully AND is auto-upgraded to new format", () => {
141
+ insertLegacyUser("legacy-alice", "AliceP@ss123");
142
+
143
+ // Sanity: the seeded hash IS bare-hex sha256 (no $).
144
+ const before = getStoredHash("legacy-alice")!;
145
+ expect(before).not.toContain("$");
146
+ expect(before).toHaveLength(64);
147
+
148
+ const result = login("legacy-alice", "AliceP@ss123");
149
+ expect(result.ok).toBe(true);
150
+
151
+ // The load-bearing assertion: after login, the legacy hash is GONE.
152
+ const after = getStoredHash("legacy-alice")!;
153
+ expect(after).toContain("$");
154
+ expect(after.startsWith("scrypt$")).toBe(true);
155
+ expect(after).not.toBe(before);
156
+ });
157
+
158
+ test("legacy user wrong-password fails AND legacy hash is preserved", () => {
159
+ insertLegacyUser("legacy-bob", "BobP@ss123");
160
+ const before = getStoredHash("legacy-bob")!;
161
+
162
+ const result = login("legacy-bob", "wrong");
163
+ expect(result.ok).toBe(false);
164
+
165
+ // Failed login MUST NOT rehash (would leak info about format).
166
+ const after = getStoredHash("legacy-bob")!;
167
+ expect(after).toBe(before);
168
+ expect(after).not.toContain("$");
169
+ });
170
+
171
+ test("new-format user logs in without rehash (already current)", () => {
172
+ // First login auto-migrates; second login should not rewrite again.
173
+ insertLegacyUser("legacy-charlie", "CharlieP@ss");
174
+ const r1 = login("legacy-charlie", "CharlieP@ss");
175
+ expect(r1.ok).toBe(true);
176
+ const afterFirst = getStoredHash("legacy-charlie")!;
177
+
178
+ const r2 = login("legacy-charlie", "CharlieP@ss");
179
+ expect(r2.ok).toBe(true);
180
+ const afterSecond = getStoredHash("legacy-charlie")!;
181
+
182
+ // Same hash on consecutive logins — no spurious rewrites.
183
+ expect(afterSecond).toBe(afterFirst);
184
+ });
185
+ });
186
+
187
+ describe("register() — new users always start in new format (bootstrap admin)", () => {
188
+ test("freshly-registered user has scrypt$ format from row 1", () => {
189
+ const r = register("dave", "DaveBootstr@p", "dave@example.com", "Dave");
190
+ expect(r.ok).toBe(true);
191
+
192
+ const stored = getStoredHash("dave")!;
193
+ expect(stored.startsWith("scrypt$")).toBe(true);
194
+ });
195
+
196
+ test("registered user can log in (smoke for the whole pipeline)", () => {
197
+ register("eve", "EveP@ssword99", undefined, "Eve");
198
+ const login_r = login("eve", "EveP@ssword99");
199
+ expect(login_r.ok).toBe(true);
200
+ });
201
+
202
+ test("registered user wrong-password fails (smoke for new-format reject)", () => {
203
+ register("frank", "FrankP@ssword99", undefined, "Frank");
204
+ const login_r = login("frank", "wrong");
205
+ expect(login_r.ok).toBe(false);
206
+ });
207
+ });
208
+
209
+ describe("login — username enumeration timing-oracle close (round-6 A1 hardening)", () => {
210
+ // Independent pre-review on #285: post-A1 the user-exists +
211
+ // wrong-password path runs ~50ms scrypt, the user-not-found path
212
+ // returns sub-ms — that's a web-measurable oracle for "is this
213
+ // username registered". Fix: run a throwaway verifyPassword
214
+ // against a module-constant dummy hash on the not-found branch
215
+ // so wall-clock cost is comparable on both paths.
216
+ //
217
+ // The test asserts ORDER-OF-MAGNITUDE timing parity, not exact
218
+ // equality — scrypt timings have natural jitter and we don't
219
+ // want a flaky test that hammers CI on busy hosts. The check
220
+ // is "both paths are slow" (both > some floor), proving the
221
+ // dummy-verify codepath actually runs on the not-found branch.
222
+ test("not-found user takes scrypt-class time (not sub-ms early-return)", () => {
223
+ register("real-user-z", "RealUserZ123");
224
+
225
+ // Warm the JIT + paging on both paths so the first call's
226
+ // amortized cost doesn't skew the comparison.
227
+ login("real-user-z", "wrong");
228
+ login("does-not-exist-warmup", "wrong");
229
+
230
+ // Sample multiple times and take the median so single-call
231
+ // jitter doesn't dominate.
232
+ const sample = (fn: () => void): number => {
233
+ const N = 5;
234
+ const xs: number[] = [];
235
+ for (let i = 0; i < N; i++) {
236
+ const start = performance.now();
237
+ fn();
238
+ xs.push(performance.now() - start);
239
+ }
240
+ xs.sort((a, b) => a - b);
241
+ return xs[Math.floor(N / 2)];
242
+ };
243
+
244
+ const tExists = sample(() => { login("real-user-z", "wrong"); });
245
+ const tMissing = sample(() => { login("nobody-here-" + Math.random(), "wrong"); });
246
+
247
+ // The fix's load-bearing claim: BOTH paths run scrypt. At test
248
+ // N=10 (~5ms per scrypt), an early-return path would be < 1ms.
249
+ // Loose lower bound of 1ms catches an "early return" regression
250
+ // without being flaky on slow CI runners.
251
+ expect(tExists).toBeGreaterThan(1);
252
+ expect(tMissing).toBeGreaterThan(1);
253
+
254
+ // Ratio: both paths should be the same order of magnitude.
255
+ // 5x is a generous bound to avoid CI flakiness from
256
+ // co-scheduled scrypt workloads or paging. Pre-fix this would
257
+ // be 50x+ trivially.
258
+ const ratio = Math.max(tExists, tMissing) / Math.max(0.1, Math.min(tExists, tMissing));
259
+ expect(ratio).toBeLessThan(5);
260
+ });
261
+
262
+ test("not-found user still returns the generic error string (no info leak)", () => {
263
+ register("user-exists", "ExistsP@ss1");
264
+
265
+ const a = login("user-exists", "wrong");
266
+ const b = login("user-does-not-exist", "wrong");
267
+
268
+ expect(a.ok).toBe(false);
269
+ expect(b.ok).toBe(false);
270
+ // Both must return EXACTLY the same error string. If they
271
+ // diverge, the username-enumeration oracle reopens at the
272
+ // string-comparison layer regardless of timing.
273
+ expect(a.error).toBe(b.error);
274
+ expect(a.error).toBe("invalid username or password");
275
+ });
276
+ });
277
+
278
+ describe("login — timing parity across all 3 failure paths (round-6 round-2)", () => {
279
+ // Independent reviewer's round-2 catch on #285: the round-1 dummy-
280
+ // scrypt fix only equalized the !user (missing) path. legacy-user-
281
+ // wrong-password still ran SHA-256 only (sub-ms) while new-format-
282
+ // user-wrong-password ran scrypt (~50ms). That ~50x gap let an
283
+ // attacker distinguish "this username exists on a legacy hash" from
284
+ // "this username has been migrated" or "doesn't exist". Fix: burn
285
+ // an equal-time scrypt on the legacy-wrong-password path so all
286
+ // three failure paths cost SHA-256 + 1 scrypt.
287
+ //
288
+ // The test asserts ORDER-OF-MAGNITUDE timing parity (max/min ratio
289
+ // < 5x) — generous to avoid CI flake but pre-fix would be 50x+.
290
+ test("all 3 wrong-password paths take comparable scrypt-class time", () => {
291
+ // Setup: one user with new-format hash, one with legacy SHA-256
292
+ // seeded directly.
293
+ register("new-user", "NewUserP@ss1");
294
+
295
+ const legacyUserId = "usr_legacy_oracle";
296
+ const legacyHashed = new Bun.CryptoHasher("sha256").update("anet:LegacyP@ss1").digest("hex");
297
+ db.run(
298
+ `INSERT INTO users (user_id, username, password_hash, role) VALUES (?1, ?2, ?3, 'user')`,
299
+ [legacyUserId, "legacy-user", legacyHashed]
300
+ );
301
+
302
+ // Warm JIT + scrypt code paths
303
+ login("new-user", "wrong");
304
+ login("legacy-user", "wrong");
305
+ login("nobody-here-warmup", "wrong");
306
+
307
+ const sample = (fn: () => void): number => {
308
+ const N = 5;
309
+ const xs: number[] = [];
310
+ for (let i = 0; i < N; i++) {
311
+ const start = performance.now();
312
+ fn();
313
+ xs.push(performance.now() - start);
314
+ }
315
+ xs.sort((a, b) => a - b);
316
+ return xs[Math.floor(N / 2)];
317
+ };
318
+
319
+ const tNewWrong = sample(() => { login("new-user", "wrong"); });
320
+ const tLegacyWrong = sample(() => { login("legacy-user", "wrong"); });
321
+ const tMissing = sample(() => { login("nobody-here-" + Math.random(), "wrong"); });
322
+
323
+ // All three must run scrypt (> 1ms floor — early returns are sub-ms)
324
+ expect(tNewWrong).toBeGreaterThan(1);
325
+ expect(tLegacyWrong).toBeGreaterThan(1);
326
+ expect(tMissing).toBeGreaterThan(1);
327
+
328
+ // Order-of-magnitude parity. Pre-fix tLegacyWrong vs tNewWrong
329
+ // would be 50x+. Generous 5x cap absorbs CI scheduling jitter.
330
+ const maxT = Math.max(tNewWrong, tLegacyWrong, tMissing);
331
+ const minT = Math.max(0.1, Math.min(tNewWrong, tLegacyWrong, tMissing));
332
+ expect(maxT / minT).toBeLessThan(5);
333
+ });
334
+
335
+ test("legacy-wrong-password returns the same generic error string as the other paths", () => {
336
+ const legacyUserId = "usr_legacy_str";
337
+ const legacyHashed = new Bun.CryptoHasher("sha256").update("anet:Pw1").digest("hex");
338
+ db.run(
339
+ `INSERT INTO users (user_id, username, password_hash, role) VALUES (?1, ?2, ?3, 'user')`,
340
+ [legacyUserId, "legacy-str", legacyHashed]
341
+ );
342
+ register("new-str", "NewP@ss1");
343
+
344
+ const a = login("legacy-str", "wrong");
345
+ const b = login("new-str", "wrong");
346
+ const c = login("does-not-exist", "wrong");
347
+
348
+ expect(a.ok).toBe(false);
349
+ expect(b.ok).toBe(false);
350
+ expect(c.ok).toBe(false);
351
+ // All three must say EXACTLY the same thing or the oracle reopens
352
+ // at the response-body layer.
353
+ expect(a.error).toBe("invalid username or password");
354
+ expect(b.error).toBe(a.error);
355
+ expect(c.error).toBe(a.error);
356
+ });
357
+ });
358
+
359
+ describe("ordering invariant — single login transition (round-6 spec)", () => {
360
+ // The dispatch's load-bearing post-condition: "post-upgrade 旧串
361
+ // 不再存在 in DB". One concise pin.
362
+ test("after first login, legacy hash is provably absent from the row", () => {
363
+ const plain = "TransitionPw1";
364
+ insertLegacyUser("legacy-tx", plain);
365
+ const legacy = legacyHash(plain);
366
+
367
+ login("legacy-tx", plain);
368
+
369
+ const after = getStoredHash("legacy-tx")!;
370
+ expect(after).not.toBe(legacy); // not the same string
371
+ expect(after.startsWith("scrypt$")).toBe(true);
372
+ // The legacy string is exactly 64 hex chars. The new string is
373
+ // markedly longer (scrypt$N$salt$hash). Verify length disjoint
374
+ // so any future regression that re-stores a 64-char bare hex
375
+ // would be caught.
376
+ expect(after.length).toBeGreaterThan(64);
377
+ });
378
+ });