@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.
@@ -0,0 +1,429 @@
1
+ // #517 — MCP write-path network resolution (utok single-network fallback).
2
+ //
3
+ // Root cause (issue #517): utok_ token rows are created with network_id=null
4
+ // BY DESIGN (auth.ts login), and the MCP write path (tools.ts canWrite) had
5
+ // no single-network fallback — unlike REST POST /api/task (server.ts), which
6
+ // auto-resolves via singleNetworkId(restScope) when the user belongs to
7
+ // exactly one network. Result: a utok node could READ everything
8
+ // (resolveReadScope falls back to all memberships) but could not WRITE
9
+ // anything, and the error mislabeled the failure as permission_denied.
10
+ //
11
+ // Fix under test:
12
+ // - getNetworkId falls back to the user's single membership via the SAME
13
+ // shared singleNetworkId/getUserNetworkIds used by REST (network-scope.ts)
14
+ // - canWrite delegates to the shared canRestWriteNetwork
15
+ // - the 7 write tools that had NO network_id input gain an optional one:
16
+ // send_message / send_reply / ack_inbox / send_ack / retry_task /
17
+ // cancel_task / reassign_task
18
+ // - writeDeniedReply names the REAL cause: network_id_required (0 or ≥2
19
+ // memberships) / access_denied (not a member of the requested network) /
20
+ // permission_denied (viewer role)
21
+ // - ntok_ (network-bound) behavior: ZERO change — pinned below
22
+ //
23
+ // Assertion discipline: error replies are asserted with toEqual (accepted
24
+ // set == spec-allowed set, no not.toBe / toContain wideners).
25
+ //
26
+ // Run: COMMHUB_DB=/tmp/517-net-scope-test.db bun test src/mcp-write-network-resolution.test.ts
27
+
28
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
29
+ import { db, uuidv4 } from "./db.js";
30
+ import { deleteNetwork } from "./auth.js";
31
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
32
+ import { registerTools } from "./tools.js";
33
+
34
+ const NET_A = "net_517_a";
35
+ const NET_B = "net_517_b";
36
+ const U_SINGLE = "u517_single"; // owner of NET_A only → fallback resolves
37
+ const U_MULTI = "u517_multi"; // member of NET_A + NET_B → ambiguous
38
+ const U_VIEWER = "u517_viewer"; // viewer of NET_A only → resolves, then role-denied
39
+ const U_NONE = "u517_none"; // no memberships
40
+ const U_OUTSIDER = "u517_outsider"; // member of NET_B only, targets NET_A
41
+ const A1 = "peer-517-a1";
42
+ const A2 = "peer-517-a2";
43
+ const ALL_USERS = [U_SINGLE, U_MULTI, U_VIEWER, U_NONE, U_OUTSIDER];
44
+
45
+ interface ToolHandler {
46
+ (args: any, extra?: any): Promise<{ content: Array<{ type: "text"; text: string }> }>;
47
+ }
48
+ interface Reply { ok?: boolean; error?: string; message?: string; [k: string]: unknown }
49
+
50
+ function cleanup() {
51
+ for (const t of ["tasks", "inbox", "task_events"]) {
52
+ try { db.run(`DELETE FROM ${t} WHERE network_id IN (?1, ?2)`, [NET_A, NET_B]); } catch {}
53
+ try { db.run(`DELETE FROM ${t} WHERE content LIKE 'x517%'`); } catch {}
54
+ }
55
+ try { db.run("DELETE FROM sessions WHERE network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {}
56
+ try { db.run("DELETE FROM nodes WHERE network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {}
57
+ try { db.run("DELETE FROM network_members WHERE network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {}
58
+ try { db.run("DELETE FROM networks WHERE network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {}
59
+ for (const u of ALL_USERS) {
60
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [u]); } catch {}
61
+ }
62
+ }
63
+
64
+ function seed() {
65
+ for (const u of ALL_USERS) {
66
+ db.run(
67
+ `INSERT INTO users (user_id, username, password_hash, role, created_at)
68
+ VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
69
+ [u, u],
70
+ );
71
+ }
72
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET_A, U_SINGLE]);
73
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET_B, U_MULTI]);
74
+ const member = (u: string, net: string, role: string) =>
75
+ db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, ?3, datetime('now'))`, [u, net, role]);
76
+ member(U_SINGLE, NET_A, "owner");
77
+ member(U_MULTI, NET_A, "member");
78
+ member(U_MULTI, NET_B, "owner");
79
+ member(U_VIEWER, NET_A, "viewer");
80
+ member(U_OUTSIDER, NET_B, "member");
81
+ for (const alias of [A1, A2]) {
82
+ db.run(
83
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state)
84
+ VALUES (?1, ?2, ?2, ?3, ?4, datetime('now'), datetime('now'), 'active')`,
85
+ [`node_${alias}`, alias, NET_A, `host-${alias}`],
86
+ );
87
+ db.run(
88
+ `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at)
89
+ VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`,
90
+ [`res_${alias}`, alias, `node_${alias}`, NET_A],
91
+ );
92
+ }
93
+ }
94
+
95
+ beforeEach(() => { cleanup(); seed(); });
96
+ afterAll(cleanup);
97
+
98
+ type HandlerMap = { tools: Record<string, ToolHandler>; schemas: Record<string, Record<string, unknown>> };
99
+
100
+ function buildHandlers(opts: { netId?: string | null; userId?: string | null; alias?: string | null; isNetwork?: boolean }): HandlerMap {
101
+ const server = new McpServer({ name: "test", version: "0" }) as any;
102
+ const tools: Record<string, ToolHandler> = {};
103
+ const schemas: Record<string, Record<string, unknown>> = {};
104
+ const origTool = server.tool.bind(server);
105
+ server.tool = (name: string, _desc: string, schema: any, handler: ToolHandler) => {
106
+ tools[name] = handler;
107
+ schemas[name] = schema;
108
+ return origTool(name, _desc, schema, handler);
109
+ };
110
+ registerTools(
111
+ server,
112
+ undefined,
113
+ opts.netId ?? null,
114
+ opts.userId ?? null,
115
+ opts.alias ?? opts.userId ?? null,
116
+ opts.isNetwork ?? false,
117
+ null,
118
+ );
119
+ return { tools, schemas };
120
+ }
121
+
122
+ async function call(handler: ToolHandler, args: any): Promise<Reply> {
123
+ const r = await handler(args);
124
+ return JSON.parse(r.content[0].text) as Reply;
125
+ }
126
+
127
+ function makeTask(to: string, status: string, content: string, net: string | null = NET_A): string {
128
+ const id = uuidv4();
129
+ db.run(
130
+ `INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, network_id)
131
+ VALUES (?1, 'dispatcher', ?2, 'normal', ?3, ?4, 'reply', datetime('now'), ?5)`,
132
+ [id, to, status, content, net],
133
+ );
134
+ return id;
135
+ }
136
+
137
+ function makeInbox(to: string, content: string, net: string | null = NET_A): string {
138
+ const id = uuidv4();
139
+ db.run(
140
+ `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, network_id)
141
+ VALUES (?1, ?2, ?3, 'message', 'normal', ?4, 'dispatcher', ?5)`,
142
+ [id, to, `node_${to}`, content, net],
143
+ );
144
+ return id;
145
+ }
146
+
147
+ const taskRow = (content: string) =>
148
+ db.get<{ task_id: string; status: string; to_name: string; network_id: string | null }>(
149
+ "SELECT task_id, status, to_name, network_id FROM tasks WHERE content = ?1", content);
150
+ const taskById = (id: string) =>
151
+ db.get<{ status: string; to_name: string; network_id: string | null }>(
152
+ "SELECT status, to_name, network_id FROM tasks WHERE task_id = ?1", id);
153
+ const inboxRow = (content: string) =>
154
+ db.get<{ id: string; acked: number; network_id: string | null }>(
155
+ "SELECT id, acked, network_id FROM inbox WHERE content = ?1", content);
156
+
157
+ // ─────────────────────────────────────────────────────────────────────
158
+ // Group 1 — utok, single network, NO network_id argument anywhere.
159
+ // The hub must auto-resolve to the user's only membership (REST parity
160
+ // with server.ts POST /api/task → singleNetworkId fallback).
161
+ // ─────────────────────────────────────────────────────────────────────
162
+ describe("utok single-network auto-resolve (no network_id argument)", () => {
163
+ test("send_task resolves to the only membership", async () => {
164
+ const { tools } = buildHandlers({ userId: U_SINGLE });
165
+ const r = await call(tools["send_task"], { alias: A1, task: "x517 g1 send_task", priority: "normal" });
166
+ expect(r.ok).toBe(true);
167
+ expect(taskRow("x517 g1 send_task")?.network_id).toBe(NET_A);
168
+ });
169
+
170
+ test("send_message resolves to the only membership", async () => {
171
+ const { tools } = buildHandlers({ userId: U_SINGLE });
172
+ const r = await call(tools["send_message"], { alias: A1, message: "x517 g1 send_message" });
173
+ expect(r.ok).toBe(true);
174
+ expect(inboxRow("x517 g1 send_message")?.network_id).toBe(NET_A);
175
+ });
176
+
177
+ test("send_reply resolves to the only membership", async () => {
178
+ const { tools } = buildHandlers({ userId: U_SINGLE });
179
+ const t = makeTask(A1, "delivered", "x517 g1 send_reply parent");
180
+ const r = await call(tools["send_reply"], { alias: A1, text: "x517 g1 send_reply", in_reply_to: t, status: "replied" });
181
+ expect(r.ok).toBe(true);
182
+ expect(taskById(t)?.status).toBe("replied");
183
+ });
184
+
185
+ test("ack_inbox resolves to the only membership", async () => {
186
+ const { tools } = buildHandlers({ userId: U_SINGLE });
187
+ const ib = makeInbox(A1, "x517 g1 ack_inbox");
188
+ const r = await call(tools["ack_inbox"], { alias: A1, message_id: ib });
189
+ expect(r.ok).toBe(true);
190
+ expect(inboxRow("x517 g1 ack_inbox")?.acked).toBe(1);
191
+ });
192
+
193
+ test("send_ack resolves to the only membership", async () => {
194
+ const { tools } = buildHandlers({ userId: U_SINGLE });
195
+ const t = makeTask(A1, "delivered", "x517 g1 send_ack");
196
+ const r = await call(tools["send_ack"], { task_id: t });
197
+ expect(r.ok).toBe(true);
198
+ expect(taskById(t)?.status).toBe("acked");
199
+ });
200
+
201
+ test("retry_task resolves to the only membership", async () => {
202
+ const { tools } = buildHandlers({ userId: U_SINGLE });
203
+ const t = makeTask(A1, "failed", "x517 g1 retry_task");
204
+ const r = await call(tools["retry_task"], { task_id: t });
205
+ expect(r.ok).toBe(true);
206
+ expect(taskById(t)?.status).toBe("delivered");
207
+ });
208
+
209
+ test("cancel_task resolves to the only membership", async () => {
210
+ const { tools } = buildHandlers({ userId: U_SINGLE });
211
+ const t = makeTask(A1, "delivered", "x517 g1 cancel_task");
212
+ const r = await call(tools["cancel_task"], { task_id: t });
213
+ expect(r.ok).toBe(true);
214
+ expect(taskById(t)?.status).toBe("cancelled");
215
+ });
216
+
217
+ test("reassign_task resolves to the only membership", async () => {
218
+ const { tools } = buildHandlers({ userId: U_SINGLE });
219
+ const t = makeTask(A1, "delivered", "x517 g1 reassign_task");
220
+ const r = await call(tools["reassign_task"], { task_id: t, new_alias: A2 });
221
+ expect(r.ok).toBe(true);
222
+ expect(taskById(t)?.to_name).toBe(A2);
223
+ });
224
+ });
225
+
226
+ // ─────────────────────────────────────────────────────────────────────
227
+ // Group 2 — utok, MULTI network. Without network_id: precise
228
+ // network_id_required error (not permission_denied). With explicit
229
+ // network_id: every formerly param-less tool must thread it through.
230
+ // One case per changed tool (通信龙 acceptance rule: 改了三个只测一个,
231
+ // 剩下两个等于没验).
232
+ // ─────────────────────────────────────────────────────────────────────
233
+ describe("utok multi-network", () => {
234
+ test("send_task without network_id → exact network_id_required error", async () => {
235
+ const { tools } = buildHandlers({ userId: U_MULTI });
236
+ const r = await call(tools["send_task"], { alias: A1, task: "x517 g2 ambiguous", priority: "normal" });
237
+ expect(r).toEqual({
238
+ ok: false,
239
+ error: "network_id_required",
240
+ message: "user token spans 2 networks; pass network_id explicitly (see /api/auth/me networks[].network_id)",
241
+ });
242
+ expect(taskRow("x517 g2 ambiguous")).toBeNull();
243
+ });
244
+
245
+ test("send_message threads explicit network_id", async () => {
246
+ const { tools } = buildHandlers({ userId: U_MULTI });
247
+ const r = await call(tools["send_message"], { alias: A1, message: "x517 g2 send_message", network_id: NET_A });
248
+ expect(r.ok).toBe(true);
249
+ expect(inboxRow("x517 g2 send_message")?.network_id).toBe(NET_A);
250
+ });
251
+
252
+ test("send_reply threads explicit network_id", async () => {
253
+ const { tools } = buildHandlers({ userId: U_MULTI });
254
+ const t = makeTask(A1, "delivered", "x517 g2 send_reply parent");
255
+ const r = await call(tools["send_reply"], { alias: A1, text: "x517 g2 send_reply", in_reply_to: t, status: "replied", network_id: NET_A });
256
+ expect(r.ok).toBe(true);
257
+ expect(taskById(t)?.status).toBe("replied");
258
+ });
259
+
260
+ test("ack_inbox threads explicit network_id", async () => {
261
+ const { tools } = buildHandlers({ userId: U_MULTI });
262
+ const ib = makeInbox(A1, "x517 g2 ack_inbox");
263
+ const r = await call(tools["ack_inbox"], { alias: A1, message_id: ib, network_id: NET_A });
264
+ expect(r.ok).toBe(true);
265
+ expect(inboxRow("x517 g2 ack_inbox")?.acked).toBe(1);
266
+ });
267
+
268
+ test("send_ack threads explicit network_id", async () => {
269
+ const { tools } = buildHandlers({ userId: U_MULTI });
270
+ const t = makeTask(A1, "delivered", "x517 g2 send_ack");
271
+ const r = await call(tools["send_ack"], { task_id: t, network_id: NET_A });
272
+ expect(r.ok).toBe(true);
273
+ expect(taskById(t)?.status).toBe("acked");
274
+ });
275
+
276
+ test("retry_task threads explicit network_id", async () => {
277
+ const { tools } = buildHandlers({ userId: U_MULTI });
278
+ const t = makeTask(A1, "failed", "x517 g2 retry_task");
279
+ const r = await call(tools["retry_task"], { task_id: t, network_id: NET_A });
280
+ expect(r.ok).toBe(true);
281
+ expect(taskById(t)?.status).toBe("delivered");
282
+ });
283
+
284
+ test("cancel_task threads explicit network_id", async () => {
285
+ const { tools } = buildHandlers({ userId: U_MULTI });
286
+ const t = makeTask(A1, "delivered", "x517 g2 cancel_task");
287
+ const r = await call(tools["cancel_task"], { task_id: t, network_id: NET_A });
288
+ expect(r.ok).toBe(true);
289
+ expect(taskById(t)?.status).toBe("cancelled");
290
+ });
291
+
292
+ test("reassign_task threads explicit network_id", async () => {
293
+ const { tools } = buildHandlers({ userId: U_MULTI });
294
+ const t = makeTask(A1, "delivered", "x517 g2 reassign_task");
295
+ const r = await call(tools["reassign_task"], { task_id: t, new_alias: A2, network_id: NET_A });
296
+ expect(r.ok).toBe(true);
297
+ expect(taskById(t)?.to_name).toBe(A2);
298
+ });
299
+
300
+ test("all 7 formerly param-less write tools expose network_id in their input schema", () => {
301
+ const { schemas } = buildHandlers({ userId: U_MULTI });
302
+ for (const name of ["send_message", "send_reply", "ack_inbox", "send_ack", "retry_task", "cancel_task", "reassign_task"]) {
303
+ expect(Object.keys(schemas[name] ?? {})).toContain("network_id");
304
+ }
305
+ });
306
+ });
307
+
308
+ // ─────────────────────────────────────────────────────────────────────
309
+ // Group 3 — precise causes replace the old catch-all message.
310
+ // ─────────────────────────────────────────────────────────────────────
311
+ describe("precise denial causes", () => {
312
+ test("utok with zero memberships → network_id_required naming the real cause", async () => {
313
+ const { tools } = buildHandlers({ userId: U_NONE });
314
+ const r = await call(tools["send_task"], { alias: A1, task: "x517 g3 none", priority: "normal" });
315
+ expect(r).toEqual({
316
+ ok: false,
317
+ error: "network_id_required",
318
+ message: "user token has no network memberships; join or create a network first",
319
+ });
320
+ });
321
+
322
+ test("utok explicitly targeting a network it is not a member of → access_denied", async () => {
323
+ const { tools } = buildHandlers({ userId: U_OUTSIDER });
324
+ const r = await call(tools["send_task"], { alias: A1, task: "x517 g3 outsider", priority: "normal", network_id: NET_A });
325
+ expect(r).toEqual({
326
+ ok: false,
327
+ error: "access_denied",
328
+ message: "access denied to requested network (not a member)",
329
+ });
330
+ expect(taskRow("x517 g3 outsider")).toBeNull();
331
+ });
332
+
333
+ test("viewer in a single network → permission_denied (send_task wording)", async () => {
334
+ const { tools } = buildHandlers({ userId: U_VIEWER });
335
+ const r = await call(tools["send_task"], { alias: A1, task: "x517 g3 viewer task", priority: "normal" });
336
+ expect(r).toEqual({
337
+ ok: false,
338
+ error: "permission_denied",
339
+ message: "Viewer role cannot send tasks",
340
+ });
341
+ });
342
+
343
+ test("viewer in a single network → permission_denied (generic write wording)", async () => {
344
+ const { tools } = buildHandlers({ userId: U_VIEWER });
345
+ const r = await call(tools["send_message"], { alias: A1, message: "x517 g3 viewer msg" });
346
+ expect(r).toEqual({
347
+ ok: false,
348
+ error: "permission_denied",
349
+ message: "Viewer role cannot write to this network",
350
+ });
351
+ });
352
+ });
353
+
354
+ // ─────────────────────────────────────────────────────────────────────
355
+ // Group 3b — ghost memberships (PR #519 codex P2): deleteNetwork used to
356
+ // leave network_members rows behind, so a stale membership to a deleted
357
+ // network could (a) make a single-network user look multi-network, or
358
+ // (b) auto-resolve a write INTO the deleted network, creating orphaned
359
+ // rows. getUserNetworkIds now joins networks; deleteNetwork now removes
360
+ // its membership rows.
361
+ // ─────────────────────────────────────────────────────────────────────
362
+ describe("ghost memberships (deleted networks)", () => {
363
+ const GHOST = "net_517_ghost";
364
+
365
+ test("membership to a deleted network does not break single-network auto-resolve", async () => {
366
+ // stale row: membership exists, networks row does not
367
+ db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [U_SINGLE, GHOST]);
368
+ const { tools } = buildHandlers({ userId: U_SINGLE });
369
+ const r = await call(tools["send_task"], { alias: A1, task: "x517 ghost still-single", priority: "normal" });
370
+ expect(r.ok).toBe(true);
371
+ expect(taskRow("x517 ghost still-single")?.network_id).toBe(NET_A);
372
+ db.run("DELETE FROM network_members WHERE network_id = ?1", [GHOST]);
373
+ });
374
+
375
+ test("sole membership pointing at a deleted network → no-membership error, no orphaned write", async () => {
376
+ db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [U_NONE, GHOST]);
377
+ const { tools } = buildHandlers({ userId: U_NONE });
378
+ const r = await call(tools["send_task"], { alias: A1, task: "x517 ghost orphan", priority: "normal" });
379
+ expect(r).toEqual({
380
+ ok: false,
381
+ error: "network_id_required",
382
+ message: "user token has no network memberships; join or create a network first",
383
+ });
384
+ expect(taskRow("x517 ghost orphan")).toBeNull();
385
+ db.run("DELETE FROM network_members WHERE network_id = ?1", [GHOST]);
386
+ });
387
+
388
+ test("deleteNetwork removes the network's membership rows (root cause)", () => {
389
+ const NET_DEL = "net_517_del";
390
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET_DEL, U_SINGLE]);
391
+ db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [U_SINGLE, NET_DEL]);
392
+ const res = deleteNetwork(U_SINGLE, NET_DEL);
393
+ expect(res.ok).toBe(true);
394
+ const left = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM network_members WHERE network_id = ?1", NET_DEL);
395
+ expect(left?.cnt).toBe(0);
396
+ });
397
+ });
398
+
399
+ // ─────────────────────────────────────────────────────────────────────
400
+ // Group 4 — ntok_ pins: network-bound tokens must see ZERO change.
401
+ // ─────────────────────────────────────────────────────────────────────
402
+ describe("ntok behavior pinned (zero change)", () => {
403
+ test("ntok send_task without network_id stays bound to token network", async () => {
404
+ const { tools } = buildHandlers({ netId: NET_A, userId: U_SINGLE, alias: A1, isNetwork: true });
405
+ const r = await call(tools["send_task"], { alias: A2, task: "x517 g4 ntok plain", priority: "normal" });
406
+ expect(r.ok).toBe(true);
407
+ expect(taskRow("x517 g4 ntok plain")?.network_id).toBe(NET_A);
408
+ });
409
+
410
+ test("ntok send_task ignores a client-supplied foreign network_id (enforced binding wins)", async () => {
411
+ const { tools } = buildHandlers({ netId: NET_A, userId: U_MULTI, alias: A1, isNetwork: true });
412
+ const r = await call(tools["send_task"], { alias: A2, task: "x517 g4 ntok override", priority: "normal", network_id: NET_B });
413
+ expect(r.ok).toBe(true);
414
+ expect(taskRow("x517 g4 ntok override")?.network_id).toBe(NET_A);
415
+ });
416
+ });
417
+
418
+ // ─────────────────────────────────────────────────────────────────────
419
+ // Group 5 — legacy global-token mode (no enforceUserId): the fallback
420
+ // must NOT kick in; writes stay unscoped exactly as before.
421
+ // ─────────────────────────────────────────────────────────────────────
422
+ describe("legacy global-token mode pinned", () => {
423
+ test("send_message without auth context stays network-unscoped", async () => {
424
+ const { tools } = buildHandlers({ userId: null });
425
+ const r = await call(tools["send_message"], { alias: A1, message: "x517 g5 legacy" });
426
+ expect(r.ok).toBe(true);
427
+ expect(inboxRow("x517 g5 legacy")?.network_id).toBeNull();
428
+ });
429
+ });
@@ -0,0 +1,81 @@
1
+ // Network-scope resolution shared by BOTH transports.
2
+ //
3
+ // #517: REST (server.ts) and MCP (tools.ts) each carried their own copy of
4
+ // the "which network does this caller write to" rule. REST grew a
5
+ // single-network fallback (POST /api/task); MCP never did — so a utok_
6
+ // caller could read everything but write nothing, with a misleading
7
+ // permission_denied. Extracted here (same pattern as lifecycle-guard.ts)
8
+ // so the rule has exactly ONE implementation. If you change write-scope
9
+ // semantics, this module is the only place — do NOT re-inline a copy in
10
+ // server.ts or tools.ts.
11
+
12
+ import { db } from "./db.js";
13
+ import { getUserNetworkRole } from "./auth.js";
14
+
15
+ export type RestNetworkScope = {
16
+ networkId: string | null;
17
+ networkIds: string[] | null;
18
+ denied?: string;
19
+ };
20
+
21
+ export function getUserNetworkIds(userId: string): string[] {
22
+ // JOIN networks: deleteNetwork historically left network_members rows
23
+ // behind (fixed alongside PR #519, but legacy DBs still carry strays).
24
+ // A ghost membership must neither make a single-network user look
25
+ // ambiguous nor let the write fallback resolve INTO a deleted network.
26
+ return db.all<{ network_id: string }>(
27
+ `SELECT m.network_id FROM network_members m
28
+ JOIN networks n ON n.network_id = m.network_id
29
+ WHERE m.user_id = ?1`,
30
+ userId
31
+ ).map((row) => row.network_id);
32
+ }
33
+
34
+ export function resolveRestNetworkScope(requested: string | null, authCtx: { userId: string; networkId: string | null } | null, isAdmin: boolean): RestNetworkScope {
35
+ // Legacy global token or open dev mode keeps the old global behavior.
36
+ if (!authCtx) return { networkId: requested || null, networkIds: null };
37
+
38
+ // Network tokens are forcibly scoped to their bound network.
39
+ if (authCtx.networkId) return { networkId: authCtx.networkId, networkIds: null };
40
+
41
+ // System admins may intentionally inspect all networks.
42
+ if (isAdmin) return { networkId: requested || null, networkIds: null };
43
+
44
+ if (requested) {
45
+ const role = getUserNetworkRole(authCtx.userId, requested);
46
+ if (!role) return { networkId: null, networkIds: [], denied: "access denied to requested network" };
47
+ return { networkId: requested, networkIds: null };
48
+ }
49
+
50
+ return { networkId: null, networkIds: getUserNetworkIds(authCtx.userId) };
51
+ }
52
+
53
+ export function addNetworkScope(sql: string, params: any[], scope: RestNetworkScope, column = "network_id"): string {
54
+ if (scope.networkId) {
55
+ sql += ` AND ${column} = ?${params.length + 1}`;
56
+ params.push(scope.networkId);
57
+ } else if (scope.networkIds) {
58
+ if (scope.networkIds.length === 0) {
59
+ sql += " AND 1=0";
60
+ } else {
61
+ const placeholders = scope.networkIds.map((_, i) => `?${params.length + i + 1}`).join(", ");
62
+ sql += ` AND ${column} IN (${placeholders})`;
63
+ params.push(...scope.networkIds);
64
+ }
65
+ }
66
+ return sql;
67
+ }
68
+
69
+ export function singleNetworkId(scope: RestNetworkScope): string | null {
70
+ if (scope.networkId) return scope.networkId;
71
+ if (scope.networkIds?.length === 1) return scope.networkIds[0];
72
+ return null;
73
+ }
74
+
75
+ export function canRestWriteNetwork(authCtx: { userId: string; networkId: string | null } | null, networkId: string | null, isAdmin: boolean): boolean {
76
+ if (!authCtx) return true; // legacy global token or open dev mode
77
+ if (isAdmin) return true;
78
+ if (!networkId) return false;
79
+ const role = getUserNetworkRole(authCtx.userId, networkId);
80
+ return !!role && role !== "viewer";
81
+ }