@sleep2agi/commhub-server 0.9.0-preview.7 → 0.9.0-preview.71

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 (170) hide show
  1. package/README.md +130 -10
  2. package/bin/commhub.ts +10 -2
  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 +331 -0
  6. package/src/admin-delete-network-http.test.ts +177 -0
  7. package/src/admin-networks-http.test.ts +88 -0
  8. package/src/agent-access.ts +461 -0
  9. package/src/agent-acl-groups-http.test.ts +247 -0
  10. package/src/agent-acl-http.test.ts +510 -0
  11. package/src/agent-acl-slice3-http.test.ts +267 -0
  12. package/src/agent-groups-perf.test.ts +167 -0
  13. package/src/alias-filter.test.ts +72 -0
  14. package/src/alias-filter.ts +32 -0
  15. package/src/api-host-supervisors-can-create-nodes.test.ts +350 -0
  16. package/src/api-host-supervisors-fallback.test.ts +271 -0
  17. package/src/api-nodes-shape.test.ts +26 -10
  18. package/src/auth-sessions-http.test.ts +250 -0
  19. package/src/auth-validate.test.ts +5 -1
  20. package/src/auth.ts +423 -29
  21. package/src/avatar-validate.test.ts +163 -0
  22. package/src/avatar-validate.ts +103 -0
  23. package/src/blocked-exit-on-reply.test.ts +122 -0
  24. package/src/boot-side-effect.test.ts +124 -0
  25. package/src/config-apply-sec1.test.ts +107 -0
  26. package/src/config-apply-validate.test.ts +92 -0
  27. package/src/config-apply-validate.ts +81 -3
  28. package/src/create-node-blocked-gate.test.ts +356 -0
  29. package/src/create-node-tool-schema.test.ts +39 -0
  30. package/src/create-node-validate.test.ts +89 -1
  31. package/src/create-node-validate.ts +89 -7
  32. package/src/create-node-workdir.test.ts +255 -0
  33. package/src/create-node.ts +51 -1
  34. package/src/dashboard-slash-routing-http.test.ts +107 -0
  35. package/src/db-adapter-guard.test.ts +85 -0
  36. package/src/db-adapter-translate.test.ts +112 -0
  37. package/src/db-adapter.ts +246 -83
  38. package/src/db-timestamp.test.ts +38 -0
  39. package/src/db-timestamp.ts +15 -0
  40. package/src/db.ts +799 -33
  41. package/src/delivered-without-push.test.ts +140 -0
  42. package/src/desktop-message-seam.test.ts +150 -0
  43. package/src/desktop-message.test.ts +368 -0
  44. package/src/ext-token-shared.test.ts +60 -0
  45. package/src/ext-validation-regression.test.ts +131 -0
  46. package/src/external-schedule-edits-http.test.ts +321 -0
  47. package/src/external-schedule-edits-timestamps.test.ts +64 -0
  48. package/src/external-schedule-edits.ts +327 -0
  49. package/src/file-download-authz-dev-open.test.ts +135 -0
  50. package/src/file-download-authz.test.ts +404 -0
  51. package/src/file-download-date-bucket.test.ts +436 -0
  52. package/src/file-network-scope-dev-open.test.ts +97 -0
  53. package/src/file-network-scope.test.ts +732 -0
  54. package/src/health-redaction.test.ts +642 -0
  55. package/src/http-gzip.test.ts +73 -0
  56. package/src/http-gzip.ts +60 -0
  57. package/src/hub-timestamp.test.ts +38 -0
  58. package/src/hub-timestamp.ts +27 -0
  59. package/src/human-dm.ts +149 -0
  60. package/src/inbox-count-sse.test.ts +310 -0
  61. package/src/inbox-count.ts +29 -0
  62. package/src/index.ts +15 -2331
  63. package/src/lifecycle-guard.ts +50 -0
  64. package/src/mcp-write-network-resolution.test.ts +429 -0
  65. package/src/messages-ack-by-agent-http.test.ts +142 -0
  66. package/src/network-name-http.test.ts +75 -0
  67. package/src/network-scope.ts +224 -0
  68. package/src/node-attrs-validate.ts +88 -0
  69. package/src/node-attrs.test.ts +217 -0
  70. package/src/node-create-request-status-http.test.ts +92 -0
  71. package/src/node-files-transport.test.ts +344 -0
  72. package/src/node-id-alias.test.ts +188 -0
  73. package/src/node-lifecycle-controllable.test.ts +64 -0
  74. package/src/node-lifecycle-controllable.ts +51 -0
  75. package/src/node-logs-transport.test.ts +263 -0
  76. package/src/node-request-retention.test.ts +281 -0
  77. package/src/node-request-retention.ts +120 -0
  78. package/src/node-skills-transport.test.ts +209 -0
  79. package/src/node-token-privilege-http.test.ts +149 -0
  80. package/src/observer-avatar-http.test.ts +429 -0
  81. package/src/observer-push.test.ts +252 -0
  82. package/src/package-version-consistency.test.ts +25 -0
  83. package/src/peer-reply-atomic.test.ts +455 -0
  84. package/src/peer-reply-capability.test.ts +70 -0
  85. package/src/pg-worker.ts +87 -0
  86. package/src/push.ts +204 -7
  87. package/src/redact-tokens.test.ts +65 -0
  88. package/src/redact-tokens.ts +40 -0
  89. package/src/rename.ts +7 -2
  90. package/src/reply-target-derivation.test.ts +96 -0
  91. package/src/report-status-host-schema.test.ts +211 -0
  92. package/src/report-status-resume-id-handover.test.ts +150 -0
  93. package/src/require-explicit-test-db.ts +9 -0
  94. package/src/requirement-tags.ts +16 -0
  95. package/src/requirements-etag-http.test.ts +91 -0
  96. package/src/requirements-http.test.ts +708 -0
  97. package/src/requirements-mcp.test.ts +142 -0
  98. package/src/requirements-migrate.ts +83 -0
  99. package/src/requirements-priority-lowest.test.ts +114 -0
  100. package/src/requirements-start-date.test.ts +125 -0
  101. package/src/requirements-upgrade-e2e.test.ts +126 -0
  102. package/src/requirements-upgrade-migration.test.ts +85 -0
  103. package/src/requirements.ts +899 -0
  104. package/src/resolve-port.test.ts +57 -0
  105. package/src/resolve-port.ts +44 -0
  106. package/src/response-charset.test.ts +130 -0
  107. package/src/rest-explicit-columns-http.test.ts +216 -0
  108. package/src/rest-identity.test.ts +129 -0
  109. package/src/rest-identity.ts +100 -0
  110. package/src/rest-projections.ts +68 -0
  111. package/src/rest-write-network-resolution.test.ts +133 -0
  112. package/src/restricted-files.ts +73 -0
  113. package/src/retention.ts +10 -1
  114. package/src/rules-file-duplicate-alias.test.ts +234 -0
  115. package/src/rules-file-session-target.test.ts +207 -0
  116. package/src/rules-file-transport.test.ts +237 -0
  117. package/src/runtime-label.test.ts +22 -0
  118. package/src/runtime-label.ts +21 -0
  119. package/src/scheduled-doorbell-nodeless-session.test.ts +130 -0
  120. package/src/scheduled-run-terminal.test.ts +268 -0
  121. package/src/scheduled-skip-blocking-task.test.ts +120 -0
  122. package/src/scheduled-tasks-http.test.ts +625 -0
  123. package/src/scheduled-tasks.ts +597 -0
  124. package/src/send-dedup-request-id.test.ts +229 -0
  125. package/src/send-reply-agent-warning.test.ts +202 -0
  126. package/src/send-reply-alias-optional.test.ts +131 -0
  127. package/src/send-reply-attachments.test.ts +458 -0
  128. package/src/send_dedup.test.ts +54 -0
  129. package/src/send_dedup.ts +52 -6
  130. package/src/serve-error.test.ts +38 -0
  131. package/src/serve-error.ts +27 -0
  132. package/src/server.ts +4385 -0
  133. package/src/shared/external-schedule-contract.ts +60 -0
  134. package/src/side-thread-command-transport.test.ts +302 -0
  135. package/src/side-thread-command-transport.ts +318 -0
  136. package/src/side-thread-http-integration.test.ts +233 -0
  137. package/src/side-thread-http.ts +504 -0
  138. package/src/side-thread-production.test.ts +72 -0
  139. package/src/side-thread-production.ts +118 -0
  140. package/src/side-thread.test.ts +1067 -0
  141. package/src/side-thread.ts +2495 -0
  142. package/src/skillhub-http.test.ts +92 -0
  143. package/src/start-node.test.ts +109 -0
  144. package/src/status-alias-filter-http.test.ts +106 -0
  145. package/src/stop-delete-node.test.ts +1164 -0
  146. package/src/task-auth-origin-http.test.ts +91 -0
  147. package/src/task-auth-origin.test.ts +24 -0
  148. package/src/task-auth-origin.ts +7 -0
  149. package/src/task-consumption.test.ts +482 -0
  150. package/src/task-diagnostic-http.test.ts +149 -0
  151. package/src/task-diagnostic.test.ts +115 -0
  152. package/src/task-diagnostic.ts +118 -0
  153. package/src/task-idempotency-mcp.test.ts +142 -0
  154. package/src/task-idempotency.test.ts +60 -0
  155. package/src/task-idempotency.ts +75 -0
  156. package/src/task-lifecycle-watcher.test.ts +204 -0
  157. package/src/task-lifecycle-watcher.ts +49 -0
  158. package/src/task-network-resolution-http.test.ts +141 -0
  159. package/src/tasks-pagination-http.test.ts +141 -0
  160. package/src/tools-list-json-schema.test.ts +66 -0
  161. package/src/tools.ts +3446 -257
  162. package/src/update-provider.test.ts +65 -0
  163. package/src/upload-early-reject-guard.test.ts +155 -0
  164. package/src/uploads-http.test.ts +97 -15
  165. package/src/uploads.test.ts +66 -0
  166. package/src/uploads.ts +130 -5
  167. package/src/user-inbox-by-agent-http.test.ts +161 -0
  168. package/src/user-inbox-netid-notnull.test.ts +122 -0
  169. package/src/user-inbox-read-http.test.ts +223 -0
  170. package/src/user-inbox-schema.test.ts +54 -0
@@ -0,0 +1,732 @@
1
+ // #503 — network scope for /api/files/:file_id + upload attribution.
2
+ //
3
+ // #495/#500 gave downloads an owner-only allow-list and explicitly
4
+ // deferred "same-network non-owner" to this issue. This suite covers
5
+ // both halves of the fix:
6
+ // • upload decides WHICH network a blob belongs to (U rows), and
7
+ // • download decides who may read a blob given that network (D rows),
8
+ // plus the enumeration-oracle rows (E) that pin denied responses to be
9
+ // byte-identical to "no such thing".
10
+ //
11
+ // FIXTURE DISCIPLINE — the whole suite is worthless if a probe is admin.
12
+ // `authorizeFileDownload` returns true for admin-utok before it ever
13
+ // looks at the network, so an admin probe passes every D row while
14
+ // proving nothing. commhub auto-promotes the FIRST registered user to
15
+ // admin (auth.ts register(): `isFirstUser ? "admin" : "user"`), so:
16
+ // 1. a throwaway `seedUser` is registered first to occupy that slot,
17
+ // 2. every non-admin probe is registered after it, and
18
+ // 3. each row re-reads the probe's role from the DB and asserts it is
19
+ // not admin — inline, not via a helper, so the assertion cannot be
20
+ // deleted in one place and lost everywhere.
21
+ // See project memory feedback_first_registered_user_is_admin_fake_green.
22
+ //
23
+ // DEV_OPEN rows live in file-network-scope-dev-open.test.ts: DEV_OPEN is
24
+ // captured at server.ts module-load time and cannot be flipped mid-suite
25
+ // (same reason #500 split its DEV_OPEN coverage into a sibling file).
26
+
27
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
28
+ import { mkdtempSync, rmSync, readFileSync, writeFileSync, mkdirSync, existsSync } from "fs";
29
+ import { join, dirname } from "path";
30
+ import { tmpdir } from "os";
31
+ // Type-only — erased at runtime, so it cannot pull server.ts in before
32
+ // the env below is set.
33
+ import type { Principal } from "./server.js";
34
+
35
+ const SERVER_DB = mkdtempSync(join(tmpdir(), "anet-503-db-")) + "/commhub.db";
36
+ const UPLOADS_DIR = mkdtempSync(join(tmpdir(), "anet-503-fs-"));
37
+ const MASTER_TOKEN = `master-503-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
38
+
39
+ // Env must be set before anything pulls in db-adapter.ts (which refuses
40
+ // the default DB under NODE_ENV=test) or server.ts (which freezes
41
+ // DEV_OPEN + AUTH_TOKEN at module load).
42
+ process.env.COMMHUB_DB ||= SERVER_DB;
43
+ process.env.COMMHUB_UPLOADS_DIR = UPLOADS_DIR;
44
+ process.env.HOST = "127.0.0.1";
45
+ process.env.COMMHUB_AUTH_TOKEN = MASTER_TOKEN;
46
+ delete process.env.COMMHUB_DEV_OPEN;
47
+
48
+ const { register, addNetworkMember, createNetworkTokenForNode } = await import("./auth.js");
49
+ const { db } = await import("./db.js");
50
+
51
+ const STAMP = `${Date.now()}${Math.floor(Math.random() * 100000)}`;
52
+
53
+ type Probe = {
54
+ name: string;
55
+ userId: string;
56
+ token: string; // utok_
57
+ ntok: string; // ntok_ bound to their own default network
58
+ networkId: string; // their own auto-created default network
59
+ };
60
+
61
+ function reg(label: string): Probe {
62
+ const name = `u503${label}${STAMP}`;
63
+ const r = register(name, "BootstrapPw123Aa!", undefined, label);
64
+ if (!r.ok) throw new Error(`${label} register failed: ${r.error}`);
65
+ return {
66
+ name,
67
+ userId: r.user!.user_id,
68
+ token: r.token!,
69
+ ntok: r.network_token!,
70
+ networkId: r.network_id!,
71
+ };
72
+ }
73
+
74
+ function roleOf(userId: string): string | null {
75
+ return db.get<{ role: string }>("SELECT role FROM users WHERE user_id = ?1", userId)?.role ?? null;
76
+ }
77
+
78
+ // 1. Occupies the auto-admin slot so no probe below can inherit it.
79
+ const seedUser = reg("seed");
80
+ // 2. The admin probe is promoted explicitly rather than relying on
81
+ // registration order, which is not stable under aggregate runs.
82
+ const adminUser = reg("admin");
83
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [adminUser.userId]);
84
+ // 3. Non-admin probes.
85
+ const nonAdminUserA = reg("usera");
86
+ const nonAdminUserB = reg("userb");
87
+ const outsiderUser = reg("outsider");
88
+ const viewerOnlyUser = reg("vieweronly");
89
+ const mixedUser = reg("mixed");
90
+ // 4. Additional admins with different network-membership counts for
91
+ // F2=F auto-derive (U11a/b/c). adminUser is multi-network (own
92
+ // default + N1 below, → U11b). adminSingleUser stays single-network
93
+ // on its own default → U11a. adminZeroUser has zero memberships
94
+ // (own default is dropped below) → U11c.
95
+ const adminSingleUser = reg("adminsingle");
96
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [adminSingleUser.userId]);
97
+ const adminZeroUser = reg("adminzero");
98
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [adminZeroUser.userId]);
99
+ db.run("DELETE FROM network_members WHERE user_id = ?1", [adminZeroUser.userId]);
100
+
101
+ // N1 is userA's own default network — userA stays single-network so the
102
+ // "no query param, unambiguous membership" upload path (U6) is reachable.
103
+ const N1 = nonAdminUserA.networkId;
104
+ // N2 is userB's own default network. userB also joins N1, making userB
105
+ // multi-network (U7/U8) and a same-network non-owner peer of userA (D3).
106
+ const N2 = nonAdminUserB.networkId;
107
+ // N3 exists but userB is not a member — the "not yours" upload row (U9).
108
+ const N3 = seedUser.networkId;
109
+ const N_FAKE = `net_503fake${STAMP}`;
110
+
111
+ // 多用户 Agent 权限:新成员默认只看授权 Agent;本测试测的是「完全信任的成员」旧语义,显式 agent_access=all。
112
+ if (!addNetworkMember(N1, nonAdminUserB.userId, "member", nonAdminUserA.userId, { agentAccess: "all" }).ok) {
113
+ throw new Error("fixture: userB → N1 member failed");
114
+ }
115
+ // The admin needs write access to N1 to be issued a node ntok_ there
116
+ // (createNetworkTokenForNode refuses non-members and viewers). That
117
+ // token is the D15 probe: a ntok_ ISSUED BY an admin.
118
+ if (!addNetworkMember(N1, adminUser.userId, "member", nonAdminUserA.userId, { agentAccess: "all" }).ok) {
119
+ throw new Error("fixture: admin → N1 member failed");
120
+ }
121
+ // viewerOnlyUser must hold exactly one network, with role viewer, so the
122
+ // "no param + unambiguous network + no write access" row (U13) is
123
+ // reachable. register() makes everyone owner of a default network, so
124
+ // that membership is dropped here.
125
+ db.run("DELETE FROM network_members WHERE user_id = ?1", [viewerOnlyUser.userId]);
126
+ if (!addNetworkMember(N1, viewerOnlyUser.userId, "viewer", nonAdminUserA.userId, { agentAccess: "all" }).ok) {
127
+ throw new Error("fixture: viewerOnly → N1 viewer failed");
128
+ }
129
+ // mixedUser can write in N1 but only read in N2 — U14 asserts that an
130
+ // explicit param pointing at the viewer network is refused.
131
+ if (!addNetworkMember(N1, mixedUser.userId, "member", nonAdminUserA.userId, { agentAccess: "all" }).ok) {
132
+ throw new Error("fixture: mixed → N1 member failed");
133
+ }
134
+ if (!addNetworkMember(N2, mixedUser.userId, "viewer", nonAdminUserB.userId, { agentAccess: "all" }).ok) {
135
+ throw new Error("fixture: mixed → N2 viewer failed");
136
+ }
137
+
138
+ const adminNtokN1 = createNetworkTokenForNode(adminUser.userId, N1, "d15probe");
139
+ if (!adminNtokN1.ok) throw new Error(`fixture: admin ntok_ on N1 failed: ${adminNtokN1.error}`);
140
+
141
+ const { bootServer, authorizeFileDownload, normalizeEntry } = await import("./server.js");
142
+ const server = bootServer({ port: 0, hostname: "127.0.0.1" });
143
+ const BASE = `http://127.0.0.1:${server.port}`;
144
+ await new Promise((r) => setTimeout(r, 100));
145
+
146
+ // The loaded server module's AUTH_TOKEN binding may predate our env in
147
+ // an aggregate run. Probe it and `skipIf` the legacy-master rows so the
148
+ // runner reports "skipped", never "passed".
149
+ const _probeMaster = await fetch(`${BASE}/api/files/00000000000000000000000000000000`, {
150
+ headers: { Authorization: `Bearer ${MASTER_TOKEN}` },
151
+ });
152
+ const masterTokenActive = _probeMaster.status === 404;
153
+
154
+ // This file asserts production semantics; if a sibling loaded server.ts
155
+ // with DEV_OPEN=1 first, the singleton wins and these rows would be
156
+ // measuring the wrong build.
157
+ async function _anonUploadStatus(): Promise<number> {
158
+ const form = new FormData();
159
+ form.append("file", new Blob([new TextEncoder().encode("p")]), "p.bin");
160
+ const r = await fetch(`${BASE}/api/upload`, { method: "POST", body: form });
161
+ return r.status;
162
+ }
163
+ const isProdMode = (await _anonUploadStatus()) !== 200;
164
+
165
+ afterAll(() => {
166
+ try { server?.stop?.(true); } catch {}
167
+ try { rmSync(UPLOADS_DIR, { recursive: true, force: true }); } catch {}
168
+ try { rmSync(SERVER_DB, { recursive: true, force: true }); } catch {}
169
+ delete process.env.COMMHUB_AUTH_TOKEN;
170
+ });
171
+
172
+ // ── helpers ────────────────────────────────────────────────────────────
173
+
174
+ function uploadUrl(networkId?: string): string {
175
+ return networkId ? `${BASE}/api/upload?network_id=${encodeURIComponent(networkId)}` : `${BASE}/api/upload`;
176
+ }
177
+
178
+ async function upload(token: string | null, body: string, filename: string, networkId?: string): Promise<Response> {
179
+ const form = new FormData();
180
+ form.append("file", new Blob([new TextEncoder().encode(body)], { type: "application/octet-stream" }), filename);
181
+ const headers: Record<string, string> = {};
182
+ if (token) headers.Authorization = `Bearer ${token}`;
183
+ return fetch(uploadUrl(networkId), { method: "POST", body: form, headers });
184
+ }
185
+
186
+ async function uploadOk(token: string, body: string, filename: string, networkId?: string): Promise<string> {
187
+ const res = await upload(token, body, filename, networkId);
188
+ if (res.status !== 200) throw new Error(`upload expected 200, got ${res.status}: ${await res.text()}`);
189
+ const json: any = await res.json();
190
+ return json.file_id;
191
+ }
192
+
193
+ function entryPath(fileId: string): string {
194
+ return join(UPLOADS_DIR, ".index", `${fileId}.json`);
195
+ }
196
+
197
+ function readEntry(fileId: string): any {
198
+ return JSON.parse(readFileSync(entryPath(fileId), "utf-8"));
199
+ }
200
+
201
+ function writeEntry(fileId: string, entry: any): void {
202
+ writeFileSync(entryPath(fileId), JSON.stringify(entry, null, 2));
203
+ }
204
+
205
+ async function download(token: string | null, fileId: string): Promise<Response> {
206
+ const headers: Record<string, string> = {};
207
+ if (token) headers.Authorization = `Bearer ${token}`;
208
+ return fetch(`${BASE}/api/files/${fileId}`, { headers });
209
+ }
210
+
211
+ async function head(token: string | null, fileId: string): Promise<Response> {
212
+ const headers: Record<string, string> = {};
213
+ if (token) headers.Authorization = `Bearer ${token}`;
214
+ return fetch(`${BASE}/api/files/${fileId}`, { method: "HEAD", headers });
215
+ }
216
+
217
+ const UNKNOWN_FILE_ID = "0123456789abcdef0123456789abcdef";
218
+
219
+ // Files used by the D rows. Built once, in beforeAll, through the real
220
+ // upload path so the D rows read what the U rows actually write.
221
+ let fileInN1 = "";
222
+ let fileInN2 = "";
223
+ let legacyOwnedByA = "";
224
+ let legacyNullOwner = "";
225
+
226
+ describe.skipIf(!isProdMode)("#503 — fixture integrity", () => {
227
+ test("probes are registered after the auto-admin slot is taken, and are not admin", () => {
228
+ expect(roleOf(nonAdminUserA.userId)).not.toBe("admin");
229
+ expect(roleOf(nonAdminUserB.userId)).not.toBe("admin");
230
+ expect(roleOf(outsiderUser.userId)).not.toBe("admin");
231
+ expect(roleOf(viewerOnlyUser.userId)).not.toBe("admin");
232
+ expect(roleOf(mixedUser.userId)).not.toBe("admin");
233
+ // The admin probe must really be admin, or D6/D10/D14/U10b/U11/U12
234
+ // would be measuring a non-admin and silently prove nothing.
235
+ expect(roleOf(adminUser.userId)).toBe("admin");
236
+ // F2=F auto-derive probes: adminSingleUser must be admin AND in exactly
237
+ // one network; adminZeroUser must be admin AND in zero networks.
238
+ // Fixture drift on either turns U11a/U11c into vacuously-true tests.
239
+ expect(roleOf(adminSingleUser.userId)).toBe("admin");
240
+ expect(roleOf(adminZeroUser.userId)).toBe("admin");
241
+ });
242
+
243
+ test("network membership is shaped as the matrix assumes", () => {
244
+ const memberships = (userId: string) =>
245
+ db.all<{ network_id: string; role: string }>(
246
+ "SELECT network_id, role FROM network_members WHERE user_id = ?1", userId,
247
+ );
248
+ // userA single-network (U6 depends on it being unambiguous).
249
+ expect(memberships(nonAdminUserA.userId).map((r) => r.network_id)).toEqual([N1]);
250
+ // userB multi-network (U7 depends on it being ambiguous).
251
+ expect(memberships(nonAdminUserB.userId).map((r) => r.network_id).sort()).toEqual([N1, N2].sort());
252
+ // outsider is in neither N1 nor N2 (D4 depends on it).
253
+ expect(memberships(outsiderUser.userId).some((r) => r.network_id === N1)).toBe(false);
254
+ // viewerOnly holds exactly one network, as viewer (U13 depends on both).
255
+ expect(memberships(viewerOnlyUser.userId)).toEqual([{ network_id: N1, role: "viewer" }]);
256
+ // N_FAKE really does not exist (U10a/U10b/E8/E9 depend on it).
257
+ expect(db.get<any>("SELECT * FROM networks WHERE network_id = ?1", N_FAKE)).toBeFalsy();
258
+ // N3 really does exist and userB really is not in it (U9 must be
259
+ // "exists but not yours", not accidentally "does not exist").
260
+ expect(db.get<any>("SELECT * FROM networks WHERE network_id = ?1", N3)).toBeTruthy();
261
+ expect(memberships(nonAdminUserB.userId).some((r) => r.network_id === N3)).toBe(false);
262
+ });
263
+ });
264
+
265
+ // ── Upload matrix ──────────────────────────────────────────────────────
266
+
267
+ describe.skipIf(!isProdMode)("#503 U — upload network attribution", () => {
268
+ // U1/U2 as designed assumed a legacy master could upload. It cannot:
269
+ // requireAuth 401s master tokens on every non-GET /api/ request since
270
+ // RFC-001 made them read-only, so the `legacy-master` upload branch is
271
+ // unreachable. These two rows pin that fact instead — if RFC-001's
272
+ // read-only rule is ever relaxed, they go red and the unattributed-write
273
+ // question has to be answered deliberately rather than by accident.
274
+ test.skipIf(!masterTokenActive)("U1: legacy master upload, no param → 401 (master tokens are read-only, RFC-001)", async () => {
275
+ const res = await upload(MASTER_TOKEN, "u1", "u1.bin");
276
+ expect(res.status).toBe(401);
277
+ });
278
+
279
+ test.skipIf(!masterTokenActive)("U2: legacy master upload, param=N1 → 401 (a query param does not buy write access)", async () => {
280
+ const res = await upload(MASTER_TOKEN, "u2", "u2.bin", N1);
281
+ expect(res.status).toBe(401);
282
+ // Same token CAN read — proves the 401 is about the write verb, not a
283
+ // dead/misconfigured master token (which would make U1 vacuous).
284
+ const readBack = await fetch(`${BASE}/api/files/${UNKNOWN_FILE_ID}`, {
285
+ headers: { Authorization: `Bearer ${MASTER_TOKEN}` },
286
+ });
287
+ expect(readBack.status).toBe(404);
288
+ });
289
+
290
+ test("U4: ntok_(N1), no param → 200 and entry.network_id === N1 (bound network is the attribution)", async () => {
291
+ const fileId = await uploadOk(nonAdminUserA.ntok, "u4", "u4.bin");
292
+ expect(readEntry(fileId).network_id).toBe(N1);
293
+ });
294
+
295
+ test("U5: ntok_(N1), param=N2 → 400 network_id_conflict (never silently overridden)", async () => {
296
+ const res = await upload(nonAdminUserA.ntok, "u5", "u5.bin", N2);
297
+ expect(res.status).toBe(400);
298
+ expect((await res.json() as any).error).toBe("network_id_conflict");
299
+ // The conflicting network must not have been written to either.
300
+ expect(db.get<any>("SELECT * FROM networks WHERE network_id = ?1", N2)).toBeTruthy();
301
+ });
302
+
303
+ test("U6: utok_ single-network non-admin, no param → 200 and entry.network_id === N1", async () => {
304
+ expect(roleOf(nonAdminUserA.userId)).not.toBe("admin");
305
+ const fileId = await uploadOk(nonAdminUserA.token, "u6", "u6.bin");
306
+ expect(readEntry(fileId).network_id).toBe(N1);
307
+ });
308
+
309
+ test("U7: utok_ multi-network non-admin, no param → 400 network_id_required ('first network' is never assumed)", async () => {
310
+ expect(roleOf(nonAdminUserB.userId)).not.toBe("admin");
311
+ const res = await upload(nonAdminUserB.token, "u7", "u7.bin");
312
+ expect(res.status).toBe(400);
313
+ expect((await res.json() as any).error).toBe("network_id_required");
314
+ });
315
+
316
+ test("U8: utok_ multi-network non-admin, param=N1 (a network they belong to) → 200 and entry.network_id === N1", async () => {
317
+ expect(roleOf(nonAdminUserB.userId)).not.toBe("admin");
318
+ const fileId = await uploadOk(nonAdminUserB.token, "u8", "u8.bin", N1);
319
+ expect(readEntry(fileId).network_id).toBe(N1);
320
+ });
321
+
322
+ // U9/U10a as designed expected a 404 emitted by the upload handler.
323
+ // The request never reaches it: every REST /api endpoint already passes
324
+ // through a shared network-scope guard (server.ts, `restScope.denied`)
325
+ // that 403s a non-member's ?network_id=. The security property the
326
+ // design wanted — a non-admin cannot tell "exists, not yours" from "no
327
+ // such network" — holds there instead, and E8 pins it byte-for-byte.
328
+ test("U9: utok_ non-admin, param=N3 (exists, not a member) → 403 at the shared REST scope guard", async () => {
329
+ expect(roleOf(nonAdminUserB.userId)).not.toBe("admin");
330
+ const res = await upload(nonAdminUserB.token, "u9", "u9.bin", N3);
331
+ expect(res.status).toBe(403);
332
+ expect((await res.json() as any).error).toBe("access denied to requested network");
333
+ });
334
+
335
+ test("U10a: utok_ non-admin, param=N_FAKE (does not exist) → 403, indistinguishable from U9", async () => {
336
+ expect(roleOf(nonAdminUserB.userId)).not.toBe("admin");
337
+ const res = await upload(nonAdminUserB.token, "u10a", "u10a.bin", N_FAKE);
338
+ expect(res.status).toBe(403);
339
+ expect((await res.json() as any).error).toBe("access denied to requested network");
340
+ });
341
+
342
+ test("U10b: admin utok_, param=N_FAKE → 400 unknown_network (admin already knows the network list; no oracle)", async () => {
343
+ expect(roleOf(adminUser.userId)).toBe("admin");
344
+ const res = await upload(adminUser.token, "u10b", "u10b.bin", N_FAKE);
345
+ expect(res.status).toBe(400);
346
+ expect((await res.json() as any).error).toBe("unknown_network");
347
+ });
348
+
349
+ // #503 Finding 2 = Option F (lead 40be9845): admin gets the same
350
+ // "auto-derive when unambiguous / require param when ambiguous"
351
+ // rule as utok_. Split old U11 into three rows to pin all three
352
+ // membership shapes.
353
+ test("U11a: admin utok_, EXACTLY 1 network membership, no param → 200 and entry.network_id === that network (F auto-derive)", async () => {
354
+ expect(roleOf(adminSingleUser.userId)).toBe("admin");
355
+ const singleNet = db.all<{ network_id: string }>(
356
+ "SELECT network_id FROM network_members WHERE user_id = ?1", adminSingleUser.userId,
357
+ );
358
+ expect(singleNet.length).toBe(1);
359
+ const fileId = await uploadOk(adminSingleUser.token, "u11a", "u11a.bin");
360
+ expect(readEntry(fileId).network_id).toBe(singleNet[0].network_id);
361
+ });
362
+
363
+ test("U11b: admin utok_, ≥2 network memberships, no param → 400 network_id_required (strict on ambiguity)", async () => {
364
+ expect(roleOf(adminUser.userId)).toBe("admin");
365
+ const multi = db.all<{ network_id: string }>(
366
+ "SELECT network_id FROM network_members WHERE user_id = ?1", adminUser.userId,
367
+ );
368
+ expect(multi.length).toBeGreaterThanOrEqual(2);
369
+ const res = await upload(adminUser.token, "u11b", "u11b.bin");
370
+ expect(res.status).toBe(400);
371
+ expect((await res.json() as any).error).toBe("network_id_required");
372
+ });
373
+
374
+ test("U11c: admin utok_, ZERO network memberships, no param → 400 network_id_required (no unowned files even for admin)", async () => {
375
+ expect(roleOf(adminZeroUser.userId)).toBe("admin");
376
+ const zero = db.all<{ network_id: string }>(
377
+ "SELECT network_id FROM network_members WHERE user_id = ?1", adminZeroUser.userId,
378
+ );
379
+ expect(zero.length).toBe(0);
380
+ const res = await upload(adminZeroUser.token, "u11c", "u11c.bin");
381
+ expect(res.status).toBe(400);
382
+ expect((await res.json() as any).error).toBe("network_id_required");
383
+ });
384
+
385
+ test("U12: admin utok_, param=N1 → 200 and entry.network_id === N1", async () => {
386
+ const fileId = await uploadOk(adminUser.token, "u12", "u12.bin", N1);
387
+ expect(readEntry(fileId).network_id).toBe(N1);
388
+ });
389
+
390
+ test("U13: utok_ viewer, single network, no param → 403 permission_denied (read-only role cannot write)", async () => {
391
+ expect(roleOf(viewerOnlyUser.userId)).not.toBe("admin");
392
+ const res = await upload(viewerOnlyUser.token, "u13", "u13.bin");
393
+ expect(res.status).toBe(403);
394
+ expect((await res.json() as any).error).toBe("permission_denied");
395
+ });
396
+
397
+ test("U14: utok_ member of N1 but viewer in N2, param=N2 → 403 permission_denied", async () => {
398
+ expect(roleOf(mixedUser.userId)).not.toBe("admin");
399
+ const res = await upload(mixedUser.token, "u14", "u14.bin", N2);
400
+ expect(res.status).toBe(403);
401
+ expect((await res.json() as any).error).toBe("permission_denied");
402
+ // Same caller CAN write to the network where they hold member — proves
403
+ // the 403 above is about the role in N2, not a blanket denial.
404
+ const okFileId = await uploadOk(mixedUser.token, "u14ok", "u14ok.bin", N1);
405
+ expect(readEntry(okFileId).network_id).toBe(N1);
406
+ });
407
+ });
408
+
409
+ // ── Download matrix ────────────────────────────────────────────────────
410
+
411
+ describe.skipIf(!isProdMode)("#503 D — download network scope", () => {
412
+ beforeAll(async () => {
413
+ fileInN1 = await uploadOk(nonAdminUserA.token, "payload-N1", "n1.bin");
414
+ fileInN2 = await uploadOk(nonAdminUserB.token, "payload-N2", "n2.bin", N2);
415
+ expect(readEntry(fileInN1).network_id).toBe(N1);
416
+ expect(readEntry(fileInN2).network_id).toBe(N2);
417
+
418
+ // Legacy entries: uploaded through the real path, then stripped of
419
+ // network_id so they carry the exact on-disk shape of a pre-#503
420
+ // blob. `delete` (not `= null`) matches the writer, which omits the
421
+ // key entirely when there is no attribution.
422
+ legacyOwnedByA = await uploadOk(nonAdminUserA.token, "payload-legacy-A", "legacy-a.bin");
423
+ const eA = readEntry(legacyOwnedByA);
424
+ delete eA.network_id;
425
+ writeEntry(legacyOwnedByA, eA);
426
+
427
+ legacyNullOwner = await uploadOk(nonAdminUserA.token, "payload-legacy-null", "legacy-null.bin");
428
+ const eN = readEntry(legacyNullOwner);
429
+ delete eN.network_id;
430
+ eN.owner = null;
431
+ eN.owner_id = null;
432
+ writeEntry(legacyNullOwner, eN);
433
+
434
+ expect("network_id" in readEntry(legacyOwnedByA)).toBe(false);
435
+ expect("network_id" in readEntry(legacyNullOwner)).toBe(false);
436
+ });
437
+
438
+ test("D1: ntok_ bound to N1 reads a file in N1 → 200", async () => {
439
+ const res = await download(nonAdminUserA.ntok, fileInN1);
440
+ expect(res.status).toBe(200);
441
+ expect(await res.text()).toBe("payload-N1");
442
+ });
443
+
444
+ test("🔴 D2: ntok_ bound to N2 reads a file in N1 → 404 (the cross-network leak #503 closes)", async () => {
445
+ const res = await download(nonAdminUserB.ntok, fileInN1);
446
+ expect(res.status).toBe(404);
447
+ expect((await res.json() as any).error).toBe("not_found");
448
+ });
449
+
450
+ test("D3: utok_ non-admin who is a member of N1 reads a file in N1 → 200 (the carve-out #495 deferred)", async () => {
451
+ expect(roleOf(nonAdminUserB.userId)).not.toBe("admin");
452
+ // userB is a same-network peer, not the owner — that distinction is
453
+ // the whole point of the row.
454
+ expect(readEntry(fileInN1).owner_id).toBe(nonAdminUserA.userId);
455
+ expect(readEntry(fileInN1).owner_id).not.toBe(nonAdminUserB.userId);
456
+ const res = await download(nonAdminUserB.token, fileInN1);
457
+ expect(res.status).toBe(200);
458
+ expect(await res.text()).toBe("payload-N1");
459
+ });
460
+
461
+ test("🔴 D4: utok_ non-admin who is not a member of N1 reads a file in N1 → 404", async () => {
462
+ expect(roleOf(outsiderUser.userId)).not.toBe("admin");
463
+ const res = await download(outsiderUser.token, fileInN1);
464
+ expect(res.status).toBe(404);
465
+ expect((await res.json() as any).error).toBe("not_found");
466
+ });
467
+
468
+ test("D5: utok_ non-admin with role viewer in N1 reads a file in N1 → 200 (viewer is read-only, not read-never)", async () => {
469
+ expect(roleOf(viewerOnlyUser.userId)).not.toBe("admin");
470
+ const res = await download(viewerOnlyUser.token, fileInN1);
471
+ expect(res.status).toBe(200);
472
+ expect(await res.text()).toBe("payload-N1");
473
+ });
474
+
475
+ test("D6: admin utok_ reads a file in a network → 200 (operational access preserved)", async () => {
476
+ expect(roleOf(adminUser.userId)).toBe("admin");
477
+ const res = await download(adminUser.token, fileInN2);
478
+ expect(res.status).toBe(200);
479
+ expect(await res.text()).toBe("payload-N2");
480
+ });
481
+
482
+ test.skipIf(!masterTokenActive)("D7: legacy master reads a file in a network → 200 (single-tenant deployments)", async () => {
483
+ const res = await download(MASTER_TOKEN, fileInN2);
484
+ expect(res.status).toBe(200);
485
+ expect(await res.text()).toBe("payload-N2");
486
+ });
487
+
488
+ test("D8: utok_ non-admin owner reads their own legacy (no network_id) file → 200", async () => {
489
+ expect(roleOf(nonAdminUserA.userId)).not.toBe("admin");
490
+ const res = await download(nonAdminUserA.token, legacyOwnedByA);
491
+ expect(res.status).toBe(200);
492
+ expect(await res.text()).toBe("payload-legacy-A");
493
+ });
494
+
495
+ test("🔴 D9: utok_ non-admin non-owner reads someone else's legacy file → 404 (owner match did not widen)", async () => {
496
+ expect(roleOf(nonAdminUserB.userId)).not.toBe("admin");
497
+ // userB shares network N1 with the owner — under the legacy branch
498
+ // that must NOT be enough, or the compat path would become a hole
499
+ // wider than the network branch it stands in for.
500
+ expect(db.all("SELECT 1 FROM network_members WHERE user_id = ?1 AND network_id = ?2",
501
+ nonAdminUserB.userId, N1).length).toBe(1);
502
+ const res = await download(nonAdminUserB.token, legacyOwnedByA);
503
+ expect(res.status).toBe(404);
504
+ expect((await res.json() as any).error).toBe("not_found");
505
+ });
506
+
507
+ test("D10: admin utok_ reads a legacy file → 200 (存量可读性不变)", async () => {
508
+ const res = await download(adminUser.token, legacyOwnedByA);
509
+ expect(res.status).toBe(200);
510
+ });
511
+
512
+ test.skipIf(!masterTokenActive)("D11: legacy master reads a legacy file → 200", async () => {
513
+ const res = await download(MASTER_TOKEN, legacyOwnedByA);
514
+ expect(res.status).toBe(200);
515
+ });
516
+
517
+ test("D12: utok_ non-admin reads a null-owner legacy file with DEV_OPEN off → 404 fail-closed", async () => {
518
+ expect(roleOf(nonAdminUserA.userId)).not.toBe("admin");
519
+ expect(process.env.COMMHUB_DEV_OPEN).toBeUndefined();
520
+ const res = await download(nonAdminUserA.token, legacyNullOwner);
521
+ expect(res.status).toBe(404);
522
+ expect((await res.json() as any).error).toBe("not_found");
523
+ });
524
+
525
+ test.skipIf(!masterTokenActive)("D13: legacy master reads a null-owner legacy file → 200", async () => {
526
+ const res = await download(MASTER_TOKEN, legacyNullOwner);
527
+ expect(res.status).toBe(200);
528
+ });
529
+
530
+ test("D14: admin utok_ reads a null-owner legacy file → 200", async () => {
531
+ const res = await download(adminUser.token, legacyNullOwner);
532
+ expect(res.status).toBe(200);
533
+ });
534
+
535
+ test("🔴 D15: an ntok_ ISSUED BY AN ADMIN, bound to N1, reads a file in N2 → 404", async () => {
536
+ // The production shape: agent node tokens resolve to an admin user.
537
+ // If resolvePrincipal classified by admin-ness before boundness, this
538
+ // token would take the admin bypass and every node would keep its
539
+ // cross-network read. Assert the premise (the token's user really is
540
+ // admin) so a fixture drift cannot turn this into a trivial pass.
541
+ expect(roleOf(adminUser.userId)).toBe("admin");
542
+ const tokenRow = db.get<{ user_id: string; network_id: string }>(
543
+ "SELECT user_id, network_id FROM api_tokens WHERE name = ?1", "node:d15probe",
544
+ );
545
+ expect(tokenRow?.user_id).toBe(adminUser.userId);
546
+ expect(tokenRow?.network_id).toBe(N1);
547
+
548
+ const res = await download(adminNtokN1.token!, fileInN2);
549
+ expect(res.status).toBe(404);
550
+ expect((await res.json() as any).error).toBe("not_found");
551
+
552
+ // Same token in its own network still works — proves the 404 is
553
+ // scope, not a broken token.
554
+ const ok = await download(adminNtokN1.token!, fileInN1);
555
+ expect(ok.status).toBe(200);
556
+ });
557
+ });
558
+
559
+ // ── Enumeration oracle ─────────────────────────────────────────────────
560
+
561
+ describe.skipIf(!isProdMode)("#503 E — denied responses carry no enumeration signal", () => {
562
+ let unknownBody = "";
563
+ let unknownRes: Response;
564
+
565
+ beforeAll(async () => {
566
+ unknownRes = await download(nonAdminUserB.token, UNKNOWN_FILE_ID);
567
+ unknownBody = await unknownRes.text();
568
+ });
569
+
570
+ test("E1: unknown file_id → 404 with the canonical not_found body", async () => {
571
+ expect(unknownRes.status).toBe(404);
572
+ expect(JSON.parse(unknownBody)).toEqual({ ok: false, error: "not_found" });
573
+ });
574
+
575
+ test("E2: cross-network deny (D2) is byte-identical to unknown file_id", async () => {
576
+ const res = await download(nonAdminUserB.ntok, fileInN1);
577
+ expect(res.status).toBe(unknownRes.status);
578
+ expect(await res.text()).toBe(unknownBody);
579
+ });
580
+
581
+ test("E3: cross-network deny via utok_ (D4) is byte-identical to unknown file_id", async () => {
582
+ expect(roleOf(outsiderUser.userId)).not.toBe("admin");
583
+ const res = await download(outsiderUser.token, fileInN1);
584
+ expect(res.status).toBe(unknownRes.status);
585
+ expect(await res.text()).toBe(unknownBody);
586
+ });
587
+
588
+ test("E4: corrupted index JSON is byte-identical to unknown file_id", async () => {
589
+ const fileId = await uploadOk(nonAdminUserA.token, "e4", "e4.bin");
590
+ writeFileSync(entryPath(fileId), "{not json");
591
+ const res = await download(nonAdminUserA.token, fileId);
592
+ expect(res.status).toBe(unknownRes.status);
593
+ expect(await res.text()).toBe(unknownBody);
594
+ });
595
+
596
+ test("E5: schema-invalid index entry is byte-identical to unknown file_id", async () => {
597
+ const fileId = await uploadOk(nonAdminUserA.token, "e5", "e5.bin");
598
+ const entry = readEntry(fileId);
599
+ // Empty-string network_id: present but not a usable value. If
600
+ // validateIndexEntry stopped rejecting it, normalizeEntry would coerce
601
+ // it to null and the blob would silently fall back to the legacy
602
+ // owner-only branch — a downgrade, not a denial.
603
+ entry.network_id = "";
604
+ writeEntry(fileId, entry);
605
+ const res = await download(nonAdminUserA.token, fileId);
606
+ expect(res.status).toBe(unknownRes.status);
607
+ expect(await res.text()).toBe(unknownBody);
608
+ });
609
+
610
+ test("E6: HEAD on a cross-network deny matches HEAD on unknown file_id (status + headers)", async () => {
611
+ const denied = await head(nonAdminUserB.ntok, fileInN1);
612
+ const unknown = await head(nonAdminUserB.token, UNKNOWN_FILE_ID);
613
+ expect(denied.status).toBe(404);
614
+ expect(denied.status).toBe(unknown.status);
615
+ expect(denied.headers.get("content-type")).toBe(unknown.headers.get("content-type"));
616
+ expect(denied.headers.get("content-length")).toBe(unknown.headers.get("content-length"));
617
+ });
618
+
619
+ test("E7: upload to a network you are not a member of → 403 with the shared scope-guard body", async () => {
620
+ expect(roleOf(nonAdminUserB.userId)).not.toBe("admin");
621
+ const res = await upload(nonAdminUserB.token, "e7", "e7.bin", N3);
622
+ expect(res.status).toBe(403);
623
+ expect(JSON.parse(await res.text())).toEqual({ ok: false, error: "access denied to requested network" });
624
+ });
625
+
626
+ test("E8: for a non-admin, 'network exists but is not yours' and 'network does not exist' are byte-identical", async () => {
627
+ // The premise both halves rest on: one network really exists and the
628
+ // other really does not. Without this the rows could match by both
629
+ // being the same kind of failure.
630
+ expect(db.get<any>("SELECT * FROM networks WHERE network_id = ?1", N3)).toBeTruthy();
631
+ expect(db.get<any>("SELECT * FROM networks WHERE network_id = ?1", N_FAKE)).toBeFalsy();
632
+ expect(roleOf(nonAdminUserB.userId)).not.toBe("admin");
633
+ const notMember = await upload(nonAdminUserB.token, "e8a", "e8a.bin", N3);
634
+ const notExist = await upload(nonAdminUserB.token, "e8b", "e8b.bin", N_FAKE);
635
+ expect(notExist.status).toBe(notMember.status);
636
+ expect(await notExist.text()).toBe(await notMember.text());
637
+ });
638
+
639
+ test("E9: admin uploading to a nonexistent network gets the distinguishable 400 (no oracle — admin sees the list anyway)", async () => {
640
+ expect(roleOf(adminUser.userId)).toBe("admin");
641
+ const res = await upload(adminUser.token, "e9", "e9.bin", N_FAKE);
642
+ expect(res.status).toBe(400);
643
+ expect(JSON.parse(await res.text()).error).toBe("unknown_network");
644
+ });
645
+ });
646
+
647
+ // ── Principal enumeration ──────────────────────────────────────────────
648
+ //
649
+ // Direct unit coverage of every Principal variant against both entry
650
+ // shapes. `anonymous` and `dev-open-anon` are unreachable over HTTP in
651
+ // production mode (requireAuth 401s first), so this is the only place
652
+ // they can be pinned at all.
653
+
654
+ describe("#503 — authorizeFileDownload covers every Principal variant", () => {
655
+ const inNetwork = normalizeEntry({ owner_id: "u_owner", network_id: "net_1" });
656
+ const legacyOwned = normalizeEntry({ owner_id: "u_owner" });
657
+ const legacyNull = normalizeEntry({ owner_id: null });
658
+
659
+ const anonymous: Principal = { kind: "anonymous" };
660
+ const devOpenAnon: Principal = { kind: "dev-open-anon" };
661
+ const legacyMaster: Principal = { kind: "legacy-master" };
662
+ const utokOwner: Principal = { kind: "utok", userId: "u_owner", username: "owner", isAdmin: false };
663
+ const ntokSame: Principal = { kind: "ntok", userId: "u_node", boundNetworkId: "net_1" };
664
+ const ntokOther: Principal = { kind: "ntok", userId: "u_node", boundNetworkId: "net_2" };
665
+ const adminUtok: Principal = { kind: "admin-utok", userId: "u_admin", username: "admin" };
666
+
667
+ test("legacy-master and admin-utok bypass in both entry shapes", () => {
668
+ for (const entry of [inNetwork, legacyOwned, legacyNull]) {
669
+ expect(authorizeFileDownload(legacyMaster, entry)).toBe(true);
670
+ expect(authorizeFileDownload(adminUtok, entry)).toBe(true);
671
+ }
672
+ });
673
+
674
+ test("anonymous and dev-open-anon are denied an attributed file", () => {
675
+ expect(authorizeFileDownload(anonymous, inNetwork)).toBe(false);
676
+ expect(authorizeFileDownload(devOpenAnon, inNetwork)).toBe(false);
677
+ });
678
+
679
+ test("ntok_ is allowed only in its bound network", () => {
680
+ expect(authorizeFileDownload(ntokSame, inNetwork)).toBe(true);
681
+ expect(authorizeFileDownload(ntokOther, inNetwork)).toBe(false);
682
+ });
683
+
684
+ test("an attributed file ignores owner match — network membership is the rule", () => {
685
+ // utokOwner IS the owner_id on this entry but holds no role in net_1,
686
+ // so it must still be denied. Owner-match must not leak back in as a
687
+ // second, weaker path around the network gate.
688
+ expect(inNetwork.ownerId).toBe("u_owner");
689
+ expect(authorizeFileDownload(utokOwner, inNetwork)).toBe(false);
690
+ });
691
+
692
+ test("legacy entries keep the pre-#503 owner-match rule", () => {
693
+ expect(authorizeFileDownload(utokOwner, legacyOwned)).toBe(true);
694
+ const other: Principal = { kind: "utok", userId: "u_other", username: "other", isAdmin: false };
695
+ expect(authorizeFileDownload(other, legacyOwned)).toBe(false);
696
+ expect(authorizeFileDownload(other, legacyNull)).toBe(false);
697
+ });
698
+
699
+ test("normalizeEntry is the only place on-disk values are narrowed", () => {
700
+ expect(normalizeEntry({ owner_id: null, network_id: undefined })).toEqual({ ownerId: null, networkId: null });
701
+ expect(normalizeEntry({ owner_id: "", network_id: "" })).toEqual({ ownerId: null, networkId: null });
702
+ expect(normalizeEntry({ owner_id: 42, network_id: { evil: true } } as any)).toEqual({ ownerId: null, networkId: null });
703
+ expect(normalizeEntry({ owner_id: "u1", network_id: "n1" })).toEqual({ ownerId: "u1", networkId: "n1" });
704
+ });
705
+ });
706
+
707
+ // ── #509 regression under network scope ────────────────────────────────
708
+
709
+ describe.skipIf(!isProdMode)("#503 — network scope does not regress cross-day downloads (#509)", () => {
710
+ test("a file indexed under a past date_bucket is still readable by its network", async () => {
711
+ const fileId = await uploadOk(nonAdminUserA.token, "yesterday-payload", "yd.bin");
712
+ const entry = readEntry(fileId);
713
+ expect(entry.network_id).toBe(N1);
714
+
715
+ // Move the blob and its index entry into a past bucket, mirroring a
716
+ // file uploaded before midnight and read after. The read path must
717
+ // use the indexed bucket, not today's.
718
+ const past = "2026-01-02";
719
+ const from = join(UPLOADS_DIR, entry.date_bucket, `${fileId}${entry.ext}`);
720
+ const to = join(UPLOADS_DIR, past, `${fileId}${entry.ext}`);
721
+ mkdirSync(dirname(to), { recursive: true });
722
+ writeFileSync(to, readFileSync(from));
723
+ rmSync(from, { force: true });
724
+ entry.date_bucket = past;
725
+ writeEntry(fileId, entry);
726
+ expect(existsSync(to)).toBe(true);
727
+
728
+ const res = await download(nonAdminUserA.ntok, fileId);
729
+ expect(res.status).toBe(200);
730
+ expect(await res.text()).toBe("yesterday-payload");
731
+ });
732
+ });