@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.
@@ -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
- db.run("DELETE FROM network_departments WHERE network_id = ?1 AND department_id = ?2", [networkId, id]);
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 IS the revocation mechanism because
163
- // removeNetworkMember does not revoke the user's tokens; the old
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 still-live
186
- // network-bound token → must now be 403, not 200.
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(403);
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.' },
@@ -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
- const row = db.get<VisibilityRow>(`SELECT ${cols} FROM requirements WHERE requirement_id = ?1`, id)
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",