@sleep2agi/commhub-server 0.9.0-preview.4 → 0.9.0-preview.40
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 +125 -7
- package/bin/commhub.ts +10 -2
- package/package.json +5 -5
- package/src/ack-create-request-transport.test.ts +159 -0
- package/src/ack-create-request.test.ts +331 -0
- package/src/admin-networks-http.test.ts +84 -0
- package/src/alias-filter.test.ts +72 -0
- package/src/alias-filter.ts +32 -0
- package/src/api-host-supervisors-fallback.test.ts +267 -0
- package/src/api-nodes-shape.test.ts +107 -13
- package/src/auth.ts +93 -14
- package/src/avatar-validate.test.ts +163 -0
- package/src/avatar-validate.ts +103 -0
- package/src/boot-side-effect.test.ts +124 -0
- package/src/config-apply-sec1.test.ts +107 -0
- package/src/config-apply-validate.test.ts +92 -0
- package/src/config-apply-validate.ts +81 -3
- package/src/create-node-tool-schema.test.ts +39 -0
- package/src/create-node-validate.test.ts +80 -1
- package/src/create-node-validate.ts +49 -5
- package/src/create-node.ts +51 -1
- package/src/dashboard-slash-routing-http.test.ts +103 -0
- package/src/db-adapter-guard.test.ts +85 -0
- package/src/db-adapter.ts +64 -23
- package/src/db-timestamp.test.ts +38 -0
- package/src/db-timestamp.ts +15 -0
- package/src/db.ts +622 -33
- package/src/delivered-without-push.test.ts +140 -0
- package/src/desktop-message.test.ts +286 -0
- package/src/ext-token-shared.test.ts +60 -0
- package/src/ext-validation-regression.test.ts +131 -0
- package/src/external-schedule-edits-http.test.ts +316 -0
- package/src/external-schedule-edits.ts +314 -0
- package/src/file-download-authz-dev-open.test.ts +135 -0
- package/src/file-download-authz.test.ts +403 -0
- package/src/file-download-date-bucket.test.ts +436 -0
- package/src/file-network-scope-dev-open.test.ts +97 -0
- package/src/file-network-scope.test.ts +731 -0
- package/src/health-redaction.test.ts +637 -0
- package/src/inbox-count-sse.test.ts +310 -0
- package/src/inbox-count.ts +29 -0
- package/src/index.ts +15 -2187
- package/src/lifecycle-guard.ts +50 -0
- package/src/list-host-supervisors.test.ts +314 -0
- package/src/mcp-write-network-resolution.test.ts +429 -0
- package/src/network-name-http.test.ts +71 -0
- package/src/network-scope.ts +101 -0
- package/src/node-attrs-validate.ts +88 -0
- package/src/node-attrs.test.ts +213 -0
- package/src/node-id-alias.test.ts +147 -0
- package/src/node-lifecycle-controllable.test.ts +64 -0
- package/src/node-lifecycle-controllable.ts +51 -0
- package/src/observer-avatar-http.test.ts +424 -0
- package/src/observer-push.test.ts +252 -0
- package/src/package-version-consistency.test.ts +25 -0
- package/src/peer-reply-atomic.test.ts +455 -0
- package/src/peer-reply-capability.test.ts +70 -0
- package/src/probe-validate.test.ts +221 -0
- package/src/probe-validate.ts +104 -0
- package/src/probe.test.ts +80 -0
- package/src/probe.ts +200 -0
- package/src/push.ts +163 -7
- package/src/reply-target-derivation.test.ts +96 -0
- package/src/require-explicit-test-db.ts +9 -0
- package/src/resolve-port.test.ts +57 -0
- package/src/resolve-port.ts +44 -0
- package/src/response-charset.test.ts +130 -0
- package/src/rest-explicit-columns-http.test.ts +212 -0
- package/src/rest-identity.test.ts +129 -0
- package/src/rest-identity.ts +100 -0
- package/src/rest-projections.ts +67 -0
- package/src/rest-write-network-resolution.test.ts +133 -0
- package/src/runtime-label.test.ts +22 -0
- package/src/runtime-label.ts +21 -0
- package/src/scheduled-run-terminal.test.ts +268 -0
- package/src/scheduled-tasks-http.test.ts +479 -0
- package/src/scheduled-tasks.ts +549 -0
- package/src/send-reply-agent-warning.test.ts +202 -0
- package/src/send-reply-alias-optional.test.ts +131 -0
- package/src/send-reply-attachments.test.ts +437 -0
- package/src/server.ts +3591 -0
- package/src/shared/external-schedule-contract.ts +60 -0
- package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
- package/src/shared/probe-host-allowlist.ts +128 -0
- package/src/side-thread-command-transport.test.ts +302 -0
- package/src/side-thread-command-transport.ts +318 -0
- package/src/side-thread-http-integration.test.ts +229 -0
- package/src/side-thread-http.ts +504 -0
- package/src/side-thread-production.test.ts +72 -0
- package/src/side-thread-production.ts +118 -0
- package/src/side-thread.test.ts +1067 -0
- package/src/side-thread.ts +2495 -0
- package/src/skillhub-http.test.ts +88 -0
- package/src/start-node.test.ts +109 -0
- package/src/stop-delete-node.test.ts +952 -0
- package/src/task-auth-origin-http.test.ts +87 -0
- package/src/task-auth-origin.test.ts +24 -0
- package/src/task-auth-origin.ts +7 -0
- package/src/task-consumption.test.ts +471 -0
- package/src/task-diagnostic-http.test.ts +145 -0
- package/src/task-diagnostic.test.ts +115 -0
- package/src/task-diagnostic.ts +118 -0
- package/src/task-idempotency-mcp.test.ts +142 -0
- package/src/task-idempotency.test.ts +60 -0
- package/src/task-idempotency.ts +75 -0
- package/src/task-lifecycle-watcher.test.ts +200 -0
- package/src/task-lifecycle-watcher.ts +49 -0
- package/src/task-network-resolution-http.test.ts +137 -0
- package/src/tasks-pagination-http.test.ts +137 -0
- package/src/tools.ts +2952 -191
- package/src/update-provider.test.ts +234 -0
- package/src/upload-early-reject-guard.test.ts +155 -0
- package/src/uploads-http.test.ts +92 -14
- package/src/uploads.test.ts +66 -0
- package/src/uploads.ts +130 -5
- package/src/vault.test.ts +200 -0
- package/src/vault.ts +156 -0
|
@@ -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,71 @@
|
|
|
1
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
import { createNetworkTokenForNode, register } from "./auth.js";
|
|
6
|
+
import { db } from "./db.js";
|
|
7
|
+
|
|
8
|
+
const PRIVATE_DB_DIR = mkdtempSync(join(tmpdir(), "anet-network-name-http-"));
|
|
9
|
+
let server: any;
|
|
10
|
+
let base = "";
|
|
11
|
+
let userToken = "";
|
|
12
|
+
let nodeToken = "";
|
|
13
|
+
let networkId = "";
|
|
14
|
+
let username = "";
|
|
15
|
+
|
|
16
|
+
beforeAll(async () => {
|
|
17
|
+
process.env.COMMHUB_DB = process.env.COMMHUB_DB || join(PRIVATE_DB_DIR, "hub.db");
|
|
18
|
+
username = `network_name_${Date.now()}_${process.pid}`;
|
|
19
|
+
const registered = register(username, "NetworkName123!", undefined, "seed");
|
|
20
|
+
expect(registered.ok).toBe(true);
|
|
21
|
+
userToken = registered.token!;
|
|
22
|
+
networkId = registered.network_id!;
|
|
23
|
+
const userId = db.get<{ user_id: string }>(
|
|
24
|
+
"SELECT user_id FROM users WHERE username = ?1",
|
|
25
|
+
[username],
|
|
26
|
+
)!.user_id;
|
|
27
|
+
const minted = createNetworkTokenForNode(userId, networkId, "network-name-node");
|
|
28
|
+
expect(minted.ok).toBe(true);
|
|
29
|
+
nodeToken = minted.token!;
|
|
30
|
+
const mod: any = await import("./server.js");
|
|
31
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
32
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
afterAll(() => {
|
|
36
|
+
try { server?.stop?.(true); } catch {}
|
|
37
|
+
try { rmSync(PRIVATE_DB_DIR, { recursive: true, force: true }); } catch {}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
async function networks(token: string): Promise<any[]> {
|
|
41
|
+
const response = await fetch(`${base}/api/networks`, {
|
|
42
|
+
headers: { Authorization: `Bearer ${token}` },
|
|
43
|
+
});
|
|
44
|
+
expect(response.status).toBe(200);
|
|
45
|
+
const body = await response.json() as any;
|
|
46
|
+
expect(body.ok).toBe(true);
|
|
47
|
+
return body.networks;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe("GET /api/networks name compatibility (#449)", () => {
|
|
51
|
+
test("utok rows expose equal non-null name and network_name", async () => {
|
|
52
|
+
const rows = await networks(userToken);
|
|
53
|
+
expect(rows).toHaveLength(1);
|
|
54
|
+
expect(rows[0].network_id).toBe(networkId);
|
|
55
|
+
// #449 is about `name` and `network_name` agreeing and being non-null.
|
|
56
|
+
// Pin them to the registered username rather than the literal "default":
|
|
57
|
+
// the auto-created network is named after its owner, so hardcoding the
|
|
58
|
+
// old literal would test the naming scheme instead of the compat shim.
|
|
59
|
+
expect(rows[0].network_name).toBe(username);
|
|
60
|
+
expect(rows[0].name).toBe(username);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("network-bound ntok rows expose the same alias", async () => {
|
|
64
|
+
const rows = await networks(nodeToken);
|
|
65
|
+
expect(rows).toHaveLength(1);
|
|
66
|
+
expect(rows[0].network_id).toBe(networkId);
|
|
67
|
+
expect(rows[0].network_name).toBe(username);
|
|
68
|
+
expect(rows[0].name).toBe(rows[0].network_name);
|
|
69
|
+
expect(rows[0].name).not.toBeNull();
|
|
70
|
+
});
|
|
71
|
+
});
|
|
@@ -0,0 +1,101 @@
|
|
|
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
|
+
/**
|
|
76
|
+
* Resolve one unambiguous network for a REST write.
|
|
77
|
+
*
|
|
78
|
+
* Admin read scope is intentionally global (`networkIds=null`), so it cannot
|
|
79
|
+
* by itself express that an admin happens to have exactly one membership.
|
|
80
|
+
* Writes must not inherit that ambiguity: use the sole real membership when
|
|
81
|
+
* one exists, and keep requiring an explicit network for zero or 2+.
|
|
82
|
+
*/
|
|
83
|
+
export function resolveRestWriteNetworkId(
|
|
84
|
+
scope: RestNetworkScope,
|
|
85
|
+
authCtx: { userId: string; networkId: string | null } | null,
|
|
86
|
+
isAdmin: boolean,
|
|
87
|
+
): string | null {
|
|
88
|
+
const scoped = singleNetworkId(scope);
|
|
89
|
+
if (scoped) return scoped;
|
|
90
|
+
if (!authCtx || !isAdmin) return null;
|
|
91
|
+
const memberships = getUserNetworkIds(authCtx.userId);
|
|
92
|
+
return memberships.length === 1 ? memberships[0] : null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function canRestWriteNetwork(authCtx: { userId: string; networkId: string | null } | null, networkId: string | null, isAdmin: boolean): boolean {
|
|
96
|
+
if (!authCtx) return true; // legacy global token or open dev mode
|
|
97
|
+
if (isAdmin) return true;
|
|
98
|
+
if (!networkId) return false;
|
|
99
|
+
const role = getUserNetworkRole(authCtx.userId, networkId);
|
|
100
|
+
return !!role && role !== "viewer";
|
|
101
|
+
}
|