@sleep2agi/commhub-server 0.9.0-preview.24 → 0.9.0-preview.26

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.
package/README.md CHANGED
@@ -38,13 +38,16 @@ Once running:
38
38
 
39
39
  ## Pairs with
40
40
 
41
- | Package | Version |
41
+ | Package | |
42
42
  |---|---|
43
- | [`@sleep2agi/agent-network`](https://www.npmjs.com/package/@sleep2agi/agent-network) | 2.1.7 |
44
- | [`@sleep2agi/agent-network-dashboard`](https://www.npmjs.com/package/@sleep2agi/agent-network-dashboard) | 0.4.2 |
45
- | [`@sleep2agi/agent-node`](https://www.npmjs.com/package/@sleep2agi/agent-node) | 2.3.0 |
43
+ | [`@sleep2agi/agent-network`](https://www.npmjs.com/package/@sleep2agi/agent-network) | CLI |
44
+ | [`@sleep2agi/agent-network-dashboard`](https://www.npmjs.com/package/@sleep2agi/agent-network-dashboard) | Web UI |
45
+ | [`@sleep2agi/agent-node`](https://www.npmjs.com/package/@sleep2agi/agent-node) | Agent runtime host |
46
46
 
47
- ## MCP tools (17)
47
+ Install the matching versions with `npm install -g <pkg>` — the `latest` dist-tag on npm is
48
+ authoritative. Pinning versions here goes stale on every release and nobody comes back to fix it.
49
+
50
+ ## MCP tools
48
51
 
49
52
  ### Agent-side
50
53
  | Tool | Description |
@@ -96,7 +99,7 @@ Network-management endpoints (`/api/networks…`) are present and used by the cu
96
99
 
97
100
  Auth: `Authorization: Bearer <token>` header, or `?token=<token>` query.
98
101
 
99
- ## SQLite schema (13 tables)
102
+ ## SQLite schema
100
103
 
101
104
  Auto-created on first run.
102
105
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.24",
3
+ "version": "0.9.0-preview.26",
4
4
  "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 17 MCP tools.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/auth.ts CHANGED
@@ -297,6 +297,11 @@ export function deleteNetwork(userId: string, networkId: string): { ok: boolean;
297
297
  const sessions = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM sessions WHERE network_id = ?1", networkId);
298
298
  if (sessions && sessions.cnt > 0) return { ok: false, error: `network has ${sessions.cnt} active session(s) — stop them first` };
299
299
  db.run("DELETE FROM networks WHERE network_id = ?1 AND owner_id = ?2", [networkId, userId]);
300
+ // PR #519 codex catch: leaving membership rows behind made deleted
301
+ // networks count toward getUserNetworkIds — a stale row could flip a
302
+ // single-network user to "ambiguous" or auto-resolve writes INTO the
303
+ // deleted network. Remove memberships with the network.
304
+ db.run("DELETE FROM network_members WHERE network_id = ?1", [networkId]);
300
305
  return { ok: true };
301
306
  }
302
307
 
@@ -60,8 +60,10 @@ describe("#462 validateAvatarUrl", () => {
60
60
  expect(validateAvatarUrl("ftp://example.com/a.png").ok).toBe(false);
61
61
  });
62
62
 
63
- test("rejects relative / protocol-relative / non-URL strings", () => {
64
- expect(validateAvatarUrl("/avatars/1.png").ok).toBe(false);
63
+ test("rejects protocol-relative / non-URL / schemeless strings", () => {
64
+ // SPEC CHANGE (通信龙 裁定, avatar 接线单): same-origin pool paths
65
+ // (/avatars/<name>.<ext>) are now ACCEPTED — that case moved to the
66
+ // dedicated relative-branch suite below. Everything here stays rejected.
65
67
  expect(validateAvatarUrl("//cdn.example.com/1.png").ok).toBe(false);
66
68
  expect(validateAvatarUrl("not a url").ok).toBe(false);
67
69
  expect(validateAvatarUrl("example.com/x.png").ok).toBe(false);
@@ -111,3 +113,51 @@ describe("#462 validateAvatarUrl", () => {
111
113
  expect(validateAvatarUrl(ok + "a").ok).toBe(false);
112
114
  });
113
115
  });
116
+
117
+ // ── Same-origin relative branch (通信龙 裁定: relax to portable pool paths;
118
+ // absolute URLs would weld one hostname into the DB across multi-origin
119
+ // entry points). Each direction is its OWN test so any single guard
120
+ // being deleted turns its OWN assertion red (no merged catch-all).
121
+ describe("validateAvatarUrl — same-origin relative pool paths", () => {
122
+ test("accepts a pool path: /avatars/avatar-09.webp", () => {
123
+ const r = validateAvatarUrl("/avatars/avatar-09.webp");
124
+ expect(r).toEqual({ ok: true, value: "/avatars/avatar-09.webp" });
125
+ });
126
+
127
+ test("accepts png and svg pool files", () => {
128
+ expect(validateAvatarUrl("/avatars/intern_avatar.png")).toEqual({ ok: true, value: "/avatars/intern_avatar.png" });
129
+ expect(validateAvatarUrl("/avatars/team-logo.svg")).toEqual({ ok: true, value: "/avatars/team-logo.svg" });
130
+ });
131
+
132
+ test("rejects protocol-relative //host (classic same-origin bypass)", () => {
133
+ const r = validateAvatarUrl("//evil.com/x.png");
134
+ expect(r.ok).toBe(false);
135
+ if (!r.ok) expect(r.reason).toContain("protocol-relative");
136
+ });
137
+
138
+ test("rejects javascript: scheme (still, after the relax)", () => {
139
+ expect(validateAvatarUrl("javascript:alert(1)").ok).toBe(false);
140
+ });
141
+
142
+ test("rejects non-/avatars/ absolute paths (/etc/passwd)", () => {
143
+ expect(validateAvatarUrl("/etc/passwd").ok).toBe(false);
144
+ });
145
+
146
+ test("rejects non-whitelisted extensions under /avatars/", () => {
147
+ expect(validateAvatarUrl("/avatars/payload.html").ok).toBe(false);
148
+ });
149
+
150
+ test("rejects nested/traversal paths (charset forbids further slashes)", () => {
151
+ expect(validateAvatarUrl("/avatars/../secrets.webp").ok).toBe(false);
152
+ expect(validateAvatarUrl("/avatars/sub/x.webp").ok).toBe(false);
153
+ });
154
+
155
+ test("rejects percent-encoding inside the filename (no encoded surprises)", () => {
156
+ expect(validateAvatarUrl("/avatars/a%2f..%2fx.webp").ok).toBe(false);
157
+ });
158
+
159
+ test("absolute http(s) branch is UNTOUCHED by the relax (positive control)", () => {
160
+ const r = validateAvatarUrl("https://example.com/pic.png");
161
+ expect(r.ok).toBe(true);
162
+ });
163
+ });
@@ -15,9 +15,26 @@ export type AvatarValidation =
15
15
  * Normalize + validate an untrusted avatar_url patch value.
16
16
  *
17
17
  * null / undefined / "" / whitespace-only → { ok, value: null } (clear)
18
- * valid absolute http(s) URL → { ok, value: trimmed }
18
+ * same-origin pool path /avatars/<name>.<ext> → { ok, value: trimmed }
19
+ * valid absolute http(s) URL → { ok, value: normalized }
19
20
  * anything else → { ok: false, reason }
21
+ *
22
+ * Relative branch rationale (通信龙 裁定, avatar 接线单): the hub is
23
+ * reached through several origins (localhost dev, public domains) — an
24
+ * absolute URL would weld ONE hostname into the DB and break the image
25
+ * for every other entry point; a same-origin relative path is portable.
26
+ * 🔴 Trap this branch must block: "starts with /" ≠ "same-origin" —
27
+ * "//evil.com/x.png" ALSO starts with "/" but is a protocol-relative URL
28
+ * the browser resolves to evil.com. Hence: single leading slash with the
29
+ * SECOND char not "/", then an exact value-set match (the /avatars/ pool
30
+ * prefix + extension whitelist), not a shape match.
20
31
  */
32
+
33
+ // Exact allowed set for same-origin values: the dashboard's designed pool
34
+ // under /avatars/. Filename charset excludes "/" (no traversal, no nested
35
+ // paths) and "%" (no encoded surprises); extensions mirror the actual
36
+ // pool contents. Widen ONLY by extending this list deliberately.
37
+ const SAME_ORIGIN_AVATAR_RE = /^\/avatars\/[A-Za-z0-9._-]+\.(webp|png|svg)$/;
21
38
  export function validateAvatarUrl(raw: unknown): AvatarValidation {
22
39
  if (raw === null || raw === undefined) return { ok: true, value: null };
23
40
  if (typeof raw !== "string") {
@@ -35,6 +52,23 @@ export function validateAvatarUrl(raw: unknown): AvatarValidation {
35
52
  if (/[\u0000-\u001f\u007f-\u009f\s]/.test(trimmed)) {
36
53
  return { ok: false, reason: "avatar_url must not contain whitespace or control characters" };
37
54
  }
55
+ // Same-origin relative branch (runs AFTER the whitespace/control gate
56
+ // above — that check protects this branch too). Order of the two tests
57
+ // matters for the reason string, not for safety: the exact-set regex
58
+ // alone already rejects "//…" (second char is "/", first segment must
59
+ // literally be "avatars"), the explicit double-slash check just names
60
+ // the classic bypass in its own words.
61
+ if (trimmed.startsWith("/")) {
62
+ if (trimmed.startsWith("//")) {
63
+ return { ok: false, reason: "avatar_url must not be protocol-relative (//host/…)" };
64
+ }
65
+ if (!SAME_ORIGIN_AVATAR_RE.test(trimmed)) {
66
+ return { ok: false, reason: "relative avatar_url must match /avatars/<name>.(webp|png|svg)" };
67
+ }
68
+ return { ok: true, value: trimmed };
69
+ }
70
+ // Absolute http(s) branch — unchanged from #462 (do not touch while
71
+ // relaxing: 通信龙 裁定 condition 3).
38
72
  let parsed: URL;
39
73
  try {
40
74
  parsed = new URL(trimmed);
@@ -0,0 +1,138 @@
1
+ // Task #25 — regression pins for ext validation in validateIndexEntry.
2
+ //
3
+ // 🔴 SCOPE (lead d505179d): this suite covers ONLY the ext regex copy
4
+ // inside `validateIndexEntry` (uploads.ts). The same-shape regex has
5
+ // FOUR copies in this file:
6
+ // • L93 sanitizeExt /\.([A-Za-z0-9]{1,16})$/ (client-name parse; no leading-dot anchor, captures group)
7
+ // • L141 buildStoragePath /^\.[A-Za-z0-9]{1,16}$/ (upload-path build)
8
+ // • L165 pathForExistingBlob /^\.[A-Za-z0-9]{1,16}$/ (download-path build)
9
+ // • L219 validateIndexEntry /^\.[A-Za-z0-9]{1,16}$/ ← THIS SUITE
10
+ // Mutation reversal proved the other three copies get ZERO coverage
11
+ // from this suite (each was widened to `/./` in turn — this suite still
12
+ // passed 30/0). See docs/tests/p-25-ext-regression/mutation-red.txt.
13
+ // Do NOT read the file name "ext validation regression" as "the ext
14
+ // validation is regression-protected" — the ext validation exists in
15
+ // four independent copies and only ONE is pinned here.
16
+ // Follow-up issue tracks the dedup/alignment evaluation (see PR body).
17
+ //
18
+ // Background (通信龙 d01bb8ce): the current regex `/^\.[A-Za-z0-9]{1,16}$/`
19
+ // in validateIndexEntry (uploads.ts) correctly rejects 11 known malicious
20
+ // ext values (path-escape, traversal, absolute paths, doubles, NUL bytes,
21
+ // backslash forms, over-length, whitespace). Verified via real-run on
22
+ // origin/main by lead — 11/11 malicious values → false.
23
+ //
24
+ // But NO test locked that behavior. Anyone who widens the regex in the
25
+ // future (accidentally or on purpose) would not hit a red test — the
26
+ // live protection would degrade silently. This suite pins current
27
+ // behavior in two directions:
28
+ //
29
+ // 1. SAFETY (欠修 direction) — 11 malicious values → false, one row
30
+ // per value with the exact literal. If regex is widened to accept
31
+ // any of them, that row turns red.
32
+ // 2. NON-REGRESSION (过修 direction) — a set of common legitimate
33
+ // extensions → true. If regex is tightened to reject any of them
34
+ // (e.g. someone locks down to a whitelist), that row turns red.
35
+ //
36
+ // Witnessed-red for the SAFETY rows: temporarily widen the regex to
37
+ // `/./` (or comment it out) in validateIndexEntry, run this suite,
38
+ // confirm all 11 malicious rows turn red, then revert. The mutation
39
+ // itself is not committed; the assertion IS the mutation-red proof
40
+ // for the reader. Recording the mutation output in the followup PR
41
+ // body (per Constraint 3 discipline established in #503).
42
+ //
43
+ // Pure unit test — no server setup, no DB, no filesystem. The regex
44
+ // lives in a pure function, so the guard is testable at that level.
45
+
46
+ import { describe, expect, test } from "bun:test";
47
+ import { validateIndexEntry } from "./uploads.js";
48
+
49
+ // A structurally-valid base entry — file_id / date_bucket / size all
50
+ // pass their own checks — so `ext` is the only field a test row varies.
51
+ // This isolates the ext regex as the deciding factor.
52
+ const validBase = {
53
+ file_id: "0123456789abcdef0123456789abcdef",
54
+ date_bucket: "2026-07-30",
55
+ size: 0,
56
+ };
57
+
58
+ describe("Task #25 (#503 followup) — ext validation regression pins", () => {
59
+ describe("SAFETY: the 11 malicious ext values from lead d01bb8ce all return false", () => {
60
+ // Each row uses the exact literal from lead's verification list so
61
+ // a widening regex catches on the same value the manual audit did.
62
+ const malicious: Array<{ label: string; ext: string }> = [
63
+ { label: "path-escape prefix", ext: "../x" },
64
+ { label: "deep traversal to /etc", ext: "../../etc/passwd" },
65
+ { label: "absolute path", ext: "/abs/x" },
66
+ { label: "double-dot alone", ext: ".." },
67
+ { label: "single-dot alone", ext: "." },
68
+ { label: "chained after legit prefix", ext: ".png/../../y" },
69
+ { label: "embedded whitespace", ext: ".p ng" },
70
+ { label: "over-length (>16 chars)", ext: "." + "a".repeat(17) },
71
+ { label: "embedded NUL byte", ext: ".p\x00ng" },
72
+ { label: "backslash traversal", ext: ".\\..\\x" },
73
+ { label: "double-backslash prefix", ext: "..\\x" },
74
+ ];
75
+ for (const { label, ext } of malicious) {
76
+ test(`rejects: ${label} — ext=${JSON.stringify(ext)}`, () => {
77
+ expect(validateIndexEntry({ ...validBase, ext })).toBe(false);
78
+ });
79
+ }
80
+
81
+ test("precondition: 11 malicious cases enumerated (defends against silent test-shrink)", () => {
82
+ // If someone deletes rows from the malicious list, this fails
83
+ // instead of the deletion silently narrowing the guard. Number
84
+ // matches lead's verification list — bump only when lead adds a
85
+ // case and confirms it via real-run.
86
+ expect(malicious.length).toBe(11);
87
+ });
88
+ });
89
+
90
+ describe("NON-REGRESSION: common legitimate extensions all return true (防过修)", () => {
91
+ // These are the extensions the live upload path actually produces
92
+ // via sanitizeExt / mime-typed multipart uploads. If someone
93
+ // tightens the regex to a stricter whitelist, any of these turning
94
+ // red flags a break in a normal user path.
95
+ const legal = [".png", ".pdf", ".pptx", ".jpg", ".jpeg", ".txt", ".mp4", ".zip", ".json", ".md"];
96
+ for (const ext of legal) {
97
+ test(`accepts: ext=${JSON.stringify(ext)}`, () => {
98
+ expect(validateIndexEntry({ ...validBase, ext })).toBe(true);
99
+ });
100
+ }
101
+
102
+ test("empty ext is accepted (file with no extension is a legitimate upload)", () => {
103
+ expect(validateIndexEntry({ ...validBase, ext: "" })).toBe(true);
104
+ });
105
+
106
+ test("precondition: at least 10 legitimate ext values pinned", () => {
107
+ // Same discipline as the malicious side — reject silent shrink.
108
+ expect(legal.length).toBeGreaterThanOrEqual(10);
109
+ });
110
+ });
111
+
112
+ describe("regex shape assumptions (documented, not enforced by the function)", () => {
113
+ // These rows document facts the current regex enforces beyond the
114
+ // 11 malicious literals. They are close to the boundary and would
115
+ // be the first things to move if someone reworked the regex.
116
+ test("upper-case extensions accepted (regex is case-insensitive by [A-Za-z])", () => {
117
+ expect(validateIndexEntry({ ...validBase, ext: ".PNG" })).toBe(true);
118
+ });
119
+ test("mixed-case extensions accepted", () => {
120
+ expect(validateIndexEntry({ ...validBase, ext: ".PdF" })).toBe(true);
121
+ });
122
+ test("digits after dot accepted (e.g. .mp4, .mp3)", () => {
123
+ expect(validateIndexEntry({ ...validBase, ext: ".mp4" })).toBe(true);
124
+ });
125
+ test("exactly 16 chars after dot is accepted (upper bound of {1,16})", () => {
126
+ expect(validateIndexEntry({ ...validBase, ext: "." + "a".repeat(16) })).toBe(true);
127
+ });
128
+ test("exactly 17 chars after dot is rejected (immediately past upper bound)", () => {
129
+ expect(validateIndexEntry({ ...validBase, ext: "." + "a".repeat(17) })).toBe(false);
130
+ });
131
+ test("compound extensions like .tar.gz are rejected (contains inner dot)", () => {
132
+ // Documented: the current regex does not support compound
133
+ // extensions. If we ever want .tar.gz support, it needs its
134
+ // own explicit design + this row updated with lead sign-off.
135
+ expect(validateIndexEntry({ ...validBase, ext: ".tar.gz" })).toBe(false);
136
+ });
137
+ });
138
+ });
@@ -56,6 +56,16 @@ const rA = register(nameA, "BootstrapPw123Aa!", undefined, "userA");
56
56
  if (!rA.ok) throw new Error(`userA register failed: ${rA.error}`);
57
57
  const userAToken = rA.token!;
58
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;
59
69
 
60
70
  const nameB = `userbbb_495_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
61
71
  const rB = register(nameB, "BootstrapPw123Aa!", undefined, "userB");
@@ -132,10 +142,16 @@ afterAll(() => {
132
142
  delete process.env.COMMHUB_AUTH_TOKEN;
133
143
  });
134
144
 
135
- async function uploadAs(token: string, content: Uint8Array, filename: string): Promise<string> {
145
+ // #503 — a utok_ holder in more than one network must name the target
146
+ // network; the server refuses to guess ("first network" is not assumed).
147
+ // Callers using a single-network token can still omit it.
148
+ async function uploadAs(token: string, content: Uint8Array, filename: string, networkId?: string): Promise<string> {
136
149
  const form = new FormData();
137
150
  form.append("file", new Blob([content], { type: "application/octet-stream" }), filename);
138
- const res = await fetch(`${BASE}/api/upload`, {
151
+ const url = networkId
152
+ ? `${BASE}/api/upload?network_id=${encodeURIComponent(networkId)}`
153
+ : `${BASE}/api/upload`;
154
+ const res = await fetch(url, {
139
155
  method: "POST",
140
156
  body: form,
141
157
  headers: { Authorization: `Bearer ${token}` },
@@ -173,27 +189,36 @@ describe.skipIf(!isProdMode)("#495 — GET /api/files/:file_id authorization (ow
173
189
  expect(await res.text()).toBe("agent-blob");
174
190
  });
175
191
 
176
- test("🔴 non-owner (userA) downloads userB's file → 404 (was 200 pre-#495)", async () => {
177
- const res = await downloadAs(userAToken, userBFileId);
192
+ test("🔴 cross-network non-owner (userC) downloads userB's file → 404 (was 200 pre-#495)", async () => {
193
+ // Precondition — userC is in NEITHER of userB's networks, so the 404
194
+ // is cross-network denial. This row used to use userA, but #503 made
195
+ // userA a legitimate same-network reader; keeping userA here would
196
+ // have turned #495's guarantee into a test that asserts nothing.
197
+ const rows = db.all<{ network_id: string }>(
198
+ "SELECT network_id FROM network_members WHERE user_id = ?1 AND network_id = ?2",
199
+ userCUserId, userBNetworkId,
200
+ );
201
+ expect(rows.length).toBe(0);
202
+ const res = await downloadAs(userCToken, userBFileId);
178
203
  expect(res.status).toBe(404);
179
204
  const body: any = await res.json();
180
205
  expect(body.error).toBe("not_found");
181
206
  });
182
207
 
183
- test("🔴 same-network non-owner (userA is a member of userB's network) → 404 (STAGED carve-out; follow-up #503)", async () => {
184
- // Precondition — userA really is a member of userB's default
185
- // network (added in module setup). Guard the invariant so the
186
- // 404 result actually represents "same-network peer denied",
187
- // not "cross-network denied".
208
+ test("🔴 same-network non-owner (userA is a member of userB's network) → 200 (#503 lifted the #495 staged carve-out)", async () => {
209
+ // Precondition — userA really is a member of userB's default network
210
+ // (added in module setup), so the 200 represents "same-network peer
211
+ // allowed" rather than an owner match or an admin bypass.
188
212
  const rows = db.all<{ network_id: string }>(
189
213
  "SELECT network_id FROM network_members WHERE user_id = ?1 AND network_id = ?2",
190
214
  userAUserId, userBNetworkId,
191
215
  );
192
216
  expect(rows.length).toBe(1);
217
+ expect(db.get<{ role: string }>("SELECT role FROM users WHERE user_id = ?1", userAUserId)?.role)
218
+ .not.toBe("admin");
193
219
  const res = await downloadAs(userAToken, userBFileId);
194
- expect(res.status).toBe(404);
195
- const body: any = await res.json();
196
- expect(body.error).toBe("not_found");
220
+ expect(res.status).toBe(200);
221
+ expect(await res.text()).toBe("secret-from-B");
197
222
  });
198
223
 
199
224
  test("admin caller downloads any file → 200 (operational access preserved)", async () => {
@@ -247,8 +272,10 @@ describe.skipIf(!isProdMode)("#500 CR2 — HEAD /api/files/:file_id authorizatio
247
272
  expect(res.headers.get("x-content-type-options")).toBe("nosniff");
248
273
  });
249
274
 
250
- test("🔴 cross-owner HEAD (userA HEAD userB's file) → 404 via helper (was fallback 200 pre-CR2)", async () => {
251
- const res = await headAs(userAToken, userBFileId);
275
+ test("🔴 cross-network HEAD (userC HEAD userB's file) → 404 via helper (was fallback 200 pre-CR2)", async () => {
276
+ // userC, not userA — #503 makes userA a same-network reader, so the
277
+ // GET/HEAD parity claim needs a principal who is genuinely denied.
278
+ const res = await headAs(userCToken, userBFileId);
252
279
  expect(res.status).toBe(404);
253
280
  // 404 must originate from authorizeFileDownload, not the fallback
254
281
  // page. Body is application/json ({"ok":false,"error":"not_found"})
@@ -284,7 +311,9 @@ describe.skipIf(!isProdMode)("#500 CR2 — HEAD /api/files/:file_id authorizatio
284
311
  // in the authorizeFileDownload comment and this test enforces it.
285
312
  describe.skipIf(!isProdMode)("#500 CR2 — upload persists truthy owner_id (D1)", () => {
286
313
  test("D1: userA authenticated upload → index entry owner_id === userA.userId (never null)", async () => {
287
- const fileId = await uploadAs(userAToken, new TextEncoder().encode("owner-persistence-check"), "d1.bin");
314
+ // userA belongs to two networks (own default + userB's), so #503
315
+ // requires the target network to be named explicitly.
316
+ const fileId = await uploadAs(userAToken, new TextEncoder().encode("owner-persistence-check"), "d1.bin", userANetworkId);
288
317
  const { readFileSync } = await import("fs");
289
318
  const entryPath = join(UPLOADS_DIR, ".index", `${fileId}.json`);
290
319
  expect(existsSync(entryPath)).toBe(true);
@@ -314,13 +343,18 @@ describe.skipIf(!isProdMode)("#495 — null-owner policy in production (DEV_OPEN
314
343
  // reproduces the shape a DEV_OPEN or legacy master upload would
315
344
  // create, without needing to spin up a second server in a
316
345
  // different mode.
317
- nullOwnerFileId = await uploadAs(userAToken, new TextEncoder().encode("legacy-blob"), "legacy.txt");
346
+ nullOwnerFileId = await uploadAs(userAToken, new TextEncoder().encode("legacy-blob"), "legacy.txt", userANetworkId);
318
347
  const { readFileSync: rfs, writeFileSync: wfs } = await import("fs");
319
348
  const entryFile = join(UPLOADS_DIR, ".index", `${nullOwnerFileId}.json`);
320
349
  if (!existsSync(entryFile)) throw new Error(`index entry not found: ${entryFile}`);
321
350
  const entry = JSON.parse(rfs(entryFile, "utf-8"));
322
351
  entry.owner = null;
323
352
  entry.owner_id = null;
353
+ // #503 — a true legacy entry predates network attribution entirely.
354
+ // Deleting the key (rather than nulling it) matches what the writer
355
+ // emits when there is no attribution, so this fixture exercises the
356
+ // legacy compatibility branch instead of the network branch.
357
+ delete entry.network_id;
324
358
  wfs(entryFile, JSON.stringify(entry, null, 2));
325
359
  });
326
360