// #517 — MCP write-path network resolution (utok single-network fallback). // // Root cause (issue #517): utok_ token rows are created with network_id=null // BY DESIGN (auth.ts login), and the MCP write path (tools.ts canWrite) had // no single-network fallback — unlike REST POST /api/task (server.ts), which // auto-resolves via singleNetworkId(restScope) when the user belongs to // exactly one network. Result: a utok node could READ everything // (resolveReadScope falls back to all memberships) but could not WRITE // anything, and the error mislabeled the failure as permission_denied. // // Fix under test: // - getNetworkId falls back to the user's single membership via the SAME // shared singleNetworkId/getUserNetworkIds used by REST (network-scope.ts) // - canWrite delegates to the shared canRestWriteNetwork // - the 7 write tools that had NO network_id input gain an optional one: // send_message / send_reply / ack_inbox / send_ack / retry_task / // cancel_task / reassign_task // - writeDeniedReply names the REAL cause: network_id_required (0 or ≥2 // memberships) / access_denied (not a member of the requested network) / // permission_denied (viewer role) // - ntok_ (network-bound) behavior: ZERO change — pinned below // // Assertion discipline: error replies are asserted with toEqual (accepted // set == spec-allowed set, no not.toBe / toContain wideners). // // Run: COMMHUB_DB=/tmp/517-net-scope-test.db bun test src/mcp-write-network-resolution.test.ts import { afterAll, beforeEach, describe, expect, test } from "bun:test"; import { db, uuidv4 } from "./db.js"; import { deleteNetwork } from "./auth.js"; import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerTools } from "./tools.js"; const NET_A = "net_517_a"; const NET_B = "net_517_b"; const U_SINGLE = "u517_single"; // owner of NET_A only → fallback resolves const U_MULTI = "u517_multi"; // member of NET_A + NET_B → ambiguous const U_VIEWER = "u517_viewer"; // viewer of NET_A only → resolves, then role-denied const U_NONE = "u517_none"; // no memberships const U_OUTSIDER = "u517_outsider"; // member of NET_B only, targets NET_A const A1 = "peer-517-a1"; const A2 = "peer-517-a2"; const ALL_USERS = [U_SINGLE, U_MULTI, U_VIEWER, U_NONE, U_OUTSIDER]; interface ToolHandler { (args: any, extra?: any): Promise<{ content: Array<{ type: "text"; text: string }> }>; } interface Reply { ok?: boolean; error?: string; message?: string; [k: string]: unknown } function cleanup() { for (const t of ["tasks", "inbox", "task_events"]) { try { db.run(`DELETE FROM ${t} WHERE network_id IN (?1, ?2)`, [NET_A, NET_B]); } catch {} try { db.run(`DELETE FROM ${t} WHERE content LIKE 'x517%'`); } catch {} } try { db.run("DELETE FROM sessions WHERE network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {} try { db.run("DELETE FROM nodes WHERE network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {} try { db.run("DELETE FROM network_members WHERE network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {} try { db.run("DELETE FROM networks WHERE network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {} for (const u of ALL_USERS) { try { db.run("DELETE FROM users WHERE user_id = ?1", [u]); } catch {} } } function seed() { for (const u of ALL_USERS) { db.run( `INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))`, [u, u], ); } db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET_A, U_SINGLE]); db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET_B, U_MULTI]); const member = (u: string, net: string, role: string) => db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, ?3, datetime('now'))`, [u, net, role]); member(U_SINGLE, NET_A, "owner"); member(U_MULTI, NET_A, "member"); member(U_MULTI, NET_B, "owner"); member(U_VIEWER, NET_A, "viewer"); member(U_OUTSIDER, NET_B, "member"); for (const alias of [A1, A2]) { db.run( `INSERT INTO nodes (node_id, node_name, alias, network_id, hostname, created_at, updated_at, lifecycle_state) VALUES (?1, ?2, ?2, ?3, ?4, datetime('now'), datetime('now'), 'active')`, [`node_${alias}`, alias, NET_A, `host-${alias}`], ); db.run( `INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at) VALUES (?1, ?2, 'idle', ?3, ?4, datetime('now'), datetime('now'))`, [`res_${alias}`, alias, `node_${alias}`, NET_A], ); } } beforeEach(() => { cleanup(); seed(); }); afterAll(cleanup); type HandlerMap = { tools: Record; schemas: Record> }; function buildHandlers(opts: { netId?: string | null; userId?: string | null; alias?: string | null; isNetwork?: boolean }): HandlerMap { const server = new McpServer({ name: "test", version: "0" }) as any; const tools: Record = {}; const schemas: Record> = {}; const origTool = server.tool.bind(server); server.tool = (name: string, _desc: string, schema: any, handler: ToolHandler) => { tools[name] = handler; schemas[name] = schema; return origTool(name, _desc, schema, handler); }; registerTools( server, undefined, opts.netId ?? null, opts.userId ?? null, opts.alias ?? opts.userId ?? null, opts.isNetwork ?? false, null, ); return { tools, schemas }; } async function call(handler: ToolHandler, args: any): Promise { const r = await handler(args); return JSON.parse(r.content[0].text) as Reply; } function makeTask(to: string, status: string, content: string, net: string | null = NET_A): string { const id = uuidv4(); db.run( `INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, network_id) VALUES (?1, 'dispatcher', ?2, 'normal', ?3, ?4, 'reply', datetime('now'), ?5)`, [id, to, status, content, net], ); return id; } function makeInbox(to: string, content: string, net: string | null = NET_A): string { const id = uuidv4(); db.run( `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, network_id) VALUES (?1, ?2, ?3, 'message', 'normal', ?4, 'dispatcher', ?5)`, [id, to, `node_${to}`, content, net], ); return id; } const taskRow = (content: string) => db.get<{ task_id: string; status: string; to_name: string; network_id: string | null }>( "SELECT task_id, status, to_name, network_id FROM tasks WHERE content = ?1", content); const taskById = (id: string) => db.get<{ status: string; to_name: string; network_id: string | null }>( "SELECT status, to_name, network_id FROM tasks WHERE task_id = ?1", id); const inboxRow = (content: string) => db.get<{ id: string; acked: number; network_id: string | null }>( "SELECT id, acked, network_id FROM inbox WHERE content = ?1", content); // ───────────────────────────────────────────────────────────────────── // Group 1 — utok, single network, NO network_id argument anywhere. // The hub must auto-resolve to the user's only membership (REST parity // with server.ts POST /api/task → singleNetworkId fallback). // ───────────────────────────────────────────────────────────────────── describe("utok single-network auto-resolve (no network_id argument)", () => { test("send_task resolves to the only membership", async () => { const { tools } = buildHandlers({ userId: U_SINGLE }); const r = await call(tools["send_task"], { alias: A1, task: "x517 g1 send_task", priority: "normal" }); expect(r.ok).toBe(true); expect(taskRow("x517 g1 send_task")?.network_id).toBe(NET_A); }); test("send_message resolves to the only membership", async () => { const { tools } = buildHandlers({ userId: U_SINGLE }); const r = await call(tools["send_message"], { alias: A1, message: "x517 g1 send_message" }); expect(r.ok).toBe(true); expect(inboxRow("x517 g1 send_message")?.network_id).toBe(NET_A); }); test("send_reply resolves to the only membership", async () => { const { tools } = buildHandlers({ userId: U_SINGLE }); const t = makeTask(A1, "delivered", "x517 g1 send_reply parent"); const r = await call(tools["send_reply"], { alias: A1, text: "x517 g1 send_reply", in_reply_to: t, status: "replied" }); expect(r.ok).toBe(true); expect(taskById(t)?.status).toBe("replied"); }); test("ack_inbox resolves to the only membership", async () => { const { tools } = buildHandlers({ userId: U_SINGLE }); const ib = makeInbox(A1, "x517 g1 ack_inbox"); const r = await call(tools["ack_inbox"], { alias: A1, message_id: ib }); expect(r.ok).toBe(true); expect(inboxRow("x517 g1 ack_inbox")?.acked).toBe(1); }); test("send_ack resolves to the only membership", async () => { const { tools } = buildHandlers({ userId: U_SINGLE }); const t = makeTask(A1, "delivered", "x517 g1 send_ack"); const r = await call(tools["send_ack"], { task_id: t }); expect(r.ok).toBe(true); expect(taskById(t)?.status).toBe("acked"); }); test("retry_task resolves to the only membership", async () => { const { tools } = buildHandlers({ userId: U_SINGLE }); const t = makeTask(A1, "failed", "x517 g1 retry_task"); const r = await call(tools["retry_task"], { task_id: t }); expect(r.ok).toBe(true); expect(taskById(t)?.status).toBe("delivered"); }); test("cancel_task resolves to the only membership", async () => { const { tools } = buildHandlers({ userId: U_SINGLE }); const t = makeTask(A1, "delivered", "x517 g1 cancel_task"); const r = await call(tools["cancel_task"], { task_id: t }); expect(r.ok).toBe(true); expect(taskById(t)?.status).toBe("cancelled"); }); test("reassign_task resolves to the only membership", async () => { const { tools } = buildHandlers({ userId: U_SINGLE }); const t = makeTask(A1, "delivered", "x517 g1 reassign_task"); const r = await call(tools["reassign_task"], { task_id: t, new_alias: A2 }); expect(r.ok).toBe(true); expect(taskById(t)?.to_name).toBe(A2); }); }); // ───────────────────────────────────────────────────────────────────── // Group 2 — utok, MULTI network. Without network_id: precise // network_id_required error (not permission_denied). With explicit // network_id: every formerly param-less tool must thread it through. // One case per changed tool (通信龙 acceptance rule: 改了三个只测一个, // 剩下两个等于没验). // ───────────────────────────────────────────────────────────────────── describe("utok multi-network", () => { test("send_task without network_id → exact network_id_required error", async () => { const { tools } = buildHandlers({ userId: U_MULTI }); const r = await call(tools["send_task"], { alias: A1, task: "x517 g2 ambiguous", priority: "normal" }); expect(r).toEqual({ ok: false, error: "network_id_required", message: "user token spans 2 networks; pass network_id explicitly (see /api/auth/me networks[].network_id)", }); expect(taskRow("x517 g2 ambiguous")).toBeNull(); }); test("send_message threads explicit network_id", async () => { const { tools } = buildHandlers({ userId: U_MULTI }); const r = await call(tools["send_message"], { alias: A1, message: "x517 g2 send_message", network_id: NET_A }); expect(r.ok).toBe(true); expect(inboxRow("x517 g2 send_message")?.network_id).toBe(NET_A); }); test("send_reply threads explicit network_id", async () => { const { tools } = buildHandlers({ userId: U_MULTI }); const t = makeTask(A1, "delivered", "x517 g2 send_reply parent"); const r = await call(tools["send_reply"], { alias: A1, text: "x517 g2 send_reply", in_reply_to: t, status: "replied", network_id: NET_A }); expect(r.ok).toBe(true); expect(taskById(t)?.status).toBe("replied"); }); test("ack_inbox threads explicit network_id", async () => { const { tools } = buildHandlers({ userId: U_MULTI }); const ib = makeInbox(A1, "x517 g2 ack_inbox"); const r = await call(tools["ack_inbox"], { alias: A1, message_id: ib, network_id: NET_A }); expect(r.ok).toBe(true); expect(inboxRow("x517 g2 ack_inbox")?.acked).toBe(1); }); test("send_ack threads explicit network_id", async () => { const { tools } = buildHandlers({ userId: U_MULTI }); const t = makeTask(A1, "delivered", "x517 g2 send_ack"); const r = await call(tools["send_ack"], { task_id: t, network_id: NET_A }); expect(r.ok).toBe(true); expect(taskById(t)?.status).toBe("acked"); }); test("retry_task threads explicit network_id", async () => { const { tools } = buildHandlers({ userId: U_MULTI }); const t = makeTask(A1, "failed", "x517 g2 retry_task"); const r = await call(tools["retry_task"], { task_id: t, network_id: NET_A }); expect(r.ok).toBe(true); expect(taskById(t)?.status).toBe("delivered"); }); test("cancel_task threads explicit network_id", async () => { const { tools } = buildHandlers({ userId: U_MULTI }); const t = makeTask(A1, "delivered", "x517 g2 cancel_task"); const r = await call(tools["cancel_task"], { task_id: t, network_id: NET_A }); expect(r.ok).toBe(true); expect(taskById(t)?.status).toBe("cancelled"); }); test("reassign_task threads explicit network_id", async () => { const { tools } = buildHandlers({ userId: U_MULTI }); const t = makeTask(A1, "delivered", "x517 g2 reassign_task"); const r = await call(tools["reassign_task"], { task_id: t, new_alias: A2, network_id: NET_A }); expect(r.ok).toBe(true); expect(taskById(t)?.to_name).toBe(A2); }); test("all 7 formerly param-less write tools expose network_id in their input schema", () => { const { schemas } = buildHandlers({ userId: U_MULTI }); for (const name of ["send_message", "send_reply", "ack_inbox", "send_ack", "retry_task", "cancel_task", "reassign_task"]) { expect(Object.keys(schemas[name] ?? {})).toContain("network_id"); } }); }); // ───────────────────────────────────────────────────────────────────── // Group 3 — precise causes replace the old catch-all message. // ───────────────────────────────────────────────────────────────────── describe("precise denial causes", () => { test("utok with zero memberships → network_id_required naming the real cause", async () => { const { tools } = buildHandlers({ userId: U_NONE }); const r = await call(tools["send_task"], { alias: A1, task: "x517 g3 none", priority: "normal" }); expect(r).toEqual({ ok: false, error: "network_id_required", message: "user token has no network memberships; join or create a network first", }); }); test("utok explicitly targeting a network it is not a member of → access_denied", async () => { const { tools } = buildHandlers({ userId: U_OUTSIDER }); const r = await call(tools["send_task"], { alias: A1, task: "x517 g3 outsider", priority: "normal", network_id: NET_A }); expect(r).toEqual({ ok: false, error: "access_denied", message: "access denied to requested network (not a member)", }); expect(taskRow("x517 g3 outsider")).toBeNull(); }); test("viewer in a single network → permission_denied (send_task wording)", async () => { const { tools } = buildHandlers({ userId: U_VIEWER }); const r = await call(tools["send_task"], { alias: A1, task: "x517 g3 viewer task", priority: "normal" }); expect(r).toEqual({ ok: false, error: "permission_denied", message: "Viewer role cannot send tasks", }); }); test("viewer in a single network → permission_denied (generic write wording)", async () => { const { tools } = buildHandlers({ userId: U_VIEWER }); const r = await call(tools["send_message"], { alias: A1, message: "x517 g3 viewer msg" }); expect(r).toEqual({ ok: false, error: "permission_denied", message: "Viewer role cannot write to this network", }); }); }); // ───────────────────────────────────────────────────────────────────── // Group 3b — ghost memberships (PR #519 codex P2): deleteNetwork used to // leave network_members rows behind, so a stale membership to a deleted // network could (a) make a single-network user look multi-network, or // (b) auto-resolve a write INTO the deleted network, creating orphaned // rows. getUserNetworkIds now joins networks; deleteNetwork now removes // its membership rows. // ───────────────────────────────────────────────────────────────────── describe("ghost memberships (deleted networks)", () => { const GHOST = "net_517_ghost"; test("membership to a deleted network does not break single-network auto-resolve", async () => { // stale row: membership exists, networks row does not db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [U_SINGLE, GHOST]); const { tools } = buildHandlers({ userId: U_SINGLE }); const r = await call(tools["send_task"], { alias: A1, task: "x517 ghost still-single", priority: "normal" }); expect(r.ok).toBe(true); expect(taskRow("x517 ghost still-single")?.network_id).toBe(NET_A); db.run("DELETE FROM network_members WHERE network_id = ?1", [GHOST]); }); test("sole membership pointing at a deleted network → no-membership error, no orphaned write", async () => { db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [U_NONE, GHOST]); const { tools } = buildHandlers({ userId: U_NONE }); const r = await call(tools["send_task"], { alias: A1, task: "x517 ghost orphan", priority: "normal" }); expect(r).toEqual({ ok: false, error: "network_id_required", message: "user token has no network memberships; join or create a network first", }); expect(taskRow("x517 ghost orphan")).toBeNull(); db.run("DELETE FROM network_members WHERE network_id = ?1", [GHOST]); }); test("deleteNetwork removes the network's membership rows (root cause)", () => { const NET_DEL = "net_517_del"; db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET_DEL, U_SINGLE]); db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))`, [U_SINGLE, NET_DEL]); const res = deleteNetwork(U_SINGLE, NET_DEL); expect(res.ok).toBe(true); const left = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM network_members WHERE network_id = ?1", NET_DEL); expect(left?.cnt).toBe(0); }); }); // ───────────────────────────────────────────────────────────────────── // Group 4 — ntok_ pins: network-bound tokens must see ZERO change. // ───────────────────────────────────────────────────────────────────── describe("ntok behavior pinned (zero change)", () => { test("ntok send_task without network_id stays bound to token network", async () => { const { tools } = buildHandlers({ netId: NET_A, userId: U_SINGLE, alias: A1, isNetwork: true }); const r = await call(tools["send_task"], { alias: A2, task: "x517 g4 ntok plain", priority: "normal" }); expect(r.ok).toBe(true); expect(taskRow("x517 g4 ntok plain")?.network_id).toBe(NET_A); }); test("ntok send_task ignores a client-supplied foreign network_id (enforced binding wins)", async () => { const { tools } = buildHandlers({ netId: NET_A, userId: U_MULTI, alias: A1, isNetwork: true }); const r = await call(tools["send_task"], { alias: A2, task: "x517 g4 ntok override", priority: "normal", network_id: NET_B }); expect(r.ok).toBe(true); expect(taskRow("x517 g4 ntok override")?.network_id).toBe(NET_A); }); }); // ───────────────────────────────────────────────────────────────────── // Group 5 — legacy global-token mode (no enforceUserId): the fallback // must NOT kick in; writes stay unscoped exactly as before. // ───────────────────────────────────────────────────────────────────── describe("legacy global-token mode pinned", () => { test("send_message without auth context stays network-unscoped", async () => { const { tools } = buildHandlers({ userId: null }); const r = await call(tools["send_message"], { alias: A1, message: "x517 g5 legacy" }); expect(r.ok).toBe(true); expect(inboxRow("x517 g5 legacy")?.network_id).toBeNull(); }); });