@sleep2agi/commhub-server 0.9.0-preview.14 → 0.9.0-preview.140

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 (341) 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-department-http.test.ts +148 -0
  16. package/src/agent-groups-perf.test.ts +223 -0
  17. package/src/agent-notice.ts +48 -0
  18. package/src/agent-team-whoami-http.test.ts +98 -0
  19. package/src/agent-teams-http.test.ts +323 -0
  20. package/src/agent-teams.ts +244 -0
  21. package/src/alias-filter.test.ts +72 -0
  22. package/src/alias-filter.ts +42 -0
  23. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  24. package/src/api-host-supervisors-fallback.test.ts +271 -0
  25. package/src/api-host-supervisors-runtime-readiness.test.ts +195 -0
  26. package/src/api-nodes-shape.test.ts +26 -10
  27. package/src/auth-password-change-cli-tokens-http.test.ts +423 -0
  28. package/src/auth-sessions-http.test.ts +250 -0
  29. package/src/auth-validate.test.ts +5 -1
  30. package/src/auth-weak-password-login.test.ts +176 -0
  31. package/src/auth.ts +604 -51
  32. package/src/avatar-validate.test.ts +163 -0
  33. package/src/avatar-validate.ts +103 -0
  34. package/src/binding-generation-http.test.ts +121 -0
  35. package/src/blocked-exit-on-reply.test.ts +122 -0
  36. package/src/boot-side-effect.test.ts +124 -0
  37. package/src/chain-attempt-dedup.test.ts +447 -0
  38. package/src/clone-node.test.ts +248 -0
  39. package/src/clone-node.ts +289 -0
  40. package/src/codex-config-flags.ts +77 -0
  41. package/src/codex-fork-contract.ts +23 -0
  42. package/src/codex-fork-migration.test.ts +38 -0
  43. package/src/commhub-bin-args.test.ts +85 -0
  44. package/src/config-apply-sec1.test.ts +107 -0
  45. package/src/config-apply-validate.test.ts +134 -1
  46. package/src/config-apply-validate.ts +111 -5
  47. package/src/create-node-blocked-gate.test.ts +356 -0
  48. package/src/create-node-network-id.test.ts +79 -0
  49. package/src/create-node-tool-schema.test.ts +49 -0
  50. package/src/create-node-unicode-name.test.ts +120 -0
  51. package/src/create-node-validate.test.ts +199 -7
  52. package/src/create-node-validate.ts +232 -15
  53. package/src/create-node-workdir.test.ts +289 -0
  54. package/src/create-node.ts +88 -23
  55. package/src/daemon-provider-snapshots.test.ts +421 -0
  56. package/src/daemon-provider-snapshots.ts +327 -0
  57. package/src/daemon-token-owner-http.test.ts +236 -0
  58. package/src/dashboard-slash-routing-http.test.ts +109 -0
  59. package/src/db-adapter-guard.test.ts +148 -0
  60. package/src/db-adapter-translate.test.ts +128 -0
  61. package/src/db-adapter.ts +323 -87
  62. package/src/db-timestamp.test.ts +38 -0
  63. package/src/db-timestamp.ts +15 -0
  64. package/src/db.ts +1111 -42
  65. package/src/delivered-without-push.test.ts +140 -0
  66. package/src/department-groups-http.test.ts +209 -0
  67. package/src/department-groups-sync-http.test.ts +328 -0
  68. package/src/department-groups.ts +434 -0
  69. package/src/department-heads-http.test.ts +330 -0
  70. package/src/department-heads.ts +226 -0
  71. package/src/departments-http.test.ts +185 -0
  72. package/src/departments.ts +276 -0
  73. package/src/desktop-message-seam.test.ts +150 -0
  74. package/src/desktop-message.test.ts +368 -0
  75. package/src/dm-file-scope-http.test.ts +178 -0
  76. package/src/env-transport-gate-http.test.ts +146 -0
  77. package/src/env-transport-gate.ts +74 -0
  78. package/src/env-transport-policy.ts +21 -0
  79. package/src/ext-token-shared.test.ts +60 -0
  80. package/src/ext-validation-regression.test.ts +131 -0
  81. package/src/external-schedule-edits-http.test.ts +321 -0
  82. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  83. package/src/external-schedule-edits.ts +327 -0
  84. package/src/file-download-authz-dev-open.test.ts +135 -0
  85. package/src/file-download-authz.test.ts +404 -0
  86. package/src/file-download-date-bucket.test.ts +436 -0
  87. package/src/file-network-scope-dev-open.test.ts +97 -0
  88. package/src/file-network-scope.test.ts +732 -0
  89. package/src/group-membership-events-http.test.ts +264 -0
  90. package/src/group-messages-http.test.ts +482 -0
  91. package/src/group-messages.ts +297 -0
  92. package/src/health-redaction.test.ts +642 -0
  93. package/src/host-supervisor-identity-http.test.ts +152 -0
  94. package/src/host-supervisor-identity.ts +92 -0
  95. package/src/http-gzip.test.ts +94 -0
  96. package/src/http-gzip.ts +112 -0
  97. package/src/hub-catalog-read-http.test.ts +187 -0
  98. package/src/hub-provider-config.test.ts +139 -0
  99. package/src/hub-provider-config.ts +105 -0
  100. package/src/hub-timestamp.test.ts +38 -0
  101. package/src/hub-timestamp.ts +27 -0
  102. package/src/human-dm.ts +215 -0
  103. package/src/human-inbox.ts +42 -0
  104. package/src/inbox-caller.ts +49 -0
  105. package/src/inbox-count-sse.test.ts +310 -0
  106. package/src/inbox-count.ts +29 -0
  107. package/src/index.ts +15 -2362
  108. package/src/lifecycle-guard.ts +1 -1
  109. package/src/list-host-supervisors.test.ts +5 -4
  110. package/src/mcp-context-cost-http.test.ts +123 -0
  111. package/src/mcp-projects-events-http.test.ts +162 -0
  112. package/src/mcp-route-label-http.test.ts +120 -0
  113. package/src/mcp-write-network-resolution.test.ts +432 -0
  114. package/src/member-audit-network-id-http.test.ts +93 -0
  115. package/src/member-presence-http.test.ts +210 -0
  116. package/src/messages-ack-by-agent-http.test.ts +142 -0
  117. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  118. package/src/migrate-sqlite-to-pg.ts +263 -0
  119. package/src/model-auth-notify-http.test.ts +181 -0
  120. package/src/model-auth-notify.ts +116 -0
  121. package/src/network-name-http.test.ts +75 -0
  122. package/src/network-scope.ts +276 -0
  123. package/src/network-token-membership-http.test.ts +139 -0
  124. package/src/node-alias-control.ts +12 -0
  125. package/src/node-attrs-validate.ts +88 -0
  126. package/src/node-attrs.test.ts +217 -0
  127. package/src/node-caller-identity-http.test.ts +1083 -0
  128. package/src/node-create-request-status-http.test.ts +105 -0
  129. package/src/node-daemon-bindings.test.ts +132 -0
  130. package/src/node-daemon-bindings.ts +135 -0
  131. package/src/node-files-transport.test.ts +344 -0
  132. package/src/node-health-codex-login.test.ts +120 -0
  133. package/src/node-health-dispatch-http.test.ts +279 -0
  134. package/src/node-health-guard.ts +105 -0
  135. package/src/node-health-passthrough.test.ts +115 -0
  136. package/src/node-health-store.ts +101 -0
  137. package/src/node-id-alias.test.ts +190 -0
  138. package/src/node-identity-conflict-http.test.ts +269 -0
  139. package/src/node-identity-conflict.ts +142 -0
  140. package/src/node-identity-mismatch.test.ts +74 -0
  141. package/src/node-lifecycle-controllable.test.ts +64 -0
  142. package/src/node-lifecycle-controllable.ts +53 -0
  143. package/src/node-lifecycle-read-http.test.ts +245 -0
  144. package/src/node-lifecycle-read.ts +121 -0
  145. package/src/node-logs-transport.test.ts +263 -0
  146. package/src/node-permissions-http.test.ts +276 -0
  147. package/src/node-permissions.ts +285 -0
  148. package/src/node-request-retention.test.ts +281 -0
  149. package/src/node-request-retention.ts +120 -0
  150. package/src/node-secret-rest-http.test.ts +190 -0
  151. package/src/node-skills-transport.test.ts +209 -0
  152. package/src/node-token-identity.ts +35 -0
  153. package/src/node-token-ownership.ts +51 -0
  154. package/src/node-token-privilege-http.test.ts +158 -0
  155. package/src/observer-avatar-http.test.ts +442 -0
  156. package/src/observer-push.test.ts +252 -0
  157. package/src/opencode-v2-catalog.test.ts +268 -0
  158. package/src/opencode-v2-catalog.ts +352 -0
  159. package/src/package-version-consistency.test.ts +25 -0
  160. package/src/password-network-tokens.ts +25 -0
  161. package/src/peer-reply-atomic.test.ts +455 -0
  162. package/src/peer-reply-capability.test.ts +70 -0
  163. package/src/pg-worker.ts +87 -0
  164. package/src/project-dir-warn-clear-http.test.ts +132 -0
  165. package/src/project-viewer-can-http.test.ts +114 -0
  166. package/src/push.ts +429 -10
  167. package/src/redact-tokens.test.ts +65 -0
  168. package/src/redact-tokens.ts +40 -0
  169. package/src/rename.ts +38 -10
  170. package/src/reply-target-derivation.test.ts +96 -0
  171. package/src/report-status-handover-capabilities-http.test.ts +124 -0
  172. package/src/report-status-host-schema.test.ts +235 -0
  173. package/src/report-status-resume-id-handover.test.ts +184 -0
  174. package/src/report-status-secret-keys.test.ts +140 -0
  175. package/src/require-explicit-test-db.ts +9 -0
  176. package/src/requirement-due-reminders-http.test.ts +620 -0
  177. package/src/requirement-due-reminders.ts +416 -0
  178. package/src/requirement-events-http.test.ts +268 -0
  179. package/src/requirement-events.test.ts +45 -0
  180. package/src/requirement-events.ts +144 -0
  181. package/src/requirement-notify.ts +165 -0
  182. package/src/requirement-participant-notify-http.test.ts +309 -0
  183. package/src/requirement-tag-ops-http.test.ts +266 -0
  184. package/src/requirement-tags.ts +89 -0
  185. package/src/requirements-abandoned-http.test.ts +236 -0
  186. package/src/requirements-comment-http.test.ts +167 -0
  187. package/src/requirements-completed-at-migration.test.ts +70 -0
  188. package/src/requirements-errors-mcp-http.test.ts +164 -0
  189. package/src/requirements-errors.ts +143 -0
  190. package/src/requirements-etag-http.test.ts +91 -0
  191. package/src/requirements-http.test.ts +709 -0
  192. package/src/requirements-last-event-http.test.ts +349 -0
  193. package/src/requirements-list-mcp-slim-http.test.ts +155 -0
  194. package/src/requirements-list-slim-http.test.ts +319 -0
  195. package/src/requirements-mcp.test.ts +163 -0
  196. package/src/requirements-migrate.ts +259 -0
  197. package/src/requirements-owner-strict-mcp-http.test.ts +145 -0
  198. package/src/requirements-people-http.test.ts +114 -0
  199. package/src/requirements-people-mcp-http.test.ts +225 -0
  200. package/src/requirements-people.ts +131 -0
  201. package/src/requirements-priority-lowest.test.ts +114 -0
  202. package/src/requirements-search-http.test.ts +182 -0
  203. package/src/requirements-search.test.ts +32 -0
  204. package/src/requirements-search.ts +145 -0
  205. package/src/requirements-seq-http.test.ts +193 -0
  206. package/src/requirements-seq-migration.test.ts +63 -0
  207. package/src/requirements-start-date.test.ts +125 -0
  208. package/src/requirements-stats-http.test.ts +357 -0
  209. package/src/requirements-stats.ts +195 -0
  210. package/src/requirements-status-participants-http.test.ts +222 -0
  211. package/src/requirements-upgrade-e2e.test.ts +126 -0
  212. package/src/requirements-upgrade-migration.test.ts +98 -0
  213. package/src/requirements.ts +1806 -0
  214. package/src/resolve-port.test.ts +57 -0
  215. package/src/resolve-port.ts +44 -0
  216. package/src/response-charset.test.ts +130 -0
  217. package/src/rest-explicit-columns-http.test.ts +222 -0
  218. package/src/rest-identity.test.ts +130 -0
  219. package/src/rest-identity.ts +141 -0
  220. package/src/rest-projections.ts +70 -0
  221. package/src/rest-write-network-resolution.test.ts +133 -0
  222. package/src/restricted-files.ts +76 -0
  223. package/src/retention.ts +15 -3
  224. package/src/route-callers-http.test.ts +120 -0
  225. package/src/route-timing.test.ts +211 -0
  226. package/src/route-timing.ts +258 -0
  227. package/src/rules-file-duplicate-alias.test.ts +234 -0
  228. package/src/rules-file-session-target.test.ts +207 -0
  229. package/src/rules-file-transport.test.ts +237 -0
  230. package/src/runtime-label.test.ts +22 -0
  231. package/src/runtime-label.ts +21 -0
  232. package/src/runtime-readiness.ts +90 -0
  233. package/src/schedule-agent-mcp-http.test.ts +381 -0
  234. package/src/schedule-agent.ts +192 -0
  235. package/src/scheduled-backoff-http.test.ts +193 -0
  236. package/src/scheduled-backoff.ts +64 -0
  237. package/src/scheduled-doorbell-nodeless-session.test.ts +134 -0
  238. package/src/scheduled-failures-http.test.ts +233 -0
  239. package/src/scheduled-failures.ts +179 -0
  240. package/src/scheduled-offline.test.ts +131 -0
  241. package/src/scheduled-offline.ts +66 -0
  242. package/src/scheduled-outcome-guidance-http.test.ts +139 -0
  243. package/src/scheduled-outcome-guidance.ts +9 -0
  244. package/src/scheduled-reply-unread-http.test.ts +212 -0
  245. package/src/scheduled-requirement-wake-http.test.ts +264 -0
  246. package/src/scheduled-requirement-wake.ts +125 -0
  247. package/src/scheduled-run-terminal.test.ts +268 -0
  248. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  249. package/src/scheduled-stuck-http.test.ts +312 -0
  250. package/src/scheduled-stuck.ts +186 -0
  251. package/src/scheduled-tasks-http.test.ts +638 -0
  252. package/src/scheduled-tasks.ts +819 -0
  253. package/src/scheduled-work-outcome-http.test.ts +154 -0
  254. package/src/scheduled-work-outcome.ts +50 -0
  255. package/src/scoped-secrets.ts +139 -0
  256. package/src/send-dedup-request-id.test.ts +229 -0
  257. package/src/send-reply-agent-warning.test.ts +202 -0
  258. package/src/send-reply-alias-optional.test.ts +131 -0
  259. package/src/send-reply-attachments.test.ts +458 -0
  260. package/src/send_dedup.test.ts +54 -0
  261. package/src/send_dedup.ts +52 -6
  262. package/src/serve-error.test.ts +38 -0
  263. package/src/serve-error.ts +27 -0
  264. package/src/server.ts +5398 -0
  265. package/src/session-task-on-dispatch.ts +14 -0
  266. package/src/session-token-ownership-http.test.ts +122 -0
  267. package/src/shared/codex-inventory.ts +41 -0
  268. package/src/shared/daemon-provider-login.ts +31 -0
  269. package/src/shared/daemon-provider-snapshot.ts +52 -0
  270. package/src/shared/external-schedule-contract.ts +60 -0
  271. package/src/shared/node-name.test.ts +62 -0
  272. package/src/shared/node-name.ts +161 -0
  273. package/src/shared/provider-application.ts +29 -0
  274. package/src/shared/provider-collection.ts +14 -0
  275. package/src/shared/provider-initialization.ts +8 -0
  276. package/src/shared/reserved-env-drift.test.ts +10 -0
  277. package/src/shared/scoped-secret-contract.ts +5 -0
  278. package/src/shared/task-content-limit-drift.test.ts +28 -0
  279. package/src/shared/task-content-limit.ts +25 -0
  280. package/src/side-thread-command-transport.test.ts +312 -0
  281. package/src/side-thread-command-transport.ts +318 -0
  282. package/src/side-thread-http-integration.test.ts +233 -0
  283. package/src/side-thread-http.ts +504 -0
  284. package/src/side-thread-production.test.ts +72 -0
  285. package/src/side-thread-production.ts +118 -0
  286. package/src/side-thread.test.ts +1067 -0
  287. package/src/side-thread.ts +2499 -0
  288. package/src/skillhub-http.test.ts +108 -0
  289. package/src/sqlite-close-checkpoint.test.ts +78 -0
  290. package/src/start-node.test.ts +264 -0
  291. package/src/status-alias-filter-http.test.ts +106 -0
  292. package/src/status-alias-resolver-http.test.ts +163 -0
  293. package/src/status-alias-resolver.ts +19 -0
  294. package/src/status-node-id-filter-http.test.ts +161 -0
  295. package/src/status-read-cache-http.test.ts +229 -0
  296. package/src/status-read-cache.ts +122 -0
  297. package/src/stop-delete-node.test.ts +509 -2
  298. package/src/task-access-http.test.ts +402 -0
  299. package/src/task-access.ts +420 -0
  300. package/src/task-activity.ts +60 -0
  301. package/src/task-auth-origin-http.test.ts +93 -0
  302. package/src/task-auth-origin.test.ts +24 -0
  303. package/src/task-auth-origin.ts +7 -0
  304. package/src/task-consumption.test.ts +482 -0
  305. package/src/task-diagnostic-http.test.ts +149 -0
  306. package/src/task-diagnostic.test.ts +115 -0
  307. package/src/task-diagnostic.ts +118 -0
  308. package/src/task-expiry-notice-http.test.ts +287 -0
  309. package/src/task-expiry-notice.ts +315 -0
  310. package/src/task-idempotency-mcp.test.ts +142 -0
  311. package/src/task-idempotency.test.ts +60 -0
  312. package/src/task-idempotency.ts +75 -0
  313. package/src/task-late-replies.ts +54 -0
  314. package/src/task-late-reply-http.test.ts +152 -0
  315. package/src/task-lifecycle-watcher.test.ts +204 -0
  316. package/src/task-lifecycle-watcher.ts +49 -0
  317. package/src/task-network-resolution-http.test.ts +140 -0
  318. package/src/task-orphan-http.test.ts +287 -0
  319. package/src/task-orphan.ts +119 -0
  320. package/src/task-queue-ahead-http.test.ts +309 -0
  321. package/src/task-queue-ahead-scale.test.ts +236 -0
  322. package/src/task-queue-ahead.ts +186 -0
  323. package/src/task-rejection-log-http.test.ts +160 -0
  324. package/src/task-rejection-log.ts +49 -0
  325. package/src/task-sender-http.test.ts +143 -0
  326. package/src/task-stale-open-http.test.ts +290 -0
  327. package/src/task-stale-open.ts +103 -0
  328. package/src/tasks-pagination-http.test.ts +141 -0
  329. package/src/tool-audience-http.test.ts +220 -0
  330. package/src/tool-audience.ts +118 -0
  331. package/src/tools-list-json-schema.test.ts +67 -0
  332. package/src/tools.ts +4008 -355
  333. package/src/upload-early-reject-guard.test.ts +155 -0
  334. package/src/uploads-http.test.ts +97 -15
  335. package/src/uploads.ts +103 -6
  336. package/src/user-avatar-http.test.ts +74 -0
  337. package/src/user-avatar-migration.test.ts +25 -0
  338. package/src/user-inbox-by-agent-http.test.ts +161 -0
  339. package/src/user-inbox-netid-notnull.test.ts +122 -0
  340. package/src/user-inbox-read-http.test.ts +223 -0
  341. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,146 @@
1
+ // #1510 — the secret-transport gate, shipped as log-only first.
2
+ //
3
+ // The load-bearing property is symmetric: with the gate in log mode a write to a
4
+ // `plain` node MUST still succeed (nobody's workflow breaks on upgrade) but MUST
5
+ // leave a hit behind, and with `enforce` the same write MUST be refused. A
6
+ // half-implemented gate that logs but also refuses would break every plain-HTTP
7
+ // node on the next deploy; one that refuses but never logs leaves nothing to
8
+ // decide with.
9
+ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
10
+ import { mkdtempSync, rmSync } from "node:fs";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+
14
+ const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-transport-gate-"));
15
+ process.env.COMMHUB_DB ||= join(PRIVATE_DB_DIR, "hub.db");
16
+
17
+ let net = "", ownerId = "", ownerToken = "";
18
+ let server: ReturnType<typeof Bun.serve>;
19
+ let base = "";
20
+ const PLAIN = "n_gate_plain";
21
+ const TLS = "n_gate_tls";
22
+
23
+ const post = async (path: string, token: string, body: unknown) => {
24
+ const r = await fetch(`${base}${path}`, {
25
+ method: "POST",
26
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
27
+ body: JSON.stringify(body),
28
+ });
29
+ return { status: r.status, body: await r.json() as any };
30
+ };
31
+ const get = async (path: string, token: string) => {
32
+ const r = await fetch(`${base}${path}`, { headers: { Authorization: `Bearer ${token}` } });
33
+ return { status: r.status, body: await r.json() as any };
34
+ };
35
+
36
+ beforeAll(async () => {
37
+ const { db } = await import("./db.js");
38
+ const { register } = await import("./auth.js");
39
+ const stamp = Date.now();
40
+ const owner = register(`gate_owner_${stamp}`, "GateOwner-Strong-1!");
41
+ expect(owner.ok).toBe(true);
42
+ ownerToken = owner.token!;
43
+ net = owner.network_id!;
44
+ ownerId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", `gate_owner_${stamp}`)!.user_id;
45
+
46
+ const seed = (nodeId: string, transport: string) => db.run(
47
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, lifecycle_state, config_snapshot)
48
+ VALUES (?1, ?1, ?1, ?2, 'active', ?3)`,
49
+ [nodeId, net, JSON.stringify({ node_secrets_capable: true })],
50
+ );
51
+ seed(PLAIN, "plain");
52
+ seed(TLS, "https");
53
+ // The transport is reported on the session row (that is where report_status
54
+ // writes it).
55
+ const session = (nodeId: string, transport: string) => db.run(
56
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, env_transport, updated_at, last_seen_at)
57
+ VALUES (?1, ?1, 'idle', ?1, ?2, ?3, datetime('now'), datetime('now'))`,
58
+ [nodeId, net, transport],
59
+ );
60
+ session(PLAIN, "plain");
61
+ session(TLS, "https");
62
+
63
+ const mod = await import("./server.js");
64
+ server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
65
+ base = `http://127.0.0.1:${server.port}`;
66
+ });
67
+
68
+ afterAll(() => {
69
+ try { server?.stop(true); } catch {}
70
+ try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
71
+ });
72
+
73
+ beforeEach(async () => {
74
+ // The lane allows one pending request per node; drain whatever the previous
75
+ // case left so the next one starts clean.
76
+ const { pullNodeSecrets, ackNodeSecrets } = await import("./scoped-secrets.js");
77
+ for (const nodeId of [PLAIN, TLS]) {
78
+ const daemon = { ok: true as const, networkId: net, daemonNodeId: nodeId, daemonAlias: nodeId };
79
+ const pulled = pullNodeSecrets(daemon) as any;
80
+ for (const r of (pulled?.requests ?? [])) ackNodeSecrets(daemon, r.request_id, null, true);
81
+ }
82
+ process.env.ANET_SECRET_TRANSPORT_GATE = "log";
83
+ });
84
+
85
+ describe("log mode (default)", () => {
86
+ test("a put to a plain node still succeeds AND leaves a hit", async () => {
87
+ const r = await post(`/api/nodes/${PLAIN}/secrets?network_id=${net}`, ownerToken, { action: "put", key: "A_KEY", value: "v", revision: 1 });
88
+ expect(r.status).toBe(200);
89
+ expect(r.body.ok).toBe(true); // not refused: that is the whole point of log mode
90
+
91
+ const log = await get(`/api/networks/${net}/env-transport-log`, ownerToken);
92
+ expect(log.status).toBe(200);
93
+ expect(log.body.total).toBe(1);
94
+ expect(log.body.rows[0]).toMatchObject({ node_id: PLAIN, route: "node_secret_put", hits: 1 });
95
+ });
96
+
97
+ test("a put to a TLS node leaves no hit", async () => {
98
+ const before = (await get(`/api/networks/${net}/env-transport-log`, ownerToken)).body.total;
99
+ const r = await post(`/api/nodes/${TLS}/secrets?network_id=${net}`, ownerToken, { action: "put", key: "B_KEY", value: "v", revision: 1 });
100
+ expect(r.status).toBe(200);
101
+ const after = (await get(`/api/networks/${net}/env-transport-log`, ownerToken)).body.total;
102
+ expect(after).toBe(before);
103
+ });
104
+
105
+ test("list is not observed (it carries no secret)", async () => {
106
+ const before = (await get(`/api/networks/${net}/env-transport-log`, ownerToken)).body.total;
107
+ await post(`/api/nodes/${PLAIN}/secrets?network_id=${net}`, ownerToken, { action: "list" });
108
+ const after = (await get(`/api/networks/${net}/env-transport-log`, ownerToken)).body.total;
109
+ expect(after).toBe(before);
110
+ });
111
+ });
112
+
113
+ describe("enforce mode", () => {
114
+ test("the same put is refused, and nothing is queued", async () => {
115
+ process.env.ANET_SECRET_TRANSPORT_GATE = "enforce";
116
+ const r = await post(`/api/nodes/${PLAIN}/secrets?network_id=${net}`, ownerToken, { action: "put", key: "C_KEY", value: "v", revision: 1 });
117
+ expect(r.status).toBe(400);
118
+ expect(r.body.error).toBe("secret_transport_plain");
119
+ });
120
+
121
+ test("a TLS node is unaffected", async () => {
122
+ process.env.ANET_SECRET_TRANSPORT_GATE = "enforce";
123
+ const r = await post(`/api/nodes/${TLS}/secrets?network_id=${net}`, ownerToken, { action: "put", key: "D_KEY", value: "v", revision: 1 });
124
+ expect(r.status).toBe(200);
125
+ });
126
+ });
127
+
128
+ describe("off mode", () => {
129
+ test("records nothing at all", async () => {
130
+ process.env.ANET_SECRET_TRANSPORT_GATE = "off";
131
+ const before = (await get(`/api/networks/${net}/env-transport-log`, ownerToken)).body.total;
132
+ const r = await post(`/api/nodes/${PLAIN}/secrets?network_id=${net}`, ownerToken, { action: "put", key: "E_KEY", value: "v", revision: 1 });
133
+ expect(r.status).toBe(200);
134
+ const after = (await get(`/api/networks/${net}/env-transport-log`, ownerToken)).body.total;
135
+ expect(after).toBe(before);
136
+ });
137
+ });
138
+
139
+ describe("the log endpoint is owner/admin only", () => {
140
+ test("an unrelated user gets 403", async () => {
141
+ const { register } = await import("./auth.js");
142
+ const other = register(`gate_other_${Date.now()}`, "GateOther-Strong-1!");
143
+ const r = await get(`/api/networks/${net}/env-transport-log`, other.token!);
144
+ expect(r.status).toBe(403);
145
+ });
146
+ });
@@ -0,0 +1,74 @@
1
+ // #1510 (log-only) — record the secret writes that a future transport gate would
2
+ // have refused, without refusing any of them.
3
+ //
4
+ // Why log-only first: refusing makes an existing workflow fail on upgrade, and
5
+ // the nodes that talk plain HTTP today are exactly the ones we cannot see from
6
+ // the Hub. This table answers "how many writes would have been blocked, on which
7
+ // node" *before* anyone decides to turn the gate on — the same rollout shape as
8
+ // #519's stale-open sweep and #525's human_only log mode.
9
+ //
10
+ // Hourly aggregation, like node_permission_log: a chatty node must not turn this
11
+ // into one row per write.
12
+ import { db } from "./db.js";
13
+ import { getEnvTransportMode } from "./env-transport-policy.js";
14
+
15
+ const HOURS_RETAINED = 30 * 24;
16
+ const MAX_ROWS = 20_000;
17
+
18
+ function hourOf(now = new Date()): string {
19
+ return now.toISOString().slice(0, 13);
20
+ }
21
+
22
+ /** Record one would-refuse hit. Never throws: logging must not break a write. */
23
+ export function noteEnvTransportWouldRefuse(networkId: string, nodeId: string, route: string, sample?: string): void {
24
+ try {
25
+ const now = new Date();
26
+ if (Math.random() < 0.02) {
27
+ db.run("DELETE FROM env_transport_would_refuse WHERE hour < ?1",
28
+ [hourOf(new Date(now.getTime() - HOURS_RETAINED * 86_400_000))]);
29
+ }
30
+ const count = Number(db.get<{ n: number | string }>("SELECT COUNT(*) AS n FROM env_transport_would_refuse")?.n ?? 0);
31
+ const hour = hourOf(now);
32
+ const s = sample ? sample.slice(0, 120) : null;
33
+ if (count >= MAX_ROWS) {
34
+ db.run(
35
+ "UPDATE env_transport_would_refuse SET hits = hits + 1, sample = COALESCE(sample, ?5) WHERE network_id = ?1 AND node_id = ?2 AND route = ?3 AND hour = ?4",
36
+ [networkId, nodeId, route, hour, s],
37
+ );
38
+ return;
39
+ }
40
+ db.run(
41
+ `INSERT INTO env_transport_would_refuse (network_id, node_id, route, hour, hits, sample) VALUES (?1, ?2, ?3, ?4, 1, ?5)
42
+ ON CONFLICT(network_id, node_id, route, hour) DO UPDATE SET hits = env_transport_would_refuse.hits + 1`,
43
+ [networkId, nodeId, route, hour, s],
44
+ );
45
+ } catch (e) {
46
+ console.error(`[env-transport] would-refuse log failed: ${(e as Error).message}`);
47
+ }
48
+ }
49
+
50
+ /** Where a secret write is aimed: the node's own row, or a daemon's. Returns the
51
+ * transport the node last reported, or null when it is unknown. */
52
+ export function transportOf(networkId: string, nodeId: string): string | null {
53
+ const row = db.get<{ env_transport: string | null }>(
54
+ "SELECT env_transport FROM sessions WHERE network_id = ?1 AND (node_id = ?2 OR alias = ?2) ORDER BY updated_at DESC LIMIT 1",
55
+ [networkId, nodeId],
56
+ );
57
+ return row?.env_transport ?? null;
58
+ }
59
+
60
+ /** Called from the write lanes. Logs when the current policy would refuse. */
61
+ export function observeSecretWrite(networkId: string, nodeId: string, route: string): void {
62
+ if (getEnvTransportMode() !== "log") return;
63
+ const transport = transportOf(networkId, nodeId);
64
+ if (transport !== "plain") return;
65
+ noteEnvTransportWouldRefuse(networkId, nodeId, route, `transport=${transport}`);
66
+ }
67
+
68
+ export function readEnvTransportLog(networkId: string, sinceHour: string) {
69
+ return db.all<{ node_id: string; route: string; hour: string; hits: number | string; sample: string | null }>(
70
+ `SELECT node_id, route, hour, hits, sample FROM env_transport_would_refuse
71
+ WHERE network_id = ?1 AND hour >= ?2 ORDER BY hour DESC, node_id, route`,
72
+ [networkId, sinceHour],
73
+ ).map(r => ({ ...r, hits: Number(r.hits) }));
74
+ }
@@ -0,0 +1,21 @@
1
+ // #1510 — the switch for the secret-transport gate.
2
+ //
3
+ // (unset) / "log" — record what a gate WOULD refuse; refuse nothing.
4
+ // "enforce" — refuse secret writes to nodes that report `plain`.
5
+ // "off" — do nothing at all (no recording either).
6
+ //
7
+ // Reading the env on every call (like the stale-open sweep) means an operator can
8
+ // change the mode without a code change and tests can flip it per case.
9
+ export type EnvTransportMode = "off" | "log" | "enforce";
10
+
11
+ export function getEnvTransportMode(env: Record<string, string | undefined> = process.env): EnvTransportMode {
12
+ const raw = (env.ANET_SECRET_TRANSPORT_GATE ?? "").trim().toLowerCase();
13
+ if (raw === "off") return "off";
14
+ if (raw === "enforce") return "enforce";
15
+ return "log";
16
+ }
17
+
18
+ /** True when a write to a node reporting `plain` must be refused. */
19
+ export function refusesPlainTransport(transport: string | null, env: Record<string, string | undefined> = process.env): boolean {
20
+ return getEnvTransportMode(env) === "enforce" && transport === "plain";
21
+ }
@@ -0,0 +1,60 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ buildStoragePath,
4
+ pathForExistingBlob,
5
+ validateIndexEntry,
6
+ } from "./uploads.js";
7
+
8
+ const fileId = "0123456789abcdef0123456789abcdef";
9
+ const dateBucket = "2026-08-09";
10
+ const uploadsRoot = "/srv/uploads";
11
+
12
+ const malicious = [
13
+ "../x",
14
+ "/abs/x",
15
+ ".png/../../y",
16
+ ".p ng",
17
+ ".p\0ng",
18
+ ".\\..\\x",
19
+ "." + "a".repeat(17),
20
+ ".tar.gz",
21
+ ];
22
+
23
+ const legal = ["", ".png", ".PNG", ".mp4", "." + "a".repeat(16)];
24
+
25
+ describe("#527 shared extension-token invariant", () => {
26
+ test("new-upload path rejects every malformed token and accepts the shared legal set", () => {
27
+ for (const ext of malicious) {
28
+ expect(() => buildStoragePath(fileId, ext, { uploadsRoot })).toThrow(/ext .* is invalid/);
29
+ }
30
+ for (const ext of legal) {
31
+ expect(buildStoragePath(fileId, ext, { uploadsRoot }).ext).toBe(ext);
32
+ }
33
+ });
34
+
35
+ test("existing-blob path rejects every malformed token and accepts the shared legal set", () => {
36
+ for (const ext of malicious) {
37
+ expect(() => pathForExistingBlob(dateBucket, fileId, ext, { uploadsRoot })).toThrow(/ext .* is invalid/);
38
+ }
39
+ for (const ext of legal) {
40
+ expect(pathForExistingBlob(dateBucket, fileId, ext, { uploadsRoot }).ext).toBe(ext);
41
+ }
42
+ });
43
+
44
+ test("stored-index gate rejects every malformed token and accepts the shared legal set", () => {
45
+ for (const ext of malicious) {
46
+ expect(validateIndexEntry({ file_id: fileId, date_bucket: dateBucket, ext, size: 0 })).toBe(false);
47
+ }
48
+ for (const ext of legal) {
49
+ expect(validateIndexEntry({ file_id: fileId, date_bucket: dateBucket, ext, size: 0 })).toBe(true);
50
+ }
51
+ });
52
+
53
+ test("all three boundaries fail closed on non-string runtime values", () => {
54
+ for (const ext of [undefined, null, 7, {}, []]) {
55
+ expect(() => buildStoragePath(fileId, ext as never, { uploadsRoot })).toThrow(/ext .* is invalid/);
56
+ expect(() => pathForExistingBlob(dateBucket, fileId, ext as never, { uploadsRoot })).toThrow(/ext .* is invalid/);
57
+ expect(validateIndexEntry({ file_id: fileId, date_bucket: dateBucket, ext, size: 0 })).toBe(false);
58
+ }
59
+ });
60
+ });
@@ -0,0 +1,131 @@
1
+ // Task #25 — regression pins for ext validation in validateIndexEntry.
2
+ //
3
+ // 🔴 SCOPE: this suite exercises the stored-index boundary through
4
+ // `validateIndexEntry`. Issue #527 removed the three independent copies
5
+ // of the stored extension-token grammar: buildStoragePath,
6
+ // pathForExistingBlob and validateIndexEntry now use one shared private
7
+ // validator. `ext-token-shared.test.ts` separately pins all three call
8
+ // sites. sanitizeExt remains intentionally independent because it parses
9
+ // a client filename rather than validating an already-extracted token.
10
+ //
11
+ // Background (通信龙 d01bb8ce): the stored extension-token grammar
12
+ // `/^\.[A-Za-z0-9]{1,16}$/` correctly rejects 11 known malicious ext
13
+ // values (path-escape, traversal, absolute paths, doubles, NUL bytes,
14
+ // backslash forms, over-length, whitespace). Verified via real-run on
15
+ // origin/main by lead — 11/11 malicious values → false.
16
+ //
17
+ // But NO test locked that behavior. Anyone who widens the regex in the
18
+ // future (accidentally or on purpose) would not hit a red test — the
19
+ // live protection would degrade silently. This suite pins current
20
+ // behavior in two directions:
21
+ //
22
+ // 1. SAFETY (欠修 direction) — 11 malicious values → false, one row
23
+ // per value with the exact literal. If regex is widened to accept
24
+ // any of them, that row turns red.
25
+ // 2. NON-REGRESSION (过修 direction) — a set of common legitimate
26
+ // extensions → true. If regex is tightened to reject any of them
27
+ // (e.g. someone locks down to a whitelist), that row turns red.
28
+ //
29
+ // Witnessed-red for the SAFETY rows: temporarily widen the regex to
30
+ // `/./` (or comment it out) in validateIndexEntry, run this suite,
31
+ // confirm all 11 malicious rows turn red, then revert. The mutation
32
+ // itself is not committed; the assertion IS the mutation-red proof
33
+ // for the reader. Recording the mutation output in the followup PR
34
+ // body (per Constraint 3 discipline established in #503).
35
+ //
36
+ // Pure unit test — no server setup, no DB, no filesystem. The regex
37
+ // lives in a pure function, so the guard is testable at that level.
38
+
39
+ import { describe, expect, test } from "bun:test";
40
+ import { validateIndexEntry } from "./uploads.js";
41
+
42
+ // A structurally-valid base entry — file_id / date_bucket / size all
43
+ // pass their own checks — so `ext` is the only field a test row varies.
44
+ // This isolates the ext regex as the deciding factor.
45
+ const validBase = {
46
+ file_id: "0123456789abcdef0123456789abcdef",
47
+ date_bucket: "2026-07-30",
48
+ size: 0,
49
+ };
50
+
51
+ describe("Task #25 (#503 followup) — ext validation regression pins", () => {
52
+ describe("SAFETY: the 11 malicious ext values from lead d01bb8ce all return false", () => {
53
+ // Each row uses the exact literal from lead's verification list so
54
+ // a widening regex catches on the same value the manual audit did.
55
+ const malicious: Array<{ label: string; ext: string }> = [
56
+ { label: "path-escape prefix", ext: "../x" },
57
+ { label: "deep traversal to /etc", ext: "../../etc/passwd" },
58
+ { label: "absolute path", ext: "/abs/x" },
59
+ { label: "double-dot alone", ext: ".." },
60
+ { label: "single-dot alone", ext: "." },
61
+ { label: "chained after legit prefix", ext: ".png/../../y" },
62
+ { label: "embedded whitespace", ext: ".p ng" },
63
+ { label: "over-length (>16 chars)", ext: "." + "a".repeat(17) },
64
+ { label: "embedded NUL byte", ext: ".p\x00ng" },
65
+ { label: "backslash traversal", ext: ".\\..\\x" },
66
+ { label: "double-backslash prefix", ext: "..\\x" },
67
+ ];
68
+ for (const { label, ext } of malicious) {
69
+ test(`rejects: ${label} — ext=${JSON.stringify(ext)}`, () => {
70
+ expect(validateIndexEntry({ ...validBase, ext })).toBe(false);
71
+ });
72
+ }
73
+
74
+ test("precondition: 11 malicious cases enumerated (defends against silent test-shrink)", () => {
75
+ // If someone deletes rows from the malicious list, this fails
76
+ // instead of the deletion silently narrowing the guard. Number
77
+ // matches lead's verification list — bump only when lead adds a
78
+ // case and confirms it via real-run.
79
+ expect(malicious.length).toBe(11);
80
+ });
81
+ });
82
+
83
+ describe("NON-REGRESSION: common legitimate extensions all return true (防过修)", () => {
84
+ // These are the extensions the live upload path actually produces
85
+ // via sanitizeExt / mime-typed multipart uploads. If someone
86
+ // tightens the regex to a stricter whitelist, any of these turning
87
+ // red flags a break in a normal user path.
88
+ const legal = [".png", ".pdf", ".pptx", ".jpg", ".jpeg", ".txt", ".mp4", ".zip", ".json", ".md"];
89
+ for (const ext of legal) {
90
+ test(`accepts: ext=${JSON.stringify(ext)}`, () => {
91
+ expect(validateIndexEntry({ ...validBase, ext })).toBe(true);
92
+ });
93
+ }
94
+
95
+ test("empty ext is accepted (file with no extension is a legitimate upload)", () => {
96
+ expect(validateIndexEntry({ ...validBase, ext: "" })).toBe(true);
97
+ });
98
+
99
+ test("precondition: at least 10 legitimate ext values pinned", () => {
100
+ // Same discipline as the malicious side — reject silent shrink.
101
+ expect(legal.length).toBeGreaterThanOrEqual(10);
102
+ });
103
+ });
104
+
105
+ describe("regex shape assumptions (documented, not enforced by the function)", () => {
106
+ // These rows document facts the current regex enforces beyond the
107
+ // 11 malicious literals. They are close to the boundary and would
108
+ // be the first things to move if someone reworked the regex.
109
+ test("upper-case extensions accepted (regex is case-insensitive by [A-Za-z])", () => {
110
+ expect(validateIndexEntry({ ...validBase, ext: ".PNG" })).toBe(true);
111
+ });
112
+ test("mixed-case extensions accepted", () => {
113
+ expect(validateIndexEntry({ ...validBase, ext: ".PdF" })).toBe(true);
114
+ });
115
+ test("digits after dot accepted (e.g. .mp4, .mp3)", () => {
116
+ expect(validateIndexEntry({ ...validBase, ext: ".mp4" })).toBe(true);
117
+ });
118
+ test("exactly 16 chars after dot is accepted (upper bound of {1,16})", () => {
119
+ expect(validateIndexEntry({ ...validBase, ext: "." + "a".repeat(16) })).toBe(true);
120
+ });
121
+ test("exactly 17 chars after dot is rejected (immediately past upper bound)", () => {
122
+ expect(validateIndexEntry({ ...validBase, ext: "." + "a".repeat(17) })).toBe(false);
123
+ });
124
+ test("compound extensions like .tar.gz are rejected (contains inner dot)", () => {
125
+ // Documented: the current regex does not support compound
126
+ // extensions. If we ever want .tar.gz support, it needs its
127
+ // own explicit design + this row updated with lead sign-off.
128
+ expect(validateIndexEntry({ ...validBase, ext: ".tar.gz" })).toBe(false);
129
+ });
130
+ });
131
+ });