@sleep2agi/commhub-server 0.9.0-preview.90 → 0.9.0-preview.92
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/package.json +1 -1
- package/src/auth.ts +40 -6
- package/src/department-heads-http.test.ts +329 -0
- package/src/department-heads.ts +226 -0
- package/src/departments.ts +7 -1
- package/src/mcp-context-cost-http.test.ts +117 -0
- package/src/network-token-membership-http.test.ts +139 -0
- package/src/node-permissions-http.test.ts +258 -0
- package/src/node-permissions.ts +242 -0
- package/src/observer-avatar-http.test.ts +7 -5
- package/src/requirement-notify.ts +31 -0
- package/src/requirements-errors.ts +2 -0
- package/src/requirements.ts +121 -8
- package/src/rest-explicit-columns-http.test.ts +2 -0
- package/src/server.ts +229 -43
- package/src/task-access.ts +108 -12
- package/src/tools.ts +107 -34
package/src/departments.ts
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
// 只有空部门(没有子部门、没有成员)能删;负责人必须是本网络成员;人离开网络后,作为负责人的引用读出来是 null。
|
|
12
12
|
|
|
13
13
|
import { db, logAudit } from "./db.js";
|
|
14
|
+
import { deleteDepartmentGrants, deleteDepartmentGrantsForNetwork } from "./department-heads.js";
|
|
14
15
|
|
|
15
16
|
try { db.exec("ALTER TABLE network_members ADD COLUMN department_id TEXT"); } catch {}
|
|
16
17
|
db.exec(`
|
|
@@ -210,7 +211,11 @@ export function deleteDepartment(networkId: string, id: string): Ok<{ deleted: s
|
|
|
210
211
|
const children = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM network_departments WHERE network_id = ?1 AND parent_id = ?2", networkId, id)?.n ?? 0;
|
|
211
212
|
const members = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM network_members WHERE network_id = ?1 AND department_id = ?2", networkId, id)?.n ?? 0;
|
|
212
213
|
if (children || members) return { ...fail(409, "department_not_empty"), children, members };
|
|
213
|
-
|
|
214
|
+
// RFC-040:部门的项目授权(network_department_project_grants)和部门同一事务删掉。
|
|
215
|
+
db.transaction(() => {
|
|
216
|
+
db.run("DELETE FROM network_departments WHERE network_id = ?1 AND department_id = ?2", [networkId, id]);
|
|
217
|
+
deleteDepartmentGrants(networkId, id);
|
|
218
|
+
});
|
|
214
219
|
return { ok: true, deleted: id };
|
|
215
220
|
}
|
|
216
221
|
|
|
@@ -226,6 +231,7 @@ export function setMemberDepartment(networkId: string, userId: string, departmen
|
|
|
226
231
|
/** 网络删掉时一起清(和项目授权同一时机调用)。 */
|
|
227
232
|
export function deleteDepartmentsForNetwork(networkId: string): void {
|
|
228
233
|
db.run("DELETE FROM network_departments WHERE network_id = ?1", [networkId]);
|
|
234
|
+
deleteDepartmentGrantsForNetwork(networkId);
|
|
229
235
|
}
|
|
230
236
|
|
|
231
237
|
export function auditDepartment(user: { user_id: string; username: string }, action: string, networkId: string, detail: string): void {
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// #476(#469 MCP 审计的最后一项)—— Hub MCP 面的上下文成本。
|
|
2
|
+
//
|
|
3
|
+
// 钉住:
|
|
4
|
+
// 1. tools/list 的总字节有上限(TOOLS_LIST_MAX_BYTES):描述 / 参数说明不能悄悄长回去。每个工具也有上限,
|
|
5
|
+
// 单条工具描述有上限。要加新工具 / 新说明,改这里的数字 —— 改的时候就会被看见。
|
|
6
|
+
// 2. requirements_events 不带 requirement_id(全网动态)默认 50 条、从新到旧、next_cursor 翻更早的;
|
|
7
|
+
// 带 requirement_id 的单卡调用不变(默认 200);REST /api/requirements/events 不变(默认 200,App 用它)。
|
|
8
|
+
// 3. requirements_events 参数严格:不认识的参数 → -32602,列出能用的参数。
|
|
9
|
+
//
|
|
10
|
+
// 跑法:cd server && COMMHUB_DB=/tmp/x.db bun test src/mcp-context-cost-http.test.ts
|
|
11
|
+
// PG:COMMHUB_TEST_PG_URL=… COMMHUB_PG_EXPERIMENTAL=1(tests/test2123-hub-postgres-ladder 里注册)
|
|
12
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
13
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
14
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
15
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
16
|
+
import { register } from "./auth.js";
|
|
17
|
+
import { registerTools } from "./tools.js";
|
|
18
|
+
|
|
19
|
+
const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
|
|
20
|
+
if (!activeDbPath) throw new Error("mcp-context-cost requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* tools/list 整份 JSON 的字节上限。#476 之前(main,含 #475)74 个工具 76,824 B;修剪后 71,399 B。
|
|
24
|
+
* 上限留 ~1.1 KB 余量;加工具或加说明超过它,就在这里改数字并在 PR 里说为什么。
|
|
25
|
+
*/
|
|
26
|
+
const TOOLS_LIST_MAX_BYTES = 72_500;
|
|
27
|
+
/** 单个工具(名字 + 描述 + 输入 schema)的上限;最大的是 report_status(节点心跳,~8.4 KB 的遥测结构)。 */
|
|
28
|
+
const TOOL_MAX_BYTES = 9_000;
|
|
29
|
+
/** 单条工具描述的上限(说明写进参数的 describe,或者拆短)。 */
|
|
30
|
+
const DESCRIPTION_MAX_BYTES = 1_000;
|
|
31
|
+
const NETWORK_EVENTS = 80;
|
|
32
|
+
|
|
33
|
+
let server: any;
|
|
34
|
+
let base = "";
|
|
35
|
+
let token = "", netId = "";
|
|
36
|
+
let close: () => Promise<void>;
|
|
37
|
+
let client: Client;
|
|
38
|
+
const call = async (name: string, args: Record<string, unknown>) => {
|
|
39
|
+
const r: any = await client.callTool({ name, arguments: args });
|
|
40
|
+
const text = String(r.content?.[0]?.text ?? "");
|
|
41
|
+
return r.isError ? { mcpError: true, text } : { ...JSON.parse(text), _bytes: Buffer.byteLength(text, "utf8") };
|
|
42
|
+
};
|
|
43
|
+
let busyId = "";
|
|
44
|
+
|
|
45
|
+
beforeAll(async () => {
|
|
46
|
+
const u = register(`ctx_${Date.now()}`, "CtxCost123!", undefined, "seed");
|
|
47
|
+
token = u.token!; netId = u.network_id!;
|
|
48
|
+
const s = new McpServer({ name: "ctx", version: "1" });
|
|
49
|
+
registerTools(s, undefined, null, u.user!.user_id, u.user!.username, false, "tok_user");
|
|
50
|
+
client = new Client({ name: "ctx-client", version: "1" });
|
|
51
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
52
|
+
await s.connect(st);
|
|
53
|
+
await client.connect(ct);
|
|
54
|
+
close = async () => { await client.close(); await s.close(); };
|
|
55
|
+
const mod: any = await import("./server.js");
|
|
56
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
57
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
58
|
+
// 一张卡上 60 条评论(单卡调用要能拿到 > 50 条),再加 20 张卡各一条
|
|
59
|
+
const busy = await call("requirements_create", { network_id: netId, name: "busy task" });
|
|
60
|
+
busyId = busy.requirement.id;
|
|
61
|
+
for (let i = 0; i < 60; i++) expect((await call("requirements_comment", { network_id: netId, id: busyId, text: `进展 ${i}:这一步完成了` })).ok).toBe(true);
|
|
62
|
+
for (let i = 0; i < NETWORK_EVENTS - 60; i++) {
|
|
63
|
+
const r = await call("requirements_create", { network_id: netId, name: `task ${i}` });
|
|
64
|
+
await call("requirements_comment", { network_id: netId, id: r.requirement.id, text: "已开始" });
|
|
65
|
+
}
|
|
66
|
+
}, 120_000);
|
|
67
|
+
afterAll(async () => { await close?.(); server?.stop?.(true); });
|
|
68
|
+
|
|
69
|
+
describe("#476 tools/list size", () => {
|
|
70
|
+
test("total, per tool and per description stay under their ceilings", async () => {
|
|
71
|
+
const { tools } = await client.listTools();
|
|
72
|
+
const total = Buffer.byteLength(JSON.stringify({ tools }), "utf8");
|
|
73
|
+
const sizes = tools.map((t: any) => ({ name: t.name, all: Buffer.byteLength(JSON.stringify(t)), desc: Buffer.byteLength(t.description ?? "") }));
|
|
74
|
+
sizes.sort((a, b) => b.all - a.all);
|
|
75
|
+
console.log(`tools/list: ${tools.length} tools, ${total} B; largest ${sizes.slice(0, 3).map(x => `${x.name}=${x.all}`).join(", ")}`);
|
|
76
|
+
expect(total).toBeLessThanOrEqual(TOOLS_LIST_MAX_BYTES);
|
|
77
|
+
for (const x of sizes) {
|
|
78
|
+
expect(x.all, x.name).toBeLessThanOrEqual(TOOL_MAX_BYTES);
|
|
79
|
+
expect(x.desc, x.name).toBeLessThanOrEqual(DESCRIPTION_MAX_BYTES);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("#476 requirements_events defaults", () => {
|
|
85
|
+
test("network-wide: 50 newest by default, next_cursor pages older events", async () => {
|
|
86
|
+
const p1 = await call("requirements_events", { network_id: netId });
|
|
87
|
+
expect(p1.events.length).toBe(50);
|
|
88
|
+
expect(typeof p1.next_cursor).toBe("string");
|
|
89
|
+
const times = p1.events.map((e: any) => e.at ?? e.created_at);
|
|
90
|
+
expect([...times].sort().reverse()).toEqual(times);
|
|
91
|
+
const p2 = await call("requirements_events", { network_id: netId, cursor: p1.next_cursor });
|
|
92
|
+
const ids = new Set([...p1.events, ...p2.events].map((e: any) => e.id));
|
|
93
|
+
expect(ids.size).toBe(p1.events.length + p2.events.length);
|
|
94
|
+
console.log(`network events page of 50: ${p1._bytes} B`);
|
|
95
|
+
expect(p1._bytes).toBeLessThan(25_000);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("an explicit limit still wins; one task's timeline keeps its old default (200)", async () => {
|
|
99
|
+
expect((await call("requirements_events", { network_id: netId, limit: 70 })).events.length).toBe(70);
|
|
100
|
+
const one = await call("requirements_events", { network_id: netId, requirement_id: busyId });
|
|
101
|
+
expect(one.events.filter((e: any) => e.kind === "comment").length).toBe(60);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("REST GET /api/requirements/events is unchanged (default 200)", async () => {
|
|
105
|
+
const res = await fetch(`${base}/api/requirements/events?network_id=${netId}`, { headers: { Authorization: `Bearer ${token}` } });
|
|
106
|
+
const body = await res.json() as any;
|
|
107
|
+
expect(res.status).toBe(200);
|
|
108
|
+
expect(body.events.length).toBeGreaterThan(50);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("unknown parameters → -32602 listing the valid ones", async () => {
|
|
112
|
+
const r = await call("requirements_events", { network_id: netId, task_id: busyId });
|
|
113
|
+
expect(r.mcpError).toBe(true);
|
|
114
|
+
expect(r.text).toContain("-32602");
|
|
115
|
+
expect(r.text).toContain("unknown parameter(s): task_id; valid parameters: network_id, requirement_id, since, limit, cursor");
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// #488 —— 网络令牌只在它的用户还是这个网络的成员时有效(RFC-041 §1)。
|
|
2
|
+
//
|
|
3
|
+
// 以前:被移出网络的成员留下的节点令牌照样解析 —— MCP 每个工具都拒(not a member),REST 读却拿得到整个网络
|
|
4
|
+
// (GET /api/status 返回全部名册)。受限成员的令牌本来就在 resolveToken 被拒,但 401 只写 unauthorized。
|
|
5
|
+
//
|
|
6
|
+
// 钉住:
|
|
7
|
+
// (i) 非成员的网络令牌:每个 REST GET 与 /mcp 都 401,reason = not_network_member(下面 GETS 是逐条探过的读路径)。
|
|
8
|
+
// (ii) removeNetworkMember 在同一个事务里吊销这个人在该网络的令牌(revoked_at),别的网络的令牌不动。
|
|
9
|
+
// (iii) 受限成员的节点令牌:401 带 reason = node_owner_restricted + hint;不认识的令牌只有 unauthorized。
|
|
10
|
+
// 成员的节点令牌、不受限的情况与以前完全一样。
|
|
11
|
+
//
|
|
12
|
+
// 跑法:cd server && COMMHUB_DB=/tmp/x.db bun test src/network-token-membership-http.test.ts
|
|
13
|
+
// PG:COMMHUB_TEST_PG_URL=… COMMHUB_PG_EXPERIMENTAL=1(tests/test2123-hub-postgres-ladder 里注册)
|
|
14
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
15
|
+
import { addNetworkMember, createNetworkTokenForNode, createNetwork, register, removeNetworkMember } from "./auth.js";
|
|
16
|
+
import { replaceAgentGrants } from "./agent-access.js";
|
|
17
|
+
import { db } from "./db.js";
|
|
18
|
+
|
|
19
|
+
const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
|
|
20
|
+
if (!activeDbPath) throw new Error("network-token-membership requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
|
|
21
|
+
|
|
22
|
+
const stamp = Date.now();
|
|
23
|
+
const PW = "TokenMember123!";
|
|
24
|
+
const OTHER = `other-${stamp}`; // 网络里别人的 Agent —— 出现在响应里 = 读到了不该读的
|
|
25
|
+
let server: any, base = "", net = "", net2 = "", taskId = "";
|
|
26
|
+
const tok: Record<string, string> = {};
|
|
27
|
+
const ids: Record<string, string> = {};
|
|
28
|
+
|
|
29
|
+
const get = (t: string, path: string) =>
|
|
30
|
+
fetch(`${base}${path}`, { headers: { Authorization: `Bearer ${t}` } })
|
|
31
|
+
.then(async (r) => ({ status: r.status, text: await r.text() }));
|
|
32
|
+
const mcp = (t: string, name: string, args: Record<string, unknown>) =>
|
|
33
|
+
fetch(`${base}/mcp`, {
|
|
34
|
+
method: "POST",
|
|
35
|
+
headers: { Authorization: `Bearer ${t}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
|
|
36
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
|
|
37
|
+
}).then(async (r) => ({ status: r.status, text: await r.text() }));
|
|
38
|
+
|
|
39
|
+
// 拿网络令牌能调的 REST GET 读路径(server.ts 里 restScope 之后的每个 GET,加上之前自己解析令牌的 /api/auth/me、/api/networks)。
|
|
40
|
+
const GETS = () => [
|
|
41
|
+
"/api/auth/me", "/api/networks",
|
|
42
|
+
`/api/status?network_id=${net}`, `/api/status?light=1`, "/api/servers", "/api/server/unknown/health", "/api/server/unknown/agents",
|
|
43
|
+
"/api/messages", "/api/messages?scope=user", "/api/stats", "/api/stats/sse", "/api/stats/routes", "/api/server-logs", "/api/audit-log",
|
|
44
|
+
"/api/task_events", "/api/nodes", `/api/nodes/${ids.other}/config`, "/api/node-create-requests", "/api/host-supervisors",
|
|
45
|
+
`/api/task/${taskId}`, "/api/tasks", "/api/completions", "/api/dm/threads", "/api/requirements",
|
|
46
|
+
];
|
|
47
|
+
|
|
48
|
+
beforeAll(async () => {
|
|
49
|
+
const boss = register(`tm_boss_${stamp}`, PW); net = boss.network_id!; ids.boss = boss.user!.user_id; tok.bossUser = boss.token!;
|
|
50
|
+
for (const k of ["exm", "rem", "mia", "stay"]) {
|
|
51
|
+
const u = register(`tm_${k}_${stamp}`, PW); ids[k] = u.user!.user_id;
|
|
52
|
+
expect(addNetworkMember(net, ids[k], "member", ids.boss, { agentAccess: "all" }).ok).toBe(true);
|
|
53
|
+
const t = createNetworkTokenForNode(ids[k], net, `${k}-${stamp}`, `n_tm_${k}_${stamp}`);
|
|
54
|
+
expect(t.ok).toBe(true);
|
|
55
|
+
tok[k] = t.token!;
|
|
56
|
+
}
|
|
57
|
+
// rem 在另一个网络里也有令牌 —— 被移出 net 时那枚不能被一起吊销。
|
|
58
|
+
const n2 = createNetwork(ids.rem, `tm-net2-${stamp}`); expect(n2.ok).toBe(true); net2 = n2.network_id!;
|
|
59
|
+
const t2 = createNetworkTokenForNode(ids.rem, net2, `rem2-${stamp}`, `n_tm_rem2_${stamp}`); tok.rem2 = t2.token!;
|
|
60
|
+
|
|
61
|
+
ids.other = `n_tm_other_${stamp}`;
|
|
62
|
+
db.run("INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id) VALUES (?1, ?2, ?2, ?3, ?4)", [ids.other, OTHER, net, ids.boss]);
|
|
63
|
+
db.run("INSERT INTO sessions (resume_id, alias, status, node_id, network_id, server) VALUES (?1, ?2, 'idle', ?3, ?4, 'unknown')", [`r_${ids.other}`, OTHER, ids.other, net]);
|
|
64
|
+
taskId = `task_tm_${stamp}`;
|
|
65
|
+
db.run("INSERT INTO tasks (task_id, from_name, to_name, to_node_id, content, status, network_id) VALUES (?1, 'boss', ?2, ?3, 'secret plan', 'pending', ?4)", [taskId, OTHER, ids.other, net]);
|
|
66
|
+
|
|
67
|
+
// exm:老数据 —— 成员行没了,令牌还在(移出前的版本不吊销)。rem:走真实的 removeNetworkMember。
|
|
68
|
+
db.run("DELETE FROM network_members WHERE network_id = ?1 AND user_id = ?2", [net, ids.exm]);
|
|
69
|
+
expect(removeNetworkMember(net, ids.rem).ok).toBe(true);
|
|
70
|
+
// mia:铸完令牌后被收窄成只看授权的 Agent。
|
|
71
|
+
expect(replaceAgentGrants({ networkId: net, userId: ids.mia, grants: [], agentAccess: "granted", actorUserId: ids.boss }).ok).toBe(true);
|
|
72
|
+
|
|
73
|
+
const mod: any = await import("./server.js");
|
|
74
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
75
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
76
|
+
}, 60_000);
|
|
77
|
+
afterAll(() => { server?.stop?.(true); });
|
|
78
|
+
|
|
79
|
+
describe("#488 (i) a network token whose user left the network", () => {
|
|
80
|
+
test("every REST GET read is 401 not_network_member and leaks nothing", async () => {
|
|
81
|
+
const leaks: string[] = [];
|
|
82
|
+
for (const path of GETS()) {
|
|
83
|
+
const r = await get(tok.exm, path);
|
|
84
|
+
if (r.status !== 401 || r.text.includes(OTHER) || r.text.includes("secret plan")) leaks.push(`${path} → ${r.status}`);
|
|
85
|
+
else expect(JSON.parse(r.text).reason).toBe("not_network_member");
|
|
86
|
+
}
|
|
87
|
+
expect(leaks).toEqual([]);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("/mcp is 401 too, with the reason", async () => {
|
|
91
|
+
const r = await mcp(tok.exm, "get_all_status", {});
|
|
92
|
+
expect(r.status).toBe(401);
|
|
93
|
+
expect(JSON.parse(r.text).reason).toBe("not_network_member");
|
|
94
|
+
expect(JSON.parse(r.text).hint).toContain("no longer a member");
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
describe("#488 (ii) removeNetworkMember revokes the member's tokens for that network", () => {
|
|
99
|
+
test("revoked_at is set for that network only, and the token is rejected", async () => {
|
|
100
|
+
const rows = db.all<{ network_id: string; revoked_at: string | null }>("SELECT network_id, revoked_at FROM api_tokens WHERE user_id = ?1 AND network_id IS NOT NULL", ids.rem);
|
|
101
|
+
expect(rows.filter((r) => r.network_id === net).length).toBeGreaterThan(0);
|
|
102
|
+
expect(rows.filter((r) => r.network_id === net).every((r) => !!r.revoked_at)).toBe(true);
|
|
103
|
+
expect(rows.filter((r) => r.network_id === net2).every((r) => !r.revoked_at)).toBe(true);
|
|
104
|
+
expect((await get(tok.rem, `/api/status?network_id=${net}`)).status).toBe(401);
|
|
105
|
+
expect((await get(tok.rem2, "/api/status")).status).toBe(200);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("#488 (iii) a restricted owner's node token says why it is refused", () => {
|
|
110
|
+
test("401 with reason node_owner_restricted + hint on REST and /mcp", async () => {
|
|
111
|
+
for (const r of [await get(tok.mia, "/api/status"), await mcp(tok.mia, "report_status", { resume_id: "r", alias: `mia-${stamp}`, status: "idle" })]) {
|
|
112
|
+
expect(r.status).toBe(401);
|
|
113
|
+
const body = JSON.parse(r.text);
|
|
114
|
+
expect(body.reason).toBe("node_owner_restricted");
|
|
115
|
+
expect(body.hint).toContain("Agent access");
|
|
116
|
+
}
|
|
117
|
+
const me = await get(tok.mia, "/api/auth/me");
|
|
118
|
+
expect(me.status).toBe(401);
|
|
119
|
+
expect(JSON.parse(me.text).reason).toBe("node_owner_restricted");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("an unknown token gets no reason (nothing to explain, nothing leaked)", async () => {
|
|
123
|
+
const r = await get("ntok_doesnotexist", "/api/status");
|
|
124
|
+
expect(r.status).toBe(401);
|
|
125
|
+
expect(JSON.parse(r.text)).toEqual({ error: "unauthorized" });
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("#488 unchanged for members", () => {
|
|
130
|
+
test("an all-access member's node token reads and heartbeats as before", async () => {
|
|
131
|
+
const st = await get(tok.stay, `/api/status?network_id=${net}`);
|
|
132
|
+
expect(st.status).toBe(200);
|
|
133
|
+
expect(st.text).toContain(OTHER);
|
|
134
|
+
const hb = await mcp(tok.stay, "report_status", { resume_id: `r_stay_${stamp}`, alias: `stay-${stamp}`, status: "idle" });
|
|
135
|
+
expect(hb.status).toBe(200);
|
|
136
|
+
expect(hb.text).toContain('\\"ok\\":true');
|
|
137
|
+
expect((await get(tok.bossUser, `/api/task/${taskId}`)).status).toBe(200);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// RFC-041 第一阶段(#487)—— 节点自己的权限:先记录、后执行。全部经真实 HTTP 入口(bootServer + fetch REST / /mcp / SSE)。
|
|
2
|
+
//
|
|
3
|
+
// 钉住:
|
|
4
|
+
// 1. log(默认):正常模式的节点「本来会被拒」的写照常放行,只按 (节点, 路由, 原因, 小时) 合并记一行。
|
|
5
|
+
// 2. 显式模式不看开关、立刻生效:只读写什么都拒、回复 / 心跳照常;受限只看只改派给它的卡,广播 / 订阅别人的推送都拒。
|
|
6
|
+
// 3. enforce:同一组请求按规则拒(node_permission_denied + reason + hint),含只有人能做的事(human_only)。
|
|
7
|
+
// 4. off:不判也不记(显式模式仍生效)。
|
|
8
|
+
// 5. 改模式:节点主人 / 网络 owner、admin 能;别的成员 403、节点令牌 403、不在网络里 404。
|
|
9
|
+
// 6. 报表:owner/admin 看到过去 7 天每个节点「本来会拦」的计数;成员 / 节点令牌 403。
|
|
10
|
+
// 7. 每个注册的 MCP 工具都归了类(新工具忘了归类会红)。8. 记录有上限、会清理。
|
|
11
|
+
//
|
|
12
|
+
// 跑法:cd server && COMMHUB_DB=/tmp/x.db bun test src/node-permissions-http.test.ts
|
|
13
|
+
// PG:COMMHUB_TEST_PG_URL=… COMMHUB_PG_EXPERIMENTAL=1(tests/test2123-hub-postgres-ladder 里注册)
|
|
14
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
15
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
16
|
+
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
|
|
17
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
18
|
+
import { addNetworkMember, createNetworkTokenForNode, register } from "./auth.js";
|
|
19
|
+
import { replaceTaskGrants } from "./task-access.js";
|
|
20
|
+
import { db } from "./db.js";
|
|
21
|
+
import { NODE_TOOL_CLASS, pruneNodePermissionLog, __resetNodePermissionLogStateForTest } from "./node-permissions.js";
|
|
22
|
+
import { registerTools } from "./tools.js";
|
|
23
|
+
|
|
24
|
+
const activeDbPath = process.env.COMMHUB_DB ?? (process.env.COMMHUB_TEST_PG_URL ? "postgres" : undefined);
|
|
25
|
+
if (!activeDbPath) throw new Error("node-permissions requires COMMHUB_DB (or COMMHUB_TEST_PG_URL) before module import");
|
|
26
|
+
|
|
27
|
+
const stamp = Date.now();
|
|
28
|
+
const PW = "NodePerm123!";
|
|
29
|
+
let server: any, base = "", net = "";
|
|
30
|
+
const user: Record<string, { id: string; token: string }> = {};
|
|
31
|
+
const node: Record<string, { id: string; alias: string; token: string; tokenId: string }> = {};
|
|
32
|
+
const card: Record<string, string> = {};
|
|
33
|
+
|
|
34
|
+
async function call(token: string, method: string, path: string, body?: unknown) {
|
|
35
|
+
const r = await fetch(`${base}${path}`, {
|
|
36
|
+
method, headers: { Authorization: `Bearer ${token}`, ...(body === undefined ? {} : { "Content-Type": "application/json" }) },
|
|
37
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
38
|
+
});
|
|
39
|
+
const text = await r.text();
|
|
40
|
+
let json: any = null; try { json = JSON.parse(text); } catch {}
|
|
41
|
+
return { status: r.status, body: json, text };
|
|
42
|
+
}
|
|
43
|
+
async function mcp(token: string, name: string, args: Record<string, unknown>): Promise<any> {
|
|
44
|
+
const r = await fetch(`${base}/mcp`, {
|
|
45
|
+
method: "POST",
|
|
46
|
+
headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream" },
|
|
47
|
+
body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
|
|
48
|
+
});
|
|
49
|
+
const text = await r.text();
|
|
50
|
+
const data = text.split("\n").find((l) => l.startsWith("data: "));
|
|
51
|
+
const msg = JSON.parse(data ? data.slice(6) : text);
|
|
52
|
+
const inner = msg.result?.content?.[0]?.text;
|
|
53
|
+
try { return JSON.parse(inner); } catch { return { raw: inner ?? msg }; }
|
|
54
|
+
}
|
|
55
|
+
const sse = async (token: string, path: string) => {
|
|
56
|
+
const ac = new AbortController();
|
|
57
|
+
const r = await fetch(`${base}${path}`, { headers: { Authorization: `Bearer ${token}` }, signal: ac.signal });
|
|
58
|
+
const status = r.status;
|
|
59
|
+
const body = status === 200 ? null : await r.json().catch(() => null);
|
|
60
|
+
ac.abort();
|
|
61
|
+
return { status, body };
|
|
62
|
+
};
|
|
63
|
+
const logRows = (nodeId: string) => db.all<{ route: string; reason: string; hits: number | string }>(
|
|
64
|
+
"SELECT route, reason, hits FROM node_permission_log WHERE network_id = ?1 AND node_id = ?2 ORDER BY route, reason", net, nodeId);
|
|
65
|
+
const hitsOf = (nodeId: string, route: string, reason: string) =>
|
|
66
|
+
Number(logRows(nodeId).find((r) => r.route === route && r.reason === reason)?.hits ?? 0);
|
|
67
|
+
const attrsRev = (nodeId: string) => Number(db.get<{ r: number | string | null }>("SELECT attrs_revision AS r FROM nodes WHERE node_id = ?1", nodeId)?.r ?? 0);
|
|
68
|
+
const setMode = (nodeKey: string, mode: string) => db.run("UPDATE nodes SET permission_mode = ?1 WHERE node_id = ?2", [mode, node[nodeKey].id]);
|
|
69
|
+
const newCard = async (name: string, extra: Record<string, unknown> = {}) => {
|
|
70
|
+
const r = await call(user.boss.token, "POST", "/api/requirements", { name, network_id: net, ...extra });
|
|
71
|
+
expect(r.status).toBe(201);
|
|
72
|
+
return r.body.requirement.id as string;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
beforeAll(async () => {
|
|
76
|
+
const boss = register(`np_boss_${stamp}`, PW); net = boss.network_id!; user.boss = { id: boss.user!.user_id, token: boss.token! };
|
|
77
|
+
for (const k of ["sco", "mem", "out"]) {
|
|
78
|
+
const u = register(`np_${k}_${stamp}`, PW); user[k] = { id: u.user!.user_id, token: u.token! };
|
|
79
|
+
if (k !== "out") expect(addNetworkMember(net, user[k].id, "member", user.boss.id, { agentAccess: "all" }).ok).toBe(true);
|
|
80
|
+
}
|
|
81
|
+
// sco:只看相关任务的成员(task_access=scoped)—— 他的节点在正常模式下会碰到「主人看不见」的卡。
|
|
82
|
+
expect(replaceTaskGrants({ networkId: net, userId: user.sco.id, taskAccess: "scoped", actorUserId: user.boss.id }).ok).toBe(true);
|
|
83
|
+
for (const [key, owner] of [["nN", "boss"], ["nR", "boss"], ["nRO", "boss"], ["nS", "sco"], ["other", "boss"]] as const) {
|
|
84
|
+
const alias = `${key.toLowerCase()}-${stamp}`, id = `n_np_${key.toLowerCase()}_${stamp}`;
|
|
85
|
+
const t = createNetworkTokenForNode(user[owner].id, net, alias, id);
|
|
86
|
+
expect(t.ok).toBe(true);
|
|
87
|
+
node[key] = { id, alias, token: t.token!, tokenId: t.token_id! };
|
|
88
|
+
db.run("INSERT INTO sessions (resume_id, alias, status, node_id, network_id) VALUES (?1, ?2, 'idle', ?3, ?4)", [`r_${id}`, alias, id, net]);
|
|
89
|
+
}
|
|
90
|
+
const mod: any = await import("./server.js");
|
|
91
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
92
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
93
|
+
card.assigned = await newCard("assigned to nR", { agent_owner: { kind: "node", id: node.nR.id } });
|
|
94
|
+
card.child = await newCard("child of assigned", { parent_id: card.assigned });
|
|
95
|
+
card.boss = await newCard("boss only");
|
|
96
|
+
card.scoPart = await newCard("sco participates", { participants: [{ kind: "user", id: user.sco.id }] });
|
|
97
|
+
setMode("nR", "restricted");
|
|
98
|
+
setMode("nRO", "readonly");
|
|
99
|
+
}, 60_000);
|
|
100
|
+
afterEach(() => { delete process.env.COMMHUB_NODE_PERMISSIONS; });
|
|
101
|
+
afterAll(() => { server?.stop?.(true); });
|
|
102
|
+
|
|
103
|
+
describe("log (default): never blocks a normal node, records would-be denials", () => {
|
|
104
|
+
test("a write beyond the owner's visibility goes through and is logged once per hour, hits merged", async () => {
|
|
105
|
+
const route = "PATCH /api/requirements/:id";
|
|
106
|
+
const before = hitsOf(node.nS.id, route, "beyond_owner_visibility");
|
|
107
|
+
expect((await call(node.nS.token, "PATCH", `/api/requirements/${card.boss}`, { description: "via scoped owner's node" })).status).toBe(200);
|
|
108
|
+
expect((await call(node.nS.token, "PATCH", `/api/requirements/${card.boss}`, { description: "again" })).status).toBe(200);
|
|
109
|
+
expect(hitsOf(node.nS.id, route, "beyond_owner_visibility")).toBe(before + 2);
|
|
110
|
+
expect(logRows(node.nS.id).filter((r) => r.route === route).length).toBe(1);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test("a list read that includes cards the owner cannot see is returned in full and logged", async () => {
|
|
114
|
+
const r = await call(node.nS.token, "GET", `/api/requirements?network_id=${net}`);
|
|
115
|
+
expect(r.status).toBe(200);
|
|
116
|
+
expect(r.body.requirements.map((x: any) => x.id)).toContain(card.boss);
|
|
117
|
+
expect(hitsOf(node.nS.id, "GET /api/requirements", "beyond_owner_visibility")).toBeGreaterThan(0);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("human-only actions are logged, not blocked by this layer", async () => {
|
|
121
|
+
const r = await call(node.nN.token, "PUT", `/api/nodes/${node.other.id}/attrs`, { display_name: "renamed by a node", base_attrs_revision: attrsRev(node.other.id) });
|
|
122
|
+
expect(r.status).toBe(200);
|
|
123
|
+
expect(hitsOf(node.nN.id, "PUT /api/nodes/:id/attrs", "human_only")).toBe(1);
|
|
124
|
+
const p = await mcp(node.nN.token, "projects_create", { network_id: net, name: `p-${stamp}` });
|
|
125
|
+
expect(p.error).not.toBe("node_permission_denied");
|
|
126
|
+
expect(hitsOf(node.nN.id, "mcp:projects_create", "human_only")).toBe(1);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("an all-access owner's normal node does its usual work with zero log rows", async () => {
|
|
130
|
+
expect((await mcp(node.nN.token, "send_task", { alias: node.other.alias, task: "hello" })).ok).toBe(true);
|
|
131
|
+
expect((await call(node.nN.token, "PATCH", `/api/requirements/${card.boss}`, { column: "doing" })).status).toBe(200);
|
|
132
|
+
expect(logRows(node.nN.id).filter((r) => r.reason !== "human_only")).toEqual([]);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
describe("explicit modes are enforced immediately (even under log)", () => {
|
|
137
|
+
test("read-only: writes and dispatch refused with a hint; heartbeat, replies and reads still work", async () => {
|
|
138
|
+
const w = await call(node.nRO.token, "PATCH", `/api/requirements/${card.boss}`, { description: "nope" });
|
|
139
|
+
expect(w.status).toBe(403);
|
|
140
|
+
expect(w.body).toMatchObject({ error: "node_permission_denied", reason: "mode_readonly" });
|
|
141
|
+
expect(w.body.hint).toContain("read-only");
|
|
142
|
+
expect((await mcp(node.nRO.token, "send_task", { alias: node.other.alias, task: "x" })).reason).toBe("mode_readonly");
|
|
143
|
+
expect((await call(node.nRO.token, "POST", "/api/task", { alias: node.other.alias, task: "x", network_id: net })).body.reason).toBe("mode_readonly");
|
|
144
|
+
expect((await mcp(node.nRO.token, "report_status", { resume_id: `r_${node.nRO.id}`, alias: node.nRO.alias, status: "idle" })).ok).toBe(true);
|
|
145
|
+
const t = await mcp(node.nN.token, "send_task", { alias: node.nRO.alias, task: "please answer" });
|
|
146
|
+
const reply = await mcp(node.nRO.token, "send_reply", { in_reply_to: t.task_id ?? t.message_id, text: "done", status: "replied" });
|
|
147
|
+
expect(reply.ok).not.toBe(false);
|
|
148
|
+
expect((await call(node.nRO.token, "GET", `/api/requirements?network_id=${net}`)).status).toBe(200);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("restricted: sees and edits only cards assigned to it (and their subtasks)", async () => {
|
|
152
|
+
const list = await call(node.nR.token, "GET", `/api/requirements?network_id=${net}`);
|
|
153
|
+
expect(list.body.requirements.map((x: any) => x.id).sort()).toEqual([card.assigned, card.child].sort());
|
|
154
|
+
expect((await call(node.nR.token, "GET", `/api/requirements/${card.boss}`)).status).toBe(404);
|
|
155
|
+
expect((await call(node.nR.token, "PATCH", `/api/requirements/${card.assigned}`, { column: "doing" })).status).toBe(200);
|
|
156
|
+
expect((await call(node.nR.token, "PATCH", `/api/requirements/${card.child}`, { column: "doing" })).status).toBe(200);
|
|
157
|
+
expect((await call(node.nR.token, "POST", `/api/requirements/${card.assigned}/comments`, { text: "progress" })).status).toBe(201);
|
|
158
|
+
const mine = await call(node.nR.token, "POST", "/api/requirements", { name: "mine", network_id: net, agent_owner: { kind: "node", id: node.nR.id } });
|
|
159
|
+
expect(mine.status).toBe(201);
|
|
160
|
+
const notMine = await call(node.nR.token, "POST", "/api/requirements", { name: "not mine", network_id: net });
|
|
161
|
+
expect(notMine.status).toBe(403);
|
|
162
|
+
expect(notMine.body.reason).toBe("mode_restricted_not_assigned");
|
|
163
|
+
const viaMcp = await mcp(node.nR.token, "requirements_list", { network_id: net });
|
|
164
|
+
expect((viaMcp.requirements ?? []).map((x: any) => x.id)).not.toContain(card.boss);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("restricted: no broadcast, no network stream, no other session's stream; its own stream works", async () => {
|
|
168
|
+
expect((await mcp(node.nR.token, "broadcast", { message: "hi all" })).reason).toBe("mode_restricted_not_assigned");
|
|
169
|
+
expect((await call(node.nR.token, "POST", "/api/broadcast", { message: "hi", network_id: net })).body.reason).toBe("mode_restricted_not_assigned");
|
|
170
|
+
expect((await sse(node.nR.token, `/events/network/${net}`)).status).toBe(403);
|
|
171
|
+
expect((await sse(node.nR.token, `/events/${encodeURIComponent(node.other.alias)}?network_id=${net}`)).status).toBe(403);
|
|
172
|
+
expect((await sse(node.nR.token, `/events/${encodeURIComponent(node.nR.alias)}?network_id=${net}`)).status).toBe(200);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
describe("enforce", () => {
|
|
177
|
+
test("the same requests that were only logged are refused with node_permission_denied", async () => {
|
|
178
|
+
process.env.COMMHUB_NODE_PERMISSIONS = "enforce";
|
|
179
|
+
// 主人看不见的卡:与不存在同一个 404(按主人的可见范围读)。
|
|
180
|
+
expect((await call(node.nS.token, "PATCH", `/api/requirements/${card.boss}`, { description: "now refused" })).status).toBe(404);
|
|
181
|
+
// 主人看得见(他是参与人)但改不了的卡:node_permission_denied。
|
|
182
|
+
const w = await call(node.nS.token, "PATCH", `/api/requirements/${card.scoPart}`, { description: "now refused" });
|
|
183
|
+
expect(w.status).toBe(403);
|
|
184
|
+
expect(w.body).toMatchObject({ error: "node_permission_denied", reason: "beyond_owner_visibility" });
|
|
185
|
+
const list = await call(node.nS.token, "GET", `/api/requirements?network_id=${net}`);
|
|
186
|
+
expect(list.body.requirements.map((x: any) => x.id)).not.toContain(card.boss);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("human-only actions are denied (REST and MCP); a node can still write its own attrs", async () => {
|
|
190
|
+
process.env.COMMHUB_NODE_PERMISSIONS = "enforce";
|
|
191
|
+
const r = await call(node.nN.token, "PUT", `/api/nodes/${node.other.id}/attrs`, { display_name: "nope", base_attrs_revision: attrsRev(node.other.id) });
|
|
192
|
+
expect(r.status).toBe(403);
|
|
193
|
+
expect(r.body.reason).toBe("human_only");
|
|
194
|
+
expect((await mcp(node.nN.token, "projects_create", { network_id: net, name: `q-${stamp}` })).reason).toBe("human_only");
|
|
195
|
+
expect((await call(node.nN.token, "PUT", `/api/nodes/${node.nN.id}/attrs`, { display_name: "me", base_attrs_revision: attrsRev(node.nN.id) })).status).toBe(200);
|
|
196
|
+
expect((await mcp(node.nN.token, "send_task", { alias: node.other.alias, task: "still fine" })).ok).toBe(true);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("off: nothing is evaluated or logged for normal nodes; explicit modes still apply", async () => {
|
|
200
|
+
process.env.COMMHUB_NODE_PERMISSIONS = "off";
|
|
201
|
+
const before = JSON.stringify(logRows(node.nS.id));
|
|
202
|
+
expect((await call(node.nS.token, "PATCH", `/api/requirements/${card.boss}`, { description: "off" })).status).toBe(200);
|
|
203
|
+
expect(JSON.stringify(logRows(node.nS.id))).toBe(before);
|
|
204
|
+
expect((await call(node.nRO.token, "PATCH", `/api/requirements/${card.boss}`, { description: "still read-only" })).status).toBe(403);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
describe("PUT /api/nodes/:id/permission-mode", () => {
|
|
209
|
+
test("owner and network owner/admin can set it and it applies on the next request; others cannot", async () => {
|
|
210
|
+
expect((await call(user.boss.token, "PUT", `/api/nodes/${node.nN.id}/permission-mode`, { mode: "readonly" })).body).toMatchObject({ ok: true, permission_mode: "readonly", previous: "normal" });
|
|
211
|
+
expect((await call(node.nN.token, "PATCH", `/api/requirements/${card.boss}`, { description: "mode flip" })).body.reason).toBe("mode_readonly");
|
|
212
|
+
expect((await call(user.boss.token, "PUT", `/api/nodes/${node.nN.id}/permission-mode`, { mode: "normal" })).status).toBe(200);
|
|
213
|
+
expect((await call(node.nN.token, "PATCH", `/api/requirements/${card.boss}`, { description: "mode flip" })).status).toBe(200);
|
|
214
|
+
expect((await call(user.sco.token, "PUT", `/api/nodes/${node.nS.id}/permission-mode`, { mode: "restricted" })).status).toBe(200); // sco 是 nS 的主人
|
|
215
|
+
expect((await call(user.sco.token, "PUT", `/api/nodes/${node.nS.id}/permission-mode`, { mode: "normal" })).status).toBe(200);
|
|
216
|
+
expect((await call(user.mem.token, "PUT", `/api/nodes/${node.nN.id}/permission-mode`, { mode: "readonly" })).status).toBe(403);
|
|
217
|
+
expect((await call(node.nN.token, "PUT", `/api/nodes/${node.nN.id}/permission-mode`, { mode: "normal" })).body.error).toBe("user_token_required");
|
|
218
|
+
expect((await call(user.out.token, "PUT", `/api/nodes/${node.nN.id}/permission-mode`, { mode: "readonly" })).status).toBe(404);
|
|
219
|
+
expect((await call(user.boss.token, "PUT", `/api/nodes/${node.nN.id}/permission-mode`, { mode: "admin" })).status).toBe(400);
|
|
220
|
+
expect(db.get<{ n: number | string }>("SELECT COUNT(*) AS n FROM audit_log WHERE action = 'node_permission_mode_changed' AND target_id = ?1", node.nN.id)!.n).toBeTruthy();
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("GET /api/networks/:id/node-permission-report", () => {
|
|
225
|
+
test("owner sees would-have-blocked counts per node; members and node tokens are refused", async () => {
|
|
226
|
+
const r = await call(user.boss.token, "GET", `/api/networks/${net}/node-permission-report`);
|
|
227
|
+
expect(r.status).toBe(200);
|
|
228
|
+
const nS = r.body.nodes.find((n: any) => n.node_id === node.nS.id);
|
|
229
|
+
expect(nS.by_reason.beyond_owner_visibility).toBeGreaterThanOrEqual(2);
|
|
230
|
+
expect(nS.alias).toBe(node.nS.alias);
|
|
231
|
+
expect(r.body.total).toBe(r.body.nodes.reduce((n: number, e: any) => n + e.total, 0));
|
|
232
|
+
expect((await call(user.mem.token, "GET", `/api/networks/${net}/node-permission-report`)).status).toBe(403);
|
|
233
|
+
expect((await call(node.nN.token, "GET", `/api/networks/${net}/node-permission-report`)).status).toBe(403);
|
|
234
|
+
expect((await call(user.boss.token, "GET", `/api/networks/${net}/node-permission-report?since=${encodeURIComponent(new Date(Date.now() + 3_600_000).toISOString())}`)).body.total).toBe(0);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
describe("bookkeeping", () => {
|
|
239
|
+
test("every MCP tool a node can call has a permission class", async () => {
|
|
240
|
+
const s = new McpServer({ name: "np", version: "1" });
|
|
241
|
+
registerTools(s, undefined, net, user.boss.id, node.nN.alias, true, node.nN.tokenId);
|
|
242
|
+
const client = new Client({ name: "np-client", version: "1" });
|
|
243
|
+
const [ct, st] = InMemoryTransport.createLinkedPair();
|
|
244
|
+
await s.connect(st); await client.connect(ct);
|
|
245
|
+
const names = (await client.listTools()).tools.map((t) => t.name);
|
|
246
|
+
await client.close(); await s.close();
|
|
247
|
+
expect(names.length).toBeGreaterThan(50);
|
|
248
|
+
expect(names.filter((n) => !NODE_TOOL_CLASS[n])).toEqual([]);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test("log rows older than the retention window are pruned", () => {
|
|
252
|
+
const old = new Date(Date.now() - 40 * 86_400_000).toISOString().slice(0, 13);
|
|
253
|
+
db.run("INSERT INTO node_permission_log (network_id, node_id, route, reason, hour, hits) VALUES (?1, ?2, 'old', 'human_only', ?3, 5)", [net, node.nN.id, old]);
|
|
254
|
+
pruneNodePermissionLog();
|
|
255
|
+
__resetNodePermissionLogStateForTest();
|
|
256
|
+
expect(db.get("SELECT 1 AS hit FROM node_permission_log WHERE route = 'old'")).toBeFalsy();
|
|
257
|
+
});
|
|
258
|
+
});
|