@sleep2agi/commhub-server 0.9.0-preview.90 → 0.9.0-preview.91
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/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 +45 -4
- package/src/rest-explicit-columns-http.test.ts +2 -0
- package/src/server.ts +131 -37
- package/src/task-access.ts +68 -10
- package/src/tools.ts +47 -33
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
|
+
});
|
|
@@ -159,8 +159,8 @@ describe("#461 GET /events/network/:id — auth", () => {
|
|
|
159
159
|
test("membership removal revokes observer access — ntok exploit repro (通信龙 review finding 1)", async () => {
|
|
160
160
|
// Exact repro of the review's exploit: add user C to network A, mint
|
|
161
161
|
// an ntok BOUND to A, remove C from A — the same live ntok must lose
|
|
162
|
-
// the stream. network_members
|
|
163
|
-
// removeNetworkMember
|
|
162
|
+
// the stream. (Before #488 network_members was the only revocation
|
|
163
|
+
// mechanism — removeNetworkMember did not revoke tokens; the old
|
|
164
164
|
// `!role && authCtx.networkId !== observedNetId` OR-form let the
|
|
165
165
|
// token's network binding bypass the membership check (utok would
|
|
166
166
|
// not catch this: its authCtx.networkId is null, so only ntok
|
|
@@ -182,10 +182,12 @@ describe("#461 GET /events/network/:id — auth", () => {
|
|
|
182
182
|
|
|
183
183
|
const removed = removeNetworkMember(memberNetworkId, outsiderUserId);
|
|
184
184
|
expect(removed.ok).toBe(true);
|
|
185
|
-
// The review's failing probe: /events/network/A with the
|
|
186
|
-
//
|
|
185
|
+
// The review's failing probe: /events/network/A with the network-bound
|
|
186
|
+
// token → must no longer be 200. Since #488 removeNetworkMember revokes
|
|
187
|
+
// the user's tokens for that network and resolveToken rejects a
|
|
188
|
+
// non-member's network token, so the token itself is refused (401).
|
|
187
189
|
const afterRemoval = await fetch(`${BASE}/events/network/${memberNetworkId}`, { headers: auth(ntok) });
|
|
188
|
-
expect(afterRemoval.status).toBe(
|
|
190
|
+
expect(afterRemoval.status).toBe(401);
|
|
189
191
|
// utok of the removed user is denied too.
|
|
190
192
|
const utokAfter = await fetch(`${BASE}/events/network/${memberNetworkId}`, { headers: auth(outsiderToken) });
|
|
191
193
|
expect(utokAfter.status).toBe(403);
|
|
@@ -132,3 +132,34 @@ export function notifyParticipantChange(input: { before: NoticeCard; after: Noti
|
|
|
132
132
|
if (pending.size > 5_000) for (const [k, p] of pending) if (now - p.startedAt >= COALESCE_WINDOW_MS) pending.delete(k);
|
|
133
133
|
return sent;
|
|
134
134
|
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* RFC-040 Q4:部门负责人(只凭负责人身份)删了本部门的一张卡 → 私信卡的负责人。
|
|
138
|
+
* 卡没有人类负责人时,私信负责 Agent 的主人(这张卡正是因为那个 Agent 才算「本部门的卡」)。
|
|
139
|
+
* 删的人就是收件人 / 收件人已不在网络 → 不发。返回收件人(测试用),没发 → null。
|
|
140
|
+
*/
|
|
141
|
+
export function notifyLeaderDeleted(input: { card: NoticeCard & { agent_owner_json?: string | null }; actorUserId: string }): string | null {
|
|
142
|
+
const { card, actorUserId } = input;
|
|
143
|
+
let target = userIdOf(parse(card.owner_json));
|
|
144
|
+
if (!target) {
|
|
145
|
+
const agent = parse(card.agent_owner_json ?? null) as { kind?: unknown; id?: unknown } | null;
|
|
146
|
+
if (agent?.kind === "node" && typeof agent.id === "string") {
|
|
147
|
+
target = db.get<{ owner_user_id: string | null }>("SELECT owner_user_id FROM nodes WHERE node_id = ?1 AND network_id = ?2", agent.id, card.network_id)?.owner_user_id ?? null;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (!target || target === actorUserId) return null;
|
|
151
|
+
const user = db.get<{ username: string; display_name: string | null }>("SELECT username, display_name FROM users WHERE user_id = ?1", actorUserId);
|
|
152
|
+
if (!user) return null;
|
|
153
|
+
const actorName = user.display_name?.trim() || user.username;
|
|
154
|
+
const number = card.seq ? ` #${card.seq}` : "";
|
|
155
|
+
const sent = sendNoticeDm({
|
|
156
|
+
networkId: card.network_id,
|
|
157
|
+
targetUserId: target,
|
|
158
|
+
sender: { userId: actorUserId, username: user.username },
|
|
159
|
+
title: "任务删除",
|
|
160
|
+
metaJson: JSON.stringify({ task_notice: { requirement_id: card.requirement_id, seq: card.seq ?? null, network_id: card.network_id, deleted: true } }),
|
|
161
|
+
messageId: `dm_task_${uuidv4()}`,
|
|
162
|
+
text: `${actorName}(部门负责人)删除了你负责的任务${number}「${card.title}」`,
|
|
163
|
+
});
|
|
164
|
+
return sent ? target : null;
|
|
165
|
+
}
|
|
@@ -48,6 +48,8 @@ const ENTRIES: Record<string, Entry> = {
|
|
|
48
48
|
agent_owner_must_be_agent: { field: 'agent_owner', message: 'agent_owner (负责 Agent) must be a node.', hint: "Use agent_owner: {kind:'node', id}. To assign a person, use owner: {kind:'user', id}." },
|
|
49
49
|
agent_owner_not_granted: { field: 'agent_owner', message: 'The current responsible Agent is one you are not granted, so you cannot replace or clear it.', hint: 'Leave agent_owner out of the patch.' },
|
|
50
50
|
invalid_participants: { field: 'participants', message: 'participants must be an array of up to 100 persons {kind, id}; it replaces the whole list.', hint: 'Send the full list you want (read it first with requirements_get).' },
|
|
51
|
+
department_scope_denied: { field: 'owner', message: 'As a department head you can hand this task only to people in your department (or yourself) and to their Agents.', hint: 'Pick someone from your department, or ask an owner / admin to move it to another department.' },
|
|
52
|
+
department_not_found: { field: 'department_id', message: 'No department with that id in your network(s).', hint: 'Department ids come from GET /api/networks/{id}/departments.' },
|
|
51
53
|
invalid_participants_delta: { field: 'participants_add', message: 'participants_add / participants_remove must each be an array of up to 100 persons ({kind, id}, {kind:"user", username} or {kind:"node", alias}).', hint: PERSON_IDS },
|
|
52
54
|
participants_conflict: { field: 'participants', message: 'participants (replace the whole list) cannot be combined with participants_add / participants_remove.', hint: 'Use participants_add / participants_remove to change a few people, or participants alone to replace the list.' },
|
|
53
55
|
participants_add_remove_overlap: { field: 'participants_remove', message: 'The same person is in both participants_add and participants_remove.', hint: 'Send each person in only one of the two lists.' },
|
package/src/requirements.ts
CHANGED
|
@@ -11,10 +11,11 @@ import { aggregateStats, parseStatsQuery, type StatsRow } from "./requirements-s
|
|
|
11
11
|
import { addHumanNetworkScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, resolveRestWriteNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
12
12
|
import { isAgentRestricted, restrictedNetworkIds, visibleAgents } from "./agent-access.js";
|
|
13
13
|
import {
|
|
14
|
-
addProjectVisibilityScope, addTaskVisibilityScope, canDeleteTask, canEditTask, canParticipantEditTask, canSeeTask, canUseProject, deleteTaskGrantsForProject, projectUseResolver,
|
|
14
|
+
addProjectVisibilityScope, addTaskVisibilityScope, canDeleteTask, canEditTask, deletesAsHeadOnly, headOnlyEditScope, canParticipantEditTask, canSeeTask, canUseProject, deleteTaskGrantsForProject, projectUseResolver,
|
|
15
15
|
isTaskScoped, PARTICIPANT_EDIT_FIELDS, shouldAuditDenied, taskPermissionsResolver, type TaskCaller,
|
|
16
16
|
} from "./task-access.js";
|
|
17
|
-
import { notifyParticipantChange } from "./requirement-notify.js";
|
|
17
|
+
import { notifyLeaderDeleted, notifyParticipantChange } from "./requirement-notify.js";
|
|
18
|
+
import { departmentCardsFor, departmentSubtree } from "./department-heads.js";
|
|
18
19
|
import { diffRequirement, ensureRequirementEvents, eventPublic, recordRequirementEvents, type EventRow } from "./requirement-events.js";
|
|
19
20
|
import { ensureNetworkTags, ensureRequirementCompletedAt, ensureRequirementIndexes, ensureRequirementProjects, ensureRequirementSeq, ensureRequirementTombstones, migrateRequirementAgentOwners, migrateRequirementPriorityCheck, nextRequirementSeq } from "./requirements-migrate.js";
|
|
20
21
|
|
|
@@ -910,6 +911,15 @@ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Recor
|
|
|
910
911
|
}
|
|
911
912
|
let people;
|
|
912
913
|
try { people = assignments(body, row.network_id, row, hiddenNodeFilter(ctx, row.network_id), ctx.strictOwner === true); } catch (e) { return errorFrom(e); }
|
|
914
|
+
// RFC-040:只凭负责人身份改这张卡时,负责人 / 负责 Agent 只能交给本部门的人(或自己)/ 本部门成员的 Agent,
|
|
915
|
+
// 不能把卡推出本部门(推出去等于替别的部门派活)。清空(null)不限制。
|
|
916
|
+
const headOnly = (has("owner") || has("agent_owner")) ? headOnlyEditScope(caller, row) : null;
|
|
917
|
+
if (headOnly) {
|
|
918
|
+
const owner = people.ownerJson ? JSON.parse(people.ownerJson) as PersonRef : null;
|
|
919
|
+
const agent = people.agentOwnerJson ? JSON.parse(people.agentOwnerJson) as PersonRef : null;
|
|
920
|
+
if (has("owner") && owner && owner.id !== caller!.userId && !headOnly.users.has(owner.id)) return jsonError("department_scope_denied", 403, { field: "owner" });
|
|
921
|
+
if (has("agent_owner") && agent && !headOnly.nodes.has(agent.id)) return jsonError("department_scope_denied", 403, { field: "agent_owner" });
|
|
922
|
+
}
|
|
913
923
|
let projectId = row.project_id;
|
|
914
924
|
if (has("project_id")) {
|
|
915
925
|
try { projectId = projectRef(body.project_id, row.network_id); } catch (e) { return errorFrom(e); }
|
|
@@ -1043,6 +1053,23 @@ function listFilters(url: URL, sql: string, params: unknown[], ctx?: Requirement
|
|
|
1043
1053
|
if (ref !== null) sql += ` AND external_ref = ?${params.push(ref)}`;
|
|
1044
1054
|
const parent = q.get("parent_id");
|
|
1045
1055
|
if (parent !== null) sql += parent === "none" ? " AND parent_id IS NULL" : ` AND parent_id = ?${params.push(parent)}`;
|
|
1056
|
+
// RFC-040:department_id= —— 负责人是这个部门(含下级)的成员、或负责 Agent 归这些成员所有的卡。可见范围照旧由上面的
|
|
1057
|
+
// addTaskVisibilityScope 管,这里只再收窄。部门按调用者看得见的网络找(部门 id 只在网络内唯一)。
|
|
1058
|
+
const dept = q.get("department_id");
|
|
1059
|
+
if (dept !== null) {
|
|
1060
|
+
if (!dept || !/^[A-Za-z0-9_-]{1,64}$/.test(dept)) return jsonError("department_not_found", 400);
|
|
1061
|
+
const networks = ctx ? departmentNetworks(ctx, dept) : [];
|
|
1062
|
+
if (!networks.length) return jsonError("department_not_found", 400);
|
|
1063
|
+
const parts: string[] = [];
|
|
1064
|
+
for (const networkId of networks) {
|
|
1065
|
+
const cards = departmentCardsFor(networkId, departmentSubtree(networkId, dept));
|
|
1066
|
+
const ors: string[] = [];
|
|
1067
|
+
if (cards.users.size) ors.push(`owner_json IN (${[...cards.users].map((id) => `?${params.push(JSON.stringify({ kind: "user", id }))}`).join(", ")})`);
|
|
1068
|
+
if (cards.nodes.size) ors.push(`agent_owner_json IN (${[...cards.nodes].map((id) => `?${params.push(JSON.stringify({ kind: "node", id }))}`).join(", ")})`);
|
|
1069
|
+
if (ors.length) parts.push(`(network_id = ?${params.push(networkId)} AND (${ors.join(" OR ")}))`);
|
|
1070
|
+
}
|
|
1071
|
+
sql += parts.length ? ` AND (${parts.join(" OR ")})` : " AND 1=0";
|
|
1072
|
+
}
|
|
1046
1073
|
if (q.get("top_level") === "1") sql += " AND parent_id IS NULL";
|
|
1047
1074
|
// Explicit archived-only wins over the legacy include-all switch.
|
|
1048
1075
|
// changes=1 看得见「被归档」这一改动:默认连归档的一起回(行上 archived: true),客户端据此把它移出看板。
|
|
@@ -1051,6 +1078,13 @@ function listFilters(url: URL, sql: string, params: unknown[], ctx?: Requirement
|
|
|
1051
1078
|
return sql;
|
|
1052
1079
|
}
|
|
1053
1080
|
|
|
1081
|
+
/** department_id 筛选:调用者作用域里有这个部门的网络。 */
|
|
1082
|
+
function departmentNetworks(ctx: RequirementsRequestContext, deptId: string): string[] {
|
|
1083
|
+
const params: unknown[] = [deptId];
|
|
1084
|
+
const sql = addHumanNetworkScope("SELECT DISTINCT network_id FROM network_departments WHERE department_id = ?1", params, ctx.scope);
|
|
1085
|
+
return db.all<{ network_id: string }>(sql, ...params).map((r) => r.network_id);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1054
1088
|
const LIST_ORDER = " ORDER BY created_at DESC, requirement_id DESC";
|
|
1055
1089
|
type LightRow = { requirement_id: string; seq?: number | null; network_id: string; created_at: string; title: string; description: string | null; assignee: string | null; tags_json: string | null; project_id: string | null; owner_json: string | null; agent_owner_json: string | null; participants_json: string | null };
|
|
1056
1090
|
const parseRef = (json: string | null) => { try { return json ? JSON.parse(json) : null; } catch { return null; } };
|
|
@@ -1160,7 +1194,7 @@ function deletedSince(ctx: RequirementsRequestContext, sinceIso: string): string
|
|
|
1160
1194
|
const EVENTS_PAGE_DEFAULT = 200;
|
|
1161
1195
|
const EVENTS_PAGE_MAX = 500;
|
|
1162
1196
|
const EVENTS_SCAN_MAX = 5000;
|
|
1163
|
-
type VisibilityRow = { network_id: string; owner_json: string | null; participants_json: string | null; created_by: string | null; created_by_json: string | null; project_id: string | null };
|
|
1197
|
+
type VisibilityRow = { network_id: string; owner_json: string | null; participants_json: string | null; created_by: string | null; created_by_json: string | null; project_id: string | null; agent_owner_json?: string | null };
|
|
1164
1198
|
|
|
1165
1199
|
function listEvents(ctx: RequirementsRequestContext): Response {
|
|
1166
1200
|
const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
|
|
@@ -1187,7 +1221,8 @@ function listEvents(ctx: RequirementsRequestContext): Response {
|
|
|
1187
1221
|
let ok = seen.get(id);
|
|
1188
1222
|
if (ok === undefined) {
|
|
1189
1223
|
const cols = "network_id, owner_json, participants_json, created_by, created_by_json, project_id";
|
|
1190
|
-
|
|
1224
|
+
// agent_owner_json:负责人按「负责 Agent 归本部门成员」也认得这张卡(RFC-040);墓碑表没有这一列。
|
|
1225
|
+
const row = db.get<VisibilityRow>(`SELECT ${cols}, agent_owner_json FROM requirements WHERE requirement_id = ?1`, id)
|
|
1191
1226
|
?? db.get<VisibilityRow>(`SELECT ${cols} FROM requirement_tombstones WHERE requirement_id = ?1`, id);
|
|
1192
1227
|
seen.set(id, ok = !!row && row.network_id === networkId && canSeeTask(caller, row));
|
|
1193
1228
|
}
|
|
@@ -1506,6 +1541,7 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
|
|
|
1506
1541
|
if (row instanceof Response) return row;
|
|
1507
1542
|
if (!canWrite(ctx, row.network_id)) return jsonError("permission_denied", 403);
|
|
1508
1543
|
if (!canDeleteTask(taskCaller(ctx), row)) return taskDenied(ctx, row, "task_delete_denied");
|
|
1544
|
+
const byLeader = deletesAsHeadOnly(taskCaller(ctx), row);
|
|
1509
1545
|
// 子需求不跟着删:先解挂(变成顶层),再删父卡。
|
|
1510
1546
|
const deletedAt = new Date().toISOString();
|
|
1511
1547
|
db.transaction(() => {
|
|
@@ -1517,6 +1553,11 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
|
|
|
1517
1553
|
});
|
|
1518
1554
|
// 硬删除以前不留痕(RFC-038 §9.1):记下是谁删了哪张(标题 + 短号),卡本身已经没了。
|
|
1519
1555
|
if (ctx.auth) logAudit(ctx.auth.userId, ctx.auth.username || null, "requirement_deleted", "requirement", row.requirement_id, JSON.stringify({ title: row.title, seq: row.seq ?? null }).slice(0, 1000), undefined, row.network_id);
|
|
1556
|
+
// RFC-040 Q4:只凭部门负责人身份删的,另记一条并私信卡的负责人(人;没有就私信负责 Agent 的主人)。
|
|
1557
|
+
if (byLeader && ctx.auth) {
|
|
1558
|
+
const notified = notifyLeaderDeleted({ card: row, actorUserId: ctx.auth.userId });
|
|
1559
|
+
logAudit(ctx.auth.userId, ctx.auth.username || null, "requirement_deleted_by_leader", "requirement", row.requirement_id, JSON.stringify({ title: row.title, seq: row.seq ?? null, owner: row.owner_json ? JSON.parse(row.owner_json) : null, agent_owner: row.agent_owner_json ? JSON.parse(row.agent_owner_json) : null, notified }).slice(0, 1000), undefined, row.network_id);
|
|
1560
|
+
}
|
|
1520
1561
|
return Response.json({ ok: true, deleted: row.requirement_id });
|
|
1521
1562
|
}
|
|
1522
1563
|
if (req.method !== "PATCH") return jsonError("not_found", 404);
|
|
@@ -32,6 +32,8 @@ const GOLDEN_RESPONSE_KEYS = {
|
|
|
32
32
|
authMeNetwork: [
|
|
33
33
|
"network_id", "network_name", "owner_id", "description", "settings",
|
|
34
34
|
"created_at", "updated_at", "visibility", "max_members", "member_role", "agent_access", "task_access",
|
|
35
|
+
// RFC-040(#455):不是列,是每次请求现算的「我负责的部门 + 全部下级」(department-heads.ts)。
|
|
36
|
+
"managed_department_ids",
|
|
35
37
|
],
|
|
36
38
|
networkDetail: [
|
|
37
39
|
"network_id", "network_name", "owner_id", "description", "settings",
|