@sleep2agi/commhub-server 0.9.0-preview.8 → 0.9.0-preview.80

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 (202) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +92 -11
  3. package/package.json +5 -5
  4. package/src/ack-create-request-transport.test.ts +159 -0
  5. package/src/ack-create-request.test.ts +5 -5
  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 +478 -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/alias-filter.test.ts +72 -0
  15. package/src/alias-filter.ts +32 -0
  16. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  17. package/src/api-host-supervisors-fallback.test.ts +271 -0
  18. package/src/api-nodes-shape.test.ts +26 -10
  19. package/src/auth-sessions-http.test.ts +250 -0
  20. package/src/auth-validate.test.ts +5 -1
  21. package/src/auth.ts +450 -31
  22. package/src/avatar-validate.test.ts +163 -0
  23. package/src/avatar-validate.ts +103 -0
  24. package/src/blocked-exit-on-reply.test.ts +122 -0
  25. package/src/boot-side-effect.test.ts +124 -0
  26. package/src/commhub-bin-args.test.ts +85 -0
  27. package/src/config-apply-sec1.test.ts +107 -0
  28. package/src/config-apply-validate.test.ts +92 -0
  29. package/src/config-apply-validate.ts +81 -3
  30. package/src/create-node-blocked-gate.test.ts +356 -0
  31. package/src/create-node-tool-schema.test.ts +39 -0
  32. package/src/create-node-validate.test.ts +89 -1
  33. package/src/create-node-validate.ts +89 -7
  34. package/src/create-node-workdir.test.ts +255 -0
  35. package/src/create-node.ts +46 -1
  36. package/src/dashboard-slash-routing-http.test.ts +107 -0
  37. package/src/db-adapter-guard.test.ts +148 -0
  38. package/src/db-adapter-translate.test.ts +128 -0
  39. package/src/db-adapter.ts +323 -87
  40. package/src/db-timestamp.test.ts +38 -0
  41. package/src/db-timestamp.ts +15 -0
  42. package/src/db.ts +805 -33
  43. package/src/delivered-without-push.test.ts +140 -0
  44. package/src/desktop-message-seam.test.ts +150 -0
  45. package/src/desktop-message.test.ts +368 -0
  46. package/src/dm-file-scope-http.test.ts +178 -0
  47. package/src/ext-token-shared.test.ts +60 -0
  48. package/src/ext-validation-regression.test.ts +131 -0
  49. package/src/external-schedule-edits-http.test.ts +321 -0
  50. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  51. package/src/external-schedule-edits.ts +327 -0
  52. package/src/file-download-authz-dev-open.test.ts +135 -0
  53. package/src/file-download-authz.test.ts +404 -0
  54. package/src/file-download-date-bucket.test.ts +436 -0
  55. package/src/file-network-scope-dev-open.test.ts +97 -0
  56. package/src/file-network-scope.test.ts +732 -0
  57. package/src/health-redaction.test.ts +642 -0
  58. package/src/http-gzip.test.ts +94 -0
  59. package/src/http-gzip.ts +110 -0
  60. package/src/hub-timestamp.test.ts +38 -0
  61. package/src/hub-timestamp.ts +27 -0
  62. package/src/human-dm.ts +215 -0
  63. package/src/inbox-count-sse.test.ts +310 -0
  64. package/src/inbox-count.ts +29 -0
  65. package/src/index.ts +15 -2331
  66. package/src/lifecycle-guard.ts +50 -0
  67. package/src/mcp-route-label-http.test.ts +120 -0
  68. package/src/mcp-write-network-resolution.test.ts +429 -0
  69. package/src/member-audit-network-id-http.test.ts +93 -0
  70. package/src/member-presence-http.test.ts +206 -0
  71. package/src/messages-ack-by-agent-http.test.ts +142 -0
  72. package/src/migrate-sqlite-to-pg.test.ts +47 -0
  73. package/src/migrate-sqlite-to-pg.ts +263 -0
  74. package/src/network-name-http.test.ts +75 -0
  75. package/src/network-scope.ts +224 -0
  76. package/src/node-attrs-validate.ts +88 -0
  77. package/src/node-attrs.test.ts +217 -0
  78. package/src/node-create-request-status-http.test.ts +92 -0
  79. package/src/node-files-transport.test.ts +344 -0
  80. package/src/node-id-alias.test.ts +188 -0
  81. package/src/node-lifecycle-controllable.test.ts +64 -0
  82. package/src/node-lifecycle-controllable.ts +51 -0
  83. package/src/node-logs-transport.test.ts +263 -0
  84. package/src/node-request-retention.test.ts +281 -0
  85. package/src/node-request-retention.ts +120 -0
  86. package/src/node-skills-transport.test.ts +209 -0
  87. package/src/node-token-privilege-http.test.ts +149 -0
  88. package/src/observer-avatar-http.test.ts +429 -0
  89. package/src/observer-push.test.ts +252 -0
  90. package/src/package-version-consistency.test.ts +25 -0
  91. package/src/peer-reply-atomic.test.ts +455 -0
  92. package/src/peer-reply-capability.test.ts +70 -0
  93. package/src/pg-worker.ts +87 -0
  94. package/src/push.ts +284 -7
  95. package/src/redact-tokens.test.ts +65 -0
  96. package/src/redact-tokens.ts +40 -0
  97. package/src/rename.ts +7 -2
  98. package/src/reply-target-derivation.test.ts +96 -0
  99. package/src/report-status-host-schema.test.ts +211 -0
  100. package/src/report-status-resume-id-handover.test.ts +150 -0
  101. package/src/require-explicit-test-db.ts +9 -0
  102. package/src/requirement-events-http.test.ts +257 -0
  103. package/src/requirement-events.test.ts +45 -0
  104. package/src/requirement-events.ts +143 -0
  105. package/src/requirement-notify.ts +134 -0
  106. package/src/requirement-participant-notify-http.test.ts +309 -0
  107. package/src/requirement-tag-ops-http.test.ts +266 -0
  108. package/src/requirement-tags.ts +89 -0
  109. package/src/requirements-completed-at-migration.test.ts +70 -0
  110. package/src/requirements-etag-http.test.ts +91 -0
  111. package/src/requirements-http.test.ts +709 -0
  112. package/src/requirements-list-slim-http.test.ts +315 -0
  113. package/src/requirements-mcp.test.ts +163 -0
  114. package/src/requirements-migrate.ts +225 -0
  115. package/src/requirements-people-http.test.ts +114 -0
  116. package/src/requirements-priority-lowest.test.ts +114 -0
  117. package/src/requirements-search-http.test.ts +182 -0
  118. package/src/requirements-search.test.ts +32 -0
  119. package/src/requirements-search.ts +138 -0
  120. package/src/requirements-seq-http.test.ts +193 -0
  121. package/src/requirements-seq-migration.test.ts +63 -0
  122. package/src/requirements-start-date.test.ts +125 -0
  123. package/src/requirements-stats-http.test.ts +288 -0
  124. package/src/requirements-stats.ts +164 -0
  125. package/src/requirements-upgrade-e2e.test.ts +126 -0
  126. package/src/requirements-upgrade-migration.test.ts +98 -0
  127. package/src/requirements.ts +1439 -0
  128. package/src/resolve-port.test.ts +57 -0
  129. package/src/resolve-port.ts +44 -0
  130. package/src/response-charset.test.ts +130 -0
  131. package/src/rest-explicit-columns-http.test.ts +216 -0
  132. package/src/rest-identity.test.ts +129 -0
  133. package/src/rest-identity.ts +100 -0
  134. package/src/rest-projections.ts +68 -0
  135. package/src/rest-write-network-resolution.test.ts +133 -0
  136. package/src/restricted-files.ts +73 -0
  137. package/src/retention.ts +15 -3
  138. package/src/route-callers-http.test.ts +120 -0
  139. package/src/route-timing.test.ts +211 -0
  140. package/src/route-timing.ts +258 -0
  141. package/src/rules-file-duplicate-alias.test.ts +234 -0
  142. package/src/rules-file-session-target.test.ts +207 -0
  143. package/src/rules-file-transport.test.ts +237 -0
  144. package/src/runtime-label.test.ts +22 -0
  145. package/src/runtime-label.ts +21 -0
  146. package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
  147. package/src/scheduled-reply-unread-http.test.ts +212 -0
  148. package/src/scheduled-run-terminal.test.ts +268 -0
  149. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  150. package/src/scheduled-tasks-http.test.ts +638 -0
  151. package/src/scheduled-tasks.ts +597 -0
  152. package/src/send-dedup-request-id.test.ts +229 -0
  153. package/src/send-reply-agent-warning.test.ts +202 -0
  154. package/src/send-reply-alias-optional.test.ts +131 -0
  155. package/src/send-reply-attachments.test.ts +458 -0
  156. package/src/send_dedup.test.ts +54 -0
  157. package/src/send_dedup.ts +52 -6
  158. package/src/serve-error.test.ts +38 -0
  159. package/src/serve-error.ts +27 -0
  160. package/src/server.ts +4522 -0
  161. package/src/shared/external-schedule-contract.ts +60 -0
  162. package/src/side-thread-command-transport.test.ts +312 -0
  163. package/src/side-thread-command-transport.ts +318 -0
  164. package/src/side-thread-http-integration.test.ts +233 -0
  165. package/src/side-thread-http.ts +504 -0
  166. package/src/side-thread-production.test.ts +72 -0
  167. package/src/side-thread-production.ts +118 -0
  168. package/src/side-thread.test.ts +1067 -0
  169. package/src/side-thread.ts +2499 -0
  170. package/src/skillhub-http.test.ts +108 -0
  171. package/src/sqlite-close-checkpoint.test.ts +78 -0
  172. package/src/start-node.test.ts +109 -0
  173. package/src/status-alias-filter-http.test.ts +106 -0
  174. package/src/status-node-id-filter-http.test.ts +161 -0
  175. package/src/stop-delete-node.test.ts +1164 -0
  176. package/src/task-access-http.test.ts +379 -0
  177. package/src/task-access.ts +307 -0
  178. package/src/task-auth-origin-http.test.ts +91 -0
  179. package/src/task-auth-origin.test.ts +24 -0
  180. package/src/task-auth-origin.ts +7 -0
  181. package/src/task-consumption.test.ts +482 -0
  182. package/src/task-diagnostic-http.test.ts +149 -0
  183. package/src/task-diagnostic.test.ts +115 -0
  184. package/src/task-diagnostic.ts +118 -0
  185. package/src/task-idempotency-mcp.test.ts +142 -0
  186. package/src/task-idempotency.test.ts +60 -0
  187. package/src/task-idempotency.ts +75 -0
  188. package/src/task-lifecycle-watcher.test.ts +204 -0
  189. package/src/task-lifecycle-watcher.ts +49 -0
  190. package/src/task-network-resolution-http.test.ts +141 -0
  191. package/src/tasks-pagination-http.test.ts +141 -0
  192. package/src/tools-list-json-schema.test.ts +66 -0
  193. package/src/tools.ts +3534 -343
  194. package/src/update-provider.test.ts +65 -0
  195. package/src/upload-early-reject-guard.test.ts +155 -0
  196. package/src/uploads-http.test.ts +97 -15
  197. package/src/uploads.test.ts +66 -0
  198. package/src/uploads.ts +138 -5
  199. package/src/user-inbox-by-agent-http.test.ts +161 -0
  200. package/src/user-inbox-netid-notnull.test.ts +122 -0
  201. package/src/user-inbox-read-http.test.ts +223 -0
  202. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,404 @@
1
+ // #495 — file download authorization (P0 staged hotfix, PR #500).
2
+ //
3
+ // Before this fix, /api/files/:file_id only checked authentication.
4
+ // Any authenticated principal could download any file cross-network by
5
+ // guessing / obtaining a file_id.
6
+ //
7
+ // This suite spawns a real Bun.serve hub in-process and validates the
8
+ // allow-list (owner + admin + legacy-master + null-owner-DEV_OPEN).
9
+ // Same-network non-owner is EXPLICITLY out of scope in this stage —
10
+ // see PR body access matrix + follow-up #503. That "same-network peer
11
+ // returns 404" is an intentional carve-out here, not a bug.
12
+ //
13
+ // The DEV_OPEN branch is verified in a sibling test file
14
+ // (file-download-authz-dev-open.test.ts) because DEV_OPEN is captured
15
+ // at server.ts module-load time and can't be flipped mid-suite.
16
+ //
17
+ // Skip discipline: master-token tests use `test.skipIf(!masterTokenActive)`
18
+ // so aggregate `bun test src/` reports "skipped" (not "passed") when
19
+ // the loaded server module's frozen AUTH_TOKEN binding predates our
20
+ // env setup. `if (!x) return` was previously used and Bun records
21
+ // that as PASS — which is exactly the "skip-as-pass" trap that let
22
+ // an earlier round claim coverage it never had. (See project memory
23
+ // `feedback_skip_via_early_return_shows_as_pass`.)
24
+
25
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
26
+ import { mkdtempSync, rmSync, writeFileSync, existsSync } from "fs";
27
+ import { join } from "path";
28
+ import { tmpdir } from "os";
29
+
30
+ const SERVER_DB = mkdtempSync(join(tmpdir(), "anet-495-db-")) + "/commhub.db";
31
+ const UPLOADS_DIR = mkdtempSync(join(tmpdir(), "anet-495-fs-"));
32
+ const MASTER_TOKEN = process.env.COMMHUB_AUTH_TOKEN ?? `master-495-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
33
+
34
+ // Set env BEFORE importing anything that pulls in db-adapter.ts (which
35
+ // refuses to open a default DB under NODE_ENV=test) or server.ts (which
36
+ // captures DEV_OPEN + AUTH_TOKEN at module load).
37
+ process.env.COMMHUB_DB ||= SERVER_DB;
38
+ process.env.COMMHUB_UPLOADS_DIR = UPLOADS_DIR;
39
+ process.env.HOST = "127.0.0.1";
40
+ process.env.COMMHUB_AUTH_TOKEN = MASTER_TOKEN;
41
+ delete process.env.COMMHUB_DEV_OPEN;
42
+
43
+ const { register, addNetworkMember, getUserAllNetworks } = await import("./auth.js");
44
+ const { db } = await import("./db.js");
45
+
46
+ // Seed accounts. register()'s "first-user-is-admin" is not reliable in
47
+ // aggregate (earlier test files may seed users), so we promote via SQL.
48
+ const adminName = `admin_495_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
49
+ const rAdmin = register(adminName, "BootstrapPw123Aa!", undefined, "seed");
50
+ if (!rAdmin.ok) throw new Error(`admin register failed: ${rAdmin.error}`);
51
+ const adminToken = rAdmin.token!;
52
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [rAdmin.user!.user_id]);
53
+
54
+ const nameA = `useraaa_495_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
55
+ const rA = register(nameA, "BootstrapPw123Aa!", undefined, "userA");
56
+ if (!rA.ok) throw new Error(`userA register failed: ${rA.error}`);
57
+ const userAToken = rA.token!;
58
+ const userAUserId = rA.user!.user_id;
59
+ const userANetworkId = rA.network_id!;
60
+
61
+ // #503 — userA joins userB's network below, which makes userA a
62
+ // SAME-network peer. Cross-network denial needs a principal who is in
63
+ // neither, so userC exists purely to be an outsider.
64
+ const nameC = `userccc_495_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
65
+ const rC = register(nameC, "BootstrapPw123Aa!", undefined, "userC");
66
+ if (!rC.ok) throw new Error(`userC register failed: ${rC.error}`);
67
+ const userCToken = rC.token!;
68
+ const userCUserId = rC.user!.user_id;
69
+
70
+ const nameB = `userbbb_495_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
71
+ const rB = register(nameB, "BootstrapPw123Aa!", undefined, "userB");
72
+ if (!rB.ok) throw new Error(`userB register failed: ${rB.error}`);
73
+ const userBToken = rB.token!;
74
+ const userBUserId = rB.user!.user_id;
75
+ const userBNetworkToken = rB.network_token!;
76
+ const userBNetworkId = getUserAllNetworks(userBUserId)[0]?.network_id!;
77
+ if (!userBNetworkId) throw new Error("userB default network not found");
78
+
79
+ // #500 CR2 DEV_OPEN 假门 fix — assert userB is NOT admin at fixture
80
+ // setup time. If fixture ordering ever changes (e.g. auth.ts flips
81
+ // "first user auto-admin" logic, or an earlier test file promotes
82
+ // userB), the assertion catches it before Test A/B can silently
83
+ // short-circuit through the admin branch of authorizeFileDownload
84
+ // and hide a broken DEV_OPEN gate. Independent审员 caught this
85
+ // class of假门 in the prior CR round — the assertion writes the
86
+ // "userB is non-admin" invariant into the test itself so a future
87
+ // fixture drift cannot re-open the hole.
88
+ {
89
+ const userBRoleRow = db.get<{ role: string }>(
90
+ "SELECT role FROM users WHERE user_id = ?1",
91
+ userBUserId,
92
+ );
93
+ if (!userBRoleRow) throw new Error("userB not found in users table after register");
94
+ if (userBRoleRow.role === "admin") {
95
+ throw new Error(
96
+ `[#500 CR2 fixture-drift guard] userB was auto-promoted to admin ` +
97
+ `(role=${userBRoleRow.role}). Refusing to run: the DEV_OPEN and ` +
98
+ `cross-owner tests would short-circuit through the admin allow-list ` +
99
+ `branch and validate nothing.`,
100
+ );
101
+ }
102
+ }
103
+
104
+ // Add userA as a member of userB's default network. This is what makes
105
+ // the "same-network non-owner" test cell meaningful — without it, that
106
+ // test would only prove cross-network denial, which is a weaker claim.
107
+ // 多用户 Agent 权限:新成员默认只看授权 Agent;本测试测的是「完全信任的成员」旧语义,显式 agent_access=all。
108
+ const addMember = addNetworkMember(userBNetworkId, userAUserId, "member", userBUserId, { agentAccess: "all" });
109
+ if (!addMember.ok) throw new Error(`add userA to userB network failed: ${addMember.error}`);
110
+
111
+ const { bootServer } = await import("./server.js");
112
+ const server = bootServer({ port: 0, hostname: "127.0.0.1" });
113
+ const BASE = `http://127.0.0.1:${server.port}`;
114
+ await new Promise((r) => setTimeout(r, 100));
115
+
116
+ // Probe the loaded server's frozen AUTH_TOKEN binding. If master token
117
+ // works we can exercise the legacy branch; if not (aggregate load-order
118
+ // effect), `test.skipIf` reports "skipped" (NOT "passed").
119
+ const _probeMaster = await fetch(`${BASE}/api/files/00000000000000000000000000000000`, {
120
+ headers: { Authorization: `Bearer ${MASTER_TOKEN}` },
121
+ });
122
+ const masterTokenActive = _probeMaster.status === 404;
123
+
124
+ // Probe the loaded server's frozen DEV_OPEN binding. This file is
125
+ // meant to run against a DEV_OPEN=false server. In aggregate mode,
126
+ // if file-download-authz-dev-open.test.ts loaded server.ts first
127
+ // with DEV_OPEN=true, the singleton wins — we can't flip it. Skip
128
+ // the whole production-mode suite via `describe.skipIf` (NOT
129
+ // early-return in each test body) so the runner reports "skipped".
130
+ async function _uploadProbe(): Promise<number> {
131
+ const form = new FormData();
132
+ form.append("file", new Blob([new TextEncoder().encode("p")], { type: "application/octet-stream" }), "p");
133
+ const r = await fetch(`${BASE}/api/upload`, { method: "POST", body: form });
134
+ return r.status;
135
+ }
136
+ const devOpenSuspected = (await _uploadProbe()) === 200;
137
+ const isProdMode = !devOpenSuspected;
138
+
139
+ afterAll(() => {
140
+ try { server?.stop?.(true); } catch {}
141
+ try { rmSync(UPLOADS_DIR, { recursive: true, force: true }); } catch {}
142
+ try { rmSync(SERVER_DB, { recursive: true, force: true }); } catch {}
143
+ delete process.env.COMMHUB_AUTH_TOKEN;
144
+ });
145
+
146
+ // #503 — a utok_ holder in more than one network must name the target
147
+ // network; the server refuses to guess ("first network" is not assumed).
148
+ // Callers using a single-network token can still omit it.
149
+ async function uploadAs(token: string, content: Uint8Array, filename: string, networkId?: string): Promise<string> {
150
+ const form = new FormData();
151
+ form.append("file", new Blob([content], { type: "application/octet-stream" }), filename);
152
+ const url = networkId
153
+ ? `${BASE}/api/upload?network_id=${encodeURIComponent(networkId)}`
154
+ : `${BASE}/api/upload`;
155
+ const res = await fetch(url, {
156
+ method: "POST",
157
+ body: form,
158
+ headers: { Authorization: `Bearer ${token}` },
159
+ });
160
+ expect(res.status).toBe(200);
161
+ const body: any = await res.json();
162
+ expect(typeof body.file_id).toBe("string");
163
+ return body.file_id;
164
+ }
165
+
166
+ async function downloadAs(token: string | null, fileId: string): Promise<Response> {
167
+ const headers: Record<string, string> = {};
168
+ if (token) headers.Authorization = `Bearer ${token}`;
169
+ return fetch(`${BASE}/api/files/${fileId}`, { headers });
170
+ }
171
+
172
+ async function downloadWithQueryToken(token: string, fileId: string): Promise<Response> {
173
+ return fetch(`${BASE}/api/files/${fileId}?token=${encodeURIComponent(token)}`);
174
+ }
175
+
176
+ describe.skipIf(!isProdMode)("#495 — GET /api/files/:file_id authorization (owner-only staged)", () => {
177
+ let userBFileId = "";
178
+
179
+ beforeAll(async () => {
180
+ userBFileId = await uploadAs(userBToken, new TextEncoder().encode("secret-from-B"), "b-secret.txt");
181
+ expect(userBFileId.length).toBe(32);
182
+ });
183
+
184
+ test("owner (userB) via utok_ downloads own file → 200 (normal path)", async () => {
185
+ const res = await downloadAs(userBToken, userBFileId);
186
+ expect(res.status).toBe(200);
187
+ expect(await res.text()).toBe("secret-from-B");
188
+ });
189
+
190
+ test("owner (userB) via ntok_ downloads own file → 200 (agent self-upload+self-download)", async () => {
191
+ const agentFileId = await uploadAs(userBNetworkToken, new TextEncoder().encode("agent-blob"), "agent.bin");
192
+ const res = await downloadAs(userBNetworkToken, agentFileId);
193
+ expect(res.status).toBe(200);
194
+ expect(await res.text()).toBe("agent-blob");
195
+ });
196
+
197
+ test("🔴 cross-network non-owner (userC) downloads userB's file → 404 (was 200 pre-#495)", async () => {
198
+ // Precondition — userC is in NEITHER of userB's networks, so the 404
199
+ // is cross-network denial. This row used to use userA, but #503 made
200
+ // userA a legitimate same-network reader; keeping userA here would
201
+ // have turned #495's guarantee into a test that asserts nothing.
202
+ const rows = db.all<{ network_id: string }>(
203
+ "SELECT network_id FROM network_members WHERE user_id = ?1 AND network_id = ?2",
204
+ userCUserId, userBNetworkId,
205
+ );
206
+ expect(rows.length).toBe(0);
207
+ const res = await downloadAs(userCToken, userBFileId);
208
+ expect(res.status).toBe(404);
209
+ const body: any = await res.json();
210
+ expect(body.error).toBe("not_found");
211
+ });
212
+
213
+ test("🔴 same-network non-owner (userA is a member of userB's network) → 200 (#503 lifted the #495 staged carve-out)", async () => {
214
+ // Precondition — userA really is a member of userB's default network
215
+ // (added in module setup), so the 200 represents "same-network peer
216
+ // allowed" rather than an owner match or an admin bypass.
217
+ const rows = db.all<{ network_id: string }>(
218
+ "SELECT network_id FROM network_members WHERE user_id = ?1 AND network_id = ?2",
219
+ userAUserId, userBNetworkId,
220
+ );
221
+ expect(rows.length).toBe(1);
222
+ expect(db.get<{ role: string }>("SELECT role FROM users WHERE user_id = ?1", userAUserId)?.role)
223
+ .not.toBe("admin");
224
+ const res = await downloadAs(userAToken, userBFileId);
225
+ expect(res.status).toBe(200);
226
+ expect(await res.text()).toBe("secret-from-B");
227
+ });
228
+
229
+ test("admin caller downloads any file → 200 (operational access preserved)", async () => {
230
+ const res = await downloadAs(adminToken, userBFileId);
231
+ expect(res.status).toBe(200);
232
+ expect(await res.text()).toBe("secret-from-B");
233
+ });
234
+
235
+ test.skipIf(!masterTokenActive)("legacy master AUTH_TOKEN downloads any file → 200 (single-tenant deployments)", async () => {
236
+ const res = await downloadAs(MASTER_TOKEN, userBFileId);
237
+ expect(res.status).toBe(200);
238
+ expect(await res.text()).toBe("secret-from-B");
239
+ });
240
+
241
+ test("anonymous request → 401 (auth still required)", async () => {
242
+ const res = await downloadAs(null, userBFileId);
243
+ expect(res.status).toBe(401);
244
+ });
245
+
246
+ test("🔴 valid query token is refused on file GET (credential must stay out of URLs)", async () => {
247
+ const res = await downloadWithQueryToken(userBToken, userBFileId);
248
+ expect(res.status).toBe(401);
249
+ const body: any = await res.json();
250
+ expect(body.error).toBe("unauthorized");
251
+ });
252
+
253
+ test("non-owner request for an UNKNOWN file_id → 404 with same shape as owner-denied (no enumeration signal)", async () => {
254
+ const res = await downloadAs(userAToken, "0123456789abcdef0123456789abcdef");
255
+ expect(res.status).toBe(404);
256
+ const body: any = await res.json();
257
+ expect(body.error).toBe("not_found");
258
+ });
259
+ });
260
+
261
+ // #500 CR2 ① — HEAD verb explicit authz coverage. Pre-CR2, HEAD fell
262
+ // through to a fallback 200 page; body was byte-identical to unknown
263
+ // file_id so no oracle formed by accident, but it was coincidence, not
264
+ // a gate. This suite asserts HEAD routes through authorizeFileDownload
265
+ // per RFC 9110 §9.3.2: same status/headers as GET, body omitted.
266
+ async function headAs(token: string | null, fileId: string): Promise<Response> {
267
+ const headers: Record<string, string> = {};
268
+ if (token) headers.Authorization = `Bearer ${token}`;
269
+ return fetch(`${BASE}/api/files/${fileId}`, { method: "HEAD", headers });
270
+ }
271
+
272
+ async function headWithQueryToken(token: string, fileId: string): Promise<Response> {
273
+ return fetch(`${BASE}/api/files/${fileId}?token=${encodeURIComponent(token)}`, { method: "HEAD" });
274
+ }
275
+
276
+ describe.skipIf(!isProdMode)("#500 CR2 — HEAD /api/files/:file_id authorization (parity with GET)", () => {
277
+ let userBFileId = "";
278
+
279
+ beforeAll(async () => {
280
+ userBFileId = await uploadAs(userBToken, new TextEncoder().encode("head-scenario-payload"), "b-head.txt");
281
+ });
282
+
283
+ test("owner (userB) HEAD own file → 200 + content-length header (body omitted per HEAD)", async () => {
284
+ const res = await headAs(userBToken, userBFileId);
285
+ expect(res.status).toBe(200);
286
+ expect(res.headers.get("content-length")).toBe(String("head-scenario-payload".length));
287
+ expect(res.headers.get("content-disposition")).toContain("attachment");
288
+ expect(res.headers.get("x-content-type-options")).toBe("nosniff");
289
+ });
290
+
291
+ test("🔴 cross-network HEAD (userC HEAD userB's file) → 404 via helper (was fallback 200 pre-CR2)", async () => {
292
+ // userC, not userA — #503 makes userA a same-network reader, so the
293
+ // GET/HEAD parity claim needs a principal who is genuinely denied.
294
+ const res = await headAs(userCToken, userBFileId);
295
+ expect(res.status).toBe(404);
296
+ // 404 must originate from authorizeFileDownload, not the fallback
297
+ // page. Body is application/json ({"ok":false,"error":"not_found"})
298
+ // — check Content-Type so a future fallback rewrite (e.g. adding
299
+ // "requested resource type: X") does not silently form an oracle
300
+ // while HEAD still returns 200 from that page.
301
+ expect(res.headers.get("content-type")?.toLowerCase()).toContain("application/json");
302
+ });
303
+
304
+ test("HEAD unknown file_id → 404 with same shape as cross-owner (no enumeration signal)", async () => {
305
+ const res = await headAs(userAToken, "0123456789abcdef0123456789abcdef");
306
+ expect(res.status).toBe(404);
307
+ expect(res.headers.get("content-type")?.toLowerCase()).toContain("application/json");
308
+ });
309
+
310
+ test("HEAD anonymous → 401 (auth still required, symmetric with GET)", async () => {
311
+ const res = await headAs(null, userBFileId);
312
+ expect(res.status).toBe(401);
313
+ });
314
+
315
+ test("🔴 valid query token is refused on file HEAD (same gate as GET)", async () => {
316
+ const res = await headWithQueryToken(userBToken, userBFileId);
317
+ expect(res.status).toBe(401);
318
+ });
319
+
320
+ test("admin HEAD any file → 200 (operational access preserved on HEAD)", async () => {
321
+ const res = await headAs(adminToken, userBFileId);
322
+ expect(res.status).toBe(200);
323
+ expect(res.headers.get("content-length")).toBe(String("head-scenario-payload".length));
324
+ });
325
+ });
326
+
327
+ // #500 CR2 ④ — Upload owner persistence. Prove that new authenticated
328
+ // uploads always write a truthy owner_id to the on-disk index entry.
329
+ // If the upload handler ever regresses to writing owner_id=null in
330
+ // requireAuth-guarded paths, D1 turns red — the invariant that
331
+ // "production uploads never produce null-owner blobs" is documented
332
+ // in the authorizeFileDownload comment and this test enforces it.
333
+ describe.skipIf(!isProdMode)("#500 CR2 — upload persists truthy owner_id (D1)", () => {
334
+ test("D1: userA authenticated upload → index entry owner_id === userA.userId (never null)", async () => {
335
+ // userA belongs to two networks (own default + userB's), so #503
336
+ // requires the target network to be named explicitly.
337
+ const fileId = await uploadAs(userAToken, new TextEncoder().encode("owner-persistence-check"), "d1.bin", userANetworkId);
338
+ const { readFileSync } = await import("fs");
339
+ const entryPath = join(UPLOADS_DIR, ".index", `${fileId}.json`);
340
+ expect(existsSync(entryPath)).toBe(true);
341
+ const entry = JSON.parse(readFileSync(entryPath, "utf-8"));
342
+ expect(entry.owner_id).not.toBeNull();
343
+ expect(entry.owner_id).toBe(userAUserId);
344
+ expect(entry.owner).toBe(nameA); // owner username stays consistent with the token that uploaded
345
+ });
346
+
347
+ test("D1b: userB authenticated upload via utok_ → owner_id === userB.userId", async () => {
348
+ const fileId = await uploadAs(userBToken, new TextEncoder().encode("owner-persistence-check-b"), "d1b.bin");
349
+ const { readFileSync } = await import("fs");
350
+ const entryPath = join(UPLOADS_DIR, ".index", `${fileId}.json`);
351
+ expect(existsSync(entryPath)).toBe(true);
352
+ const entry = JSON.parse(readFileSync(entryPath, "utf-8"));
353
+ expect(entry.owner_id).not.toBeNull();
354
+ expect(entry.owner_id).toBe(userBUserId);
355
+ });
356
+ });
357
+
358
+ describe.skipIf(!isProdMode)("#495 — null-owner policy in production (DEV_OPEN=off, non-admin/non-legacy)", () => {
359
+ let nullOwnerFileId = "";
360
+
361
+ beforeAll(async () => {
362
+ // Simulate a legacy null-owner index entry by uploading normally
363
+ // then rewriting the on-disk index to owner_id=null. This precisely
364
+ // reproduces the shape a DEV_OPEN or legacy master upload would
365
+ // create, without needing to spin up a second server in a
366
+ // different mode.
367
+ nullOwnerFileId = await uploadAs(userAToken, new TextEncoder().encode("legacy-blob"), "legacy.txt", userANetworkId);
368
+ const { readFileSync: rfs, writeFileSync: wfs } = await import("fs");
369
+ const entryFile = join(UPLOADS_DIR, ".index", `${nullOwnerFileId}.json`);
370
+ if (!existsSync(entryFile)) throw new Error(`index entry not found: ${entryFile}`);
371
+ const entry = JSON.parse(rfs(entryFile, "utf-8"));
372
+ entry.owner = null;
373
+ entry.owner_id = null;
374
+ // #503 — a true legacy entry predates network attribution entirely.
375
+ // Deleting the key (rather than nulling it) matches what the writer
376
+ // emits when there is no attribution, so this fixture exercises the
377
+ // legacy compatibility branch instead of the network branch.
378
+ delete entry.network_id;
379
+ wfs(entryFile, JSON.stringify(entry, null, 2));
380
+ });
381
+
382
+ test.skipIf(!masterTokenActive)("null-owner + legacy master → 200 (RFC-001 read-only backward-compat)", async () => {
383
+ const res = await downloadAs(MASTER_TOKEN, nullOwnerFileId);
384
+ expect(res.status).toBe(200);
385
+ });
386
+
387
+ test("null-owner + admin utok_ → 200 (operational access preserved)", async () => {
388
+ const res = await downloadAs(adminToken, nullOwnerFileId);
389
+ expect(res.status).toBe(200);
390
+ });
391
+
392
+ test("🔴 Test A (production default) — null-owner + normal user + DEV_OPEN off → 404 fail-closed", async () => {
393
+ // Runtime invariant: this test file explicitly deletes COMMHUB_DEV_OPEN
394
+ // at module setup, so the DEV_OPEN branch in authorizeFileDownload
395
+ // MUST NOT execute here. If someone weakens the guard so that
396
+ // null-owner is granted even without DEV_OPEN, this assertion turns
397
+ // red. That IS the mutation self-check for the production carve-out.
398
+ expect(process.env.COMMHUB_DEV_OPEN).toBeUndefined();
399
+ const res = await downloadAs(userAToken, nullOwnerFileId);
400
+ expect(res.status).toBe(404);
401
+ const body: any = await res.json();
402
+ expect(body.error).toBe("not_found");
403
+ });
404
+ });