@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.90",
3
+ "version": "0.9.0-preview.91",
4
4
  "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/auth.ts CHANGED
@@ -337,12 +337,18 @@ export function isExpiredSessionToken(token: string): boolean {
337
337
  return !!row;
338
338
  }
339
339
 
340
- export function resolveToken(token: string): { user: AuthUser; networkId: string | null; tokenName: string | null; tokenId: string | null } | null {
340
+ export type TokenRejection = "node_owner_restricted" | "not_network_member";
341
+
342
+ /** 查令牌行并判它能不能用:没有这一行 → null;有但被拒 → { rejected };能用 → { row }。 */
343
+ function lookupToken(token: string): { row: any; rejected?: undefined } | { row?: undefined; rejected: TokenRejection } | null {
341
344
  const tHash = hashToken(token);
342
345
  const cutoff = idleCutoffOffset();
343
346
  const row = db.get<any>(
344
347
  `SELECT t.token_id, t.user_id, t.network_id, t.scope, t.name AS token_name,
345
- u.username, u.display_name, u.email, u.role
348
+ u.username, u.display_name, u.email, u.role,
349
+ CASE WHEN t.network_id IS NULL THEN 1
350
+ WHEN EXISTS (SELECT 1 FROM network_members nm WHERE nm.network_id = t.network_id AND nm.user_id = t.user_id) THEN 1
351
+ ELSE 0 END AS is_member
346
352
  FROM api_tokens t JOIN users u ON t.user_id = u.user_id
347
353
  WHERE t.token_hash = ?1
348
354
  AND (t.expires_at IS NULL OR t.expires_at > datetime('now'))
@@ -352,10 +358,34 @@ export function resolveToken(token: string): { user: AuthUser; networkId: string
352
358
 
353
359
  if (!row) return null;
354
360
 
361
+ // #488 —— 网络令牌只在它的用户还是这个网络的成员时有效。被移出网络的人留下的令牌(移出前签发的、
362
+ // 或移出时还没自动吊销的老数据)以前照样解析:MCP 每个工具都拒,REST 读却能拿到整个网络的名册。
363
+ if (row.network_id && !Number(row.is_member)) return { rejected: "not_network_member" };
364
+
355
365
  // 多用户 Agent 权限:网络令牌(ntok_ / 邀请码令牌)的权限是「这个网络里的一切」,
356
366
  // 受限成员在受限网络里不能持有它 —— 否则拿它就绕过了授权过滤。这里是唯一的解析入口,
357
367
  // 在这里拒绝,所有 REST / MCP / SSE 路径一起生效(含升级前就签发、后来才被设为受限的令牌)。
358
- if (row.network_id && isAgentRestricted(row.user_id, row.network_id)) return null;
368
+ if (row.network_id && isAgentRestricted(row.user_id, row.network_id)) return { rejected: "node_owner_restricted" };
369
+ return { row };
370
+ }
371
+
372
+ /**
373
+ * 令牌存在、但因为主人的网络成员身份被拒时,说清楚为什么(给 401 带上 reason / hint)。
374
+ * 只在调用者真的持有这枚令牌时才有输出 —— 不认识的令牌一律 null,不泄露任何东西。
375
+ */
376
+ export function tokenRejection(token: string | null | undefined): { reason: TokenRejection; hint: string } | null {
377
+ if (!token) return null;
378
+ const r = lookupToken(token);
379
+ if (!r?.rejected) return null;
380
+ return r.rejected === "not_network_member"
381
+ ? { reason: r.rejected, hint: "this token's owner is no longer a member of its network; ask a network admin to re-invite them, then create a new token" }
382
+ : { reason: r.rejected, hint: "this token's owner only has access to granted Agents in this network, and network tokens need full Agent access; ask a network admin to set the owner's Agent access to all" };
383
+ }
384
+
385
+ export function resolveToken(token: string): { user: AuthUser; networkId: string | null; tokenName: string | null; tokenId: string | null } | null {
386
+ const found = lookupToken(token);
387
+ if (!found?.row) return null;
388
+ const row = found.row;
359
389
 
360
390
  // Update last_used(节流:见 LAST_USED_WRITE_INTERVAL_SECONDS)
361
391
  db.run(
@@ -652,9 +682,13 @@ export function removeNetworkMember(networkId: string, userId: string): { ok: bo
652
682
  const member = db.get<any>("SELECT role FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId);
653
683
  if (!member) return { ok: false, error: "not a member" };
654
684
  if (member.role === "owner") return { ok: false, error: "cannot remove owner" };
655
- db.run("DELETE FROM network_members WHERE network_id = ?1 AND user_id = ?2", [networkId, userId]);
656
- deleteAgentGrants(networkId, userId);
657
- deleteTaskGrantsForMember(networkId, userId);
685
+ db.transaction(() => {
686
+ db.run("DELETE FROM network_members WHERE network_id = ?1 AND user_id = ?2", [networkId, userId]);
687
+ // #488 —— 他在这个网络里的令牌(节点令牌 / 邀请码令牌)一起吊销;resolveToken 也会拒非成员的令牌,这里让库里的状态如实。
688
+ db.run("UPDATE api_tokens SET revoked_at = datetime('now') WHERE network_id = ?1 AND user_id = ?2 AND revoked_at IS NULL", [networkId, userId]);
689
+ deleteAgentGrants(networkId, userId);
690
+ deleteTaskGrantsForMember(networkId, userId);
691
+ });
658
692
  return { ok: true };
659
693
  }
660
694
 
@@ -0,0 +1,329 @@
1
+ // RFC-040(#455)部门负责人权限 —— 逐条钉矩阵:owner / admin / 负责人 / 上级负责人 / 普通成员 / viewer(当负责人)。
2
+ //
3
+ // 组织架构(测试夹具,全是占位名):
4
+ // 研发(上级负责人 parentHead)── 前端(负责人 head)── 前端一组
5
+ // └─ 后端
6
+ // 销售(无负责人)
7
+ // 只读组(负责人是 viewer —— 不获得任何东西)
8
+ // 成员:head 在前端,member 在前端一组(拥有节点 node_m),be 在后端,sales 在销售,viewer 在只读组,
9
+ // allMember(task_access='all')在销售。除 allMember 外都是「只看相关任务」(scoped)。
10
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
11
+ import { mkdtempSync, rmSync } from "fs";
12
+ import { join } from "path";
13
+ import { tmpdir } from "os";
14
+ import { register, createNetworkTokenForNode } from "./auth.js";
15
+ import { db } from "./db.js";
16
+
17
+ const DIR = mkdtempSync(join(tmpdir(), "anet-dept-heads-"));
18
+ let BASE = "";
19
+ let hub: any = null;
20
+ const PW = "DeptHeadsPassw0rd!xyz";
21
+ let NET = "";
22
+ const U: Record<string, { token: string; id: string }> = {};
23
+ const D: Record<string, string> = {};
24
+ const C: Record<string, string> = {};
25
+ const P: Record<string, string> = {};
26
+ let NODE_TOKEN = "";
27
+
28
+ async function send(token: string, method: string, path: string, body?: unknown) {
29
+ const res = await fetch(`${BASE}${path}`, { method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
30
+ const text = await res.text();
31
+ let json: any = null;
32
+ try { json = JSON.parse(text); } catch {}
33
+ return { status: res.status, body: json };
34
+ }
35
+ const dept = (token: string, method: string, path = "", body?: unknown) => send(token, method, `/api/networks/${NET}/departments${path}`, body);
36
+ const me = async (k: string) => (await send(U[k].token, "GET", "/api/auth/me")).body.networks.find((n: any) => n.network_id === NET).managed_department_ids as string[];
37
+ const visible = async (k: string, query = "") => new Set(((await send(U[k].token, "GET", `/api/requirements?network_id=${NET}${query}`)).body.requirements as any[]).map(r => r.id));
38
+ const card = (k: string) => C[k];
39
+ const userRef = (k: string) => ({ kind: "user", id: U[k].id });
40
+
41
+ beforeAll(async () => {
42
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
43
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
44
+ process.env.HOST = "127.0.0.1";
45
+ const a = register(`dh_owner_${Date.now()}`, PW, undefined, "Owner");
46
+ U.owner = { token: a.token!, id: a.user!.user_id };
47
+ NET = a.network_id!;
48
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [U.owner.id]);
49
+ const mod: any = await import("./server.js");
50
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
51
+ BASE = `http://127.0.0.1:${hub.port}`;
52
+ const stamp = Date.now();
53
+ const add = async (k: string, role: string, scoped = true) => {
54
+ const username = `dh_${k}_${stamp}`;
55
+ expect((await send(U.owner.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role })).status).toBe(200);
56
+ const login = await send("", "POST", "/api/auth/login", { username, password: PW });
57
+ U[k] = { token: login.body.token, id: login.body.user.user_id };
58
+ if (scoped && role !== "admin") expect((await send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U[k].id}/task-grants`, { task_access: "scoped" })).status).toBe(200);
59
+ };
60
+ await add("admin", "admin");
61
+ for (const k of ["head", "parentHead", "member", "be", "sales"]) await add(k, "member");
62
+ await add("viewer", "viewer");
63
+ await add("allMember", "member", false);
64
+
65
+ const mkDept = async (k: string, name: string, parent: string | null, leader: string | null) => {
66
+ const r = await dept(U.owner.token, "POST", "", { name, parent_id: parent, leader_user_id: leader ? U[leader].id : null });
67
+ expect(r.status).toBe(201);
68
+ D[k] = r.body.department.id;
69
+ };
70
+ await mkDept("rd", "研发", null, "parentHead");
71
+ await mkDept("fe", "前端", D.rd, "head");
72
+ await mkDept("fe1", "前端一组", D.fe, null);
73
+ await mkDept("be", "后端", D.rd, null);
74
+ await mkDept("sales", "销售", null, null);
75
+ await mkDept("ro", "只读组", null, "viewer");
76
+ const place = async (k: string, d: string) => expect((await send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U[k].id}/department`, { department_id: D[d] })).status).toBe(200);
77
+ await place("head", "fe"); await place("member", "fe1"); await place("be", "be"); await place("sales", "sales"); await place("viewer", "ro"); await place("allMember", "sales");
78
+ await place("parentHead", "rd");
79
+
80
+ // member 拥有的节点:用 owner 建令牌,再把节点主人改成 member(scoped 成员不能自己建节点令牌)。
81
+ const node = createNetworkTokenForNode(U.owner.id, NET, "示例-成员节点", "node_dh_m");
82
+ NODE_TOKEN = node.token!;
83
+ db.run("UPDATE nodes SET owner_user_id = ?1 WHERE node_id = 'node_dh_m'", [U.member.id]);
84
+
85
+ for (const [k, name] of [["p1", "示例项目一"], ["p2", "示例项目二"]] as const) {
86
+ const r = await send(U.owner.token, "POST", `/api/requirements/projects?network_id=${NET}`, { name, network_id: NET });
87
+ expect(r.status).toBe(201);
88
+ P[k] = r.body.project.id;
89
+ }
90
+ const mkCard = async (k: string, fields: Record<string, unknown>) => {
91
+ const r = await send(U.owner.token, "POST", "/api/requirements", { name: `示例卡 ${k}`, network_id: NET, ...fields });
92
+ expect(r.status).toBe(201);
93
+ C[k] = r.body.requirement.id;
94
+ };
95
+ await mkCard("memberOwned", { owner: userRef("member") });
96
+ await mkCard("memberAgent", { agent_owner: { kind: "node", id: "node_dh_m" } });
97
+ await mkCard("salesOwned", { owner: userRef("sales"), project_id: P.p2 });
98
+ await mkCard("beOwned", { owner: userRef("be") });
99
+ await mkCard("salesP1", { owner: userRef("sales"), project_id: P.p1 });
100
+ await mkCard("toDelete", { owner: userRef("member") });
101
+ await mkCard("toDeleteAgent", { agent_owner: { kind: "node", id: "node_dh_m" } });
102
+ }, 60_000);
103
+
104
+ afterAll(() => {
105
+ try { hub?.stop?.(true); } catch {}
106
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
107
+ });
108
+
109
+ describe("who is a head (computed per request)", () => {
110
+ test("/api/auth/me managed_department_ids: own departments + all sub-departments; others empty; a viewer leader gets nothing", async () => {
111
+ expect(new Set(await me("head"))).toEqual(new Set([D.fe, D.fe1]));
112
+ expect(new Set(await me("parentHead"))).toEqual(new Set([D.rd, D.fe, D.fe1, D.be]));
113
+ expect(await me("member")).toEqual([]);
114
+ expect(await me("viewer")).toEqual([]);
115
+ expect(await me("owner")).toEqual([]);
116
+ const node = await send(NODE_TOKEN, "GET", "/api/auth/me");
117
+ expect(node.body.networks[0].managed_department_ids).toEqual([]);
118
+ });
119
+
120
+ test("departments list: viewer_can per department; project_grants only for owner / admin", async () => {
121
+ const byId = (r: any) => new Map((r.body.departments as any[]).map(d => [d.id, d.viewer_can]));
122
+ const head = await dept(U.head.token, "GET");
123
+ const v = byId(head);
124
+ expect(v.get(D.fe)).toEqual({ manage: false, create_child: true }); // 自己负责的部门:不能改,能在下面建
125
+ expect(v.get(D.fe1)).toEqual({ manage: true, create_child: true });
126
+ expect(v.get(D.be)).toEqual({ manage: false, create_child: false });
127
+ expect("project_grants" in head.body).toBe(false);
128
+ expect(byId(await dept(U.parentHead.token, "GET")).get(D.fe)).toEqual({ manage: true, create_child: true });
129
+ expect(byId(await dept(U.member.token, "GET")).get(D.fe1)).toEqual({ manage: false, create_child: false });
130
+ const owner = await dept(U.owner.token, "GET");
131
+ expect(byId(owner).get(D.sales)).toEqual({ manage: true, create_child: true });
132
+ expect(Array.isArray(owner.body.project_grants)).toBe(true);
133
+ expect(Array.isArray((await dept(U.admin.token, "GET")).body.project_grants)).toBe(true);
134
+ });
135
+ });
136
+
137
+ describe("department writes: heads inside their subtree, fixed 403 outside", () => {
138
+ test("create: under own subtree 201; elsewhere / top level → 403 department_scope_denied; non-heads unchanged", async () => {
139
+ const ok = await dept(U.head.token, "POST", "", { name: "前端二组", parent_id: D.fe });
140
+ expect(ok.status).toBe(201);
141
+ D.fe2 = ok.body.department.id;
142
+ for (const parent of [D.be, D.rd, null]) {
143
+ const r = await dept(U.head.token, "POST", "", { name: "越权部门", parent_id: parent });
144
+ expect(r.status).toBe(403);
145
+ expect(r.body.error).toBe("department_scope_denied");
146
+ }
147
+ const leaderOutside = await dept(U.head.token, "POST", "", { name: "前端三组", parent_id: D.fe, leader_user_id: U.sales.id });
148
+ expect(leaderOutside.body.error).toBe("department_scope_denied");
149
+ // 普通成员 / viewer 负责人:与以前逐字节相同。
150
+ for (const k of ["member", "viewer"]) {
151
+ const r = await dept(U[k].token, "POST", "", { name: "x", parent_id: D.fe });
152
+ expect(r.status).toBe(403);
153
+ expect(r.body).toEqual({ ok: false, error: "owner/admin required" });
154
+ }
155
+ });
156
+
157
+ test("patch: own department is the parent head's; sub-departments rename / move / set leader only inside the subtree", async () => {
158
+ const own = await dept(U.head.token, "PATCH", `/${D.fe}`, { name: "前端改名" });
159
+ expect(own.status).toBe(403);
160
+ expect(own.body.error).toBe("department_scope_denied");
161
+ expect((await dept(U.parentHead.token, "PATCH", `/${D.fe}`, { name: "前端(上级改)" })).status).toBe(200);
162
+ expect((await dept(U.head.token, "PATCH", `/${D.fe1}`, { name: "前端一组(改)" })).status).toBe(200);
163
+ expect((await dept(U.head.token, "PATCH", `/${D.fe2}`, { parent_id: D.fe1 })).status).toBe(200);
164
+ expect((await dept(U.head.token, "PATCH", `/${D.fe2}`, { parent_id: D.be })).body.error).toBe("department_scope_denied");
165
+ expect((await dept(U.head.token, "PATCH", `/${D.fe2}`, { parent_id: null })).body.error).toBe("department_scope_denied");
166
+ expect((await dept(U.head.token, "PATCH", `/${D.be}`, { name: "后端改" })).body.error).toBe("department_scope_denied");
167
+ expect((await dept(U.head.token, "PATCH", `/${D.fe1}`, { leader_user_id: U.member.id })).status).toBe(200);
168
+ expect((await dept(U.head.token, "PATCH", `/${D.fe1}`, { leader_user_id: U.sales.id })).body.error).toBe("department_scope_denied");
169
+ expect((await dept(U.head.token, "PATCH", `/${D.fe1}`, { leader_user_id: null })).status).toBe(200);
170
+ expect((await dept(U.head.token, "PATCH", "/dept_missing", { name: "x" })).status).toBe(404);
171
+ });
172
+
173
+ test("delete: empty sub-department yes, outside no; the 409 not-empty rule still applies", async () => {
174
+ expect((await dept(U.head.token, "DELETE", `/${D.be}`)).body.error).toBe("department_scope_denied");
175
+ expect((await dept(U.head.token, "DELETE", `/${D.fe1}`)).status).toBe(409); // member 在里面
176
+ expect((await dept(U.head.token, "DELETE", `/${D.fe2}`)).status).toBe(200);
177
+ });
178
+
179
+ test("moving people: only from inside the subtree to inside the subtree; in / out (incl. unassigned) is admin-only", async () => {
180
+ const move = (k: string, who: string, to: string | null) => send(U[k].token, "PUT", `/api/networks/${NET}/members/${U[who].id}/department`, { department_id: to });
181
+ expect((await move("head", "member", D.fe)).status).toBe(200);
182
+ expect((await move("head", "member", D.fe1)).status).toBe(200);
183
+ expect((await move("head", "member", null)).body.error).toBe("department_scope_denied");
184
+ expect((await move("head", "member", D.sales)).body.error).toBe("department_scope_denied");
185
+ expect((await move("head", "sales", D.fe)).body.error).toBe("department_scope_denied");
186
+ expect((await move("member", "member", D.fe)).body).toEqual({ ok: false, error: "owner/admin required" });
187
+ expect((await move("admin", "sales", D.fe)).status).toBe(200);
188
+ expect((await move("admin", "sales", D.sales)).status).toBe(200);
189
+ });
190
+ });
191
+
192
+ describe("tasks: union with the existing ACL", () => {
193
+ test("a head sees, edits and is offered delete on the department's cards (owner or Agent owner in the subtree), nothing else", async () => {
194
+ const seen = await visible("head");
195
+ expect(seen.has(card("memberOwned"))).toBe(true);
196
+ expect(seen.has(card("memberAgent"))).toBe(true);
197
+ expect(seen.has(card("salesOwned"))).toBe(false);
198
+ expect(seen.has(card("beOwned"))).toBe(false);
199
+ expect((await send(U.head.token, "GET", `/api/requirements/${card("beOwned")}`)).status).toBe(404);
200
+ const row = (await send(U.head.token, "GET", `/api/requirements/${card("memberOwned")}`)).body.requirement;
201
+ expect(row.viewer_can).toMatchObject({ edit: true, delete: true });
202
+ expect((await send(U.head.token, "PATCH", `/api/requirements/${card("memberOwned")}`, { name: "示例卡(负责人改)" })).status).toBe(200);
203
+ const parent = await visible("parentHead");
204
+ expect(parent.has(card("beOwned"))).toBe(true);
205
+ expect(parent.has(card("memberOwned"))).toBe(true);
206
+ expect(parent.has(card("salesOwned"))).toBe(false);
207
+ });
208
+
209
+ test("a head-only edit can hand the card only to the department (or self); clearing is allowed", async () => {
210
+ const out = await send(U.head.token, "PATCH", `/api/requirements/${card("memberAgent")}`, { owner: userRef("sales") });
211
+ expect(out.status).toBe(403);
212
+ expect(out.body.error).toBe("department_scope_denied");
213
+ expect(out.body.field).toBe("owner");
214
+ expect((await send(U.head.token, "PATCH", `/api/requirements/${card("memberAgent")}`, { owner: userRef("member") })).status).toBe(200);
215
+ expect((await send(U.head.token, "PATCH", `/api/requirements/${card("memberAgent")}`, { owner: null })).status).toBe(200);
216
+ });
217
+
218
+ test("viewers never get head power; ordinary members see nothing new", async () => {
219
+ // viewer 负责「只读组」,把 sales 临时放进去:viewer 仍看不见 sales 的卡,也不能删。
220
+ expect((await send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U.sales.id}/department`, { department_id: D.ro })).status).toBe(200);
221
+ expect((await visible("viewer")).has(card("salesOwned"))).toBe(false);
222
+ expect((await send(U.viewer.token, "DELETE", `/api/requirements/${card("salesOwned")}`)).status).not.toBe(200);
223
+ expect((await send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U.sales.id}/department`, { department_id: D.sales })).status).toBe(200);
224
+ const be = await visible("be");
225
+ expect(be.has(card("memberOwned"))).toBe(false);
226
+ expect([...be].every(id => id === card("beOwned"))).toBe(true);
227
+ });
228
+
229
+ test("revoking a head takes effect on the very next request (no cache, no re-login)", async () => {
230
+ expect((await dept(U.owner.token, "PATCH", `/${D.fe}`, { leader_user_id: null })).status).toBe(200);
231
+ expect(await me("head")).toEqual([]);
232
+ expect((await visible("head")).has(card("memberOwned"))).toBe(false);
233
+ expect((await dept(U.head.token, "POST", "", { name: "撤了还想建", parent_id: D.fe })).body).toEqual({ ok: false, error: "owner/admin required" });
234
+ expect((await send(U.head.token, "PATCH", `/api/requirements/${card("memberOwned")}`, { name: "撤了还想改" })).status).toBe(404);
235
+ expect((await dept(U.owner.token, "PATCH", `/${D.fe}`, { leader_user_id: U.head.id })).status).toBe(200);
236
+ expect((await visible("head")).has(card("memberOwned"))).toBe(true);
237
+ });
238
+
239
+ test("union never revokes: personal project grants and task_access='all' are untouched by departments", async () => {
240
+ // member 有按人授权 p1(可改)。把他调来调去、设部门授权、撤负责人,都不影响这份授权。
241
+ expect((await send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U.member.id}/task-grants`, { project_grants: [{ project_id: P.p1, can_edit: true }] })).status).toBe(200);
242
+ expect((await visible("member")).has(card("salesP1"))).toBe(true);
243
+ expect((await send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U.member.id}/department`, { department_id: D.be })).status).toBe(200);
244
+ expect((await dept(U.owner.token, "PUT", `/${D.be}/project-grants`, { project_grants: [] })).status).toBe(200);
245
+ expect((await visible("member")).has(card("salesP1"))).toBe(true);
246
+ expect((await send(U.member.token, "PATCH", `/api/requirements/${card("salesP1")}`, { name: "按人授权可改" })).status).toBe(200);
247
+ expect((await send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U.member.id}/department`, { department_id: D.fe1 })).status).toBe(200);
248
+ // task_access='all' 的老成员:看见全部,和部门无关。
249
+ const all = await visible("allMember");
250
+ for (const k of ["memberOwned", "salesOwned", "beOwned"]) expect(all.has(card(k))).toBe(true);
251
+ expect((await send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U.member.id}/task-grants`, { project_grants: [] })).status).toBe(200);
252
+ });
253
+ });
254
+
255
+ describe("department project grants", () => {
256
+ test("owner / admin only; whole-batch validation", async () => {
257
+ expect((await dept(U.head.token, "GET", `/${D.rd}/project-grants`)).body).toEqual({ ok: false, error: "owner/admin required" });
258
+ expect((await dept(U.parentHead.token, "PUT", `/${D.rd}/project-grants`, { project_grants: [{ project_id: P.p2 }] })).status).toBe(403);
259
+ const bad = await dept(U.owner.token, "PUT", `/${D.rd}/project-grants`, { project_grants: [{ project_id: P.p2 }, { project_id: "proj_not_here" }] });
260
+ expect(bad.status).toBe(400);
261
+ expect(bad.body.error).toBe("project_not_in_network");
262
+ expect((await dept(U.owner.token, "GET", `/${D.rd}/project-grants`)).body.project_grants).toEqual([]);
263
+ expect((await dept(U.owner.token, "GET", "/dept_missing/project-grants")).status).toBe(404);
264
+ });
265
+
266
+ test("a grant to a department cascades to its sub-departments, not to siblings; can_edit decides edit", async () => {
267
+ expect((await visible("member")).has(card("salesOwned"))).toBe(false);
268
+ expect((await dept(U.admin.token, "PUT", `/${D.rd}/project-grants`, { project_grants: [{ project_id: P.p2, can_edit: false }] })).status).toBe(200);
269
+ expect((await visible("member")).has(card("salesOwned"))).toBe(true); // member 在 研发 › 前端 › 前端一组
270
+ expect((await visible("sales")).has(card("salesOwned"))).toBe(true); // 自己的卡,本来就看得见
271
+ expect((await send(U.member.token, "PATCH", `/api/requirements/${card("salesOwned")}`, { name: "只读授权" })).status).toBe(403);
272
+ expect((await dept(U.owner.token, "PUT", `/${D.rd}/project-grants`, { project_grants: [{ project_id: P.p2, can_edit: true }] })).status).toBe(200);
273
+ expect((await send(U.member.token, "PATCH", `/api/requirements/${card("salesOwned")}`, { name: "部门授权可改" })).status).toBe(200);
274
+ // 授权给 后端:前端一组的 member 拿不到。
275
+ expect((await dept(U.owner.token, "PUT", `/${D.rd}/project-grants`, { project_grants: [] })).status).toBe(200);
276
+ expect((await dept(U.owner.token, "PUT", `/${D.be}/project-grants`, { project_grants: [{ project_id: P.p2 }] })).status).toBe(200);
277
+ expect((await visible("member")).has(card("salesOwned"))).toBe(false);
278
+ expect((await visible("be")).has(card("salesOwned"))).toBe(true);
279
+ expect((await dept(U.owner.token, "GET")).body.project_grants).toEqual([{ department_id: D.be, project_id: P.p2, can_edit: false }]);
280
+ // 项目列表也跟着授权走。
281
+ const projects = (await send(U.be.token, "GET", `/api/requirements/projects?network_id=${NET}`)).body.projects.map((p: any) => p.id);
282
+ expect(projects).toContain(P.p2);
283
+ });
284
+ });
285
+
286
+ describe("delete by a head: audit + DM", () => {
287
+ test("head deletes a department card → requirement_deleted_by_leader + a DM to the card's owner (or the Agent's owner)", async () => {
288
+ const inbox = (uid: string) => db.all<{ content: string }>("SELECT content FROM user_inbox WHERE user_id = ?1 AND content LIKE '%部门负责人%'", uid);
289
+ const before = inbox(U.member.id).length;
290
+ expect((await send(U.head.token, "DELETE", `/api/requirements/${card("toDelete")}`)).status).toBe(200);
291
+ expect((await send(U.head.token, "DELETE", `/api/requirements/${card("toDeleteAgent")}`)).status).toBe(200);
292
+ const audits = db.all<{ action: string; target_id: string; detail: string }>("SELECT action, target_id, detail FROM audit_log WHERE action = 'requirement_deleted_by_leader' ORDER BY id");
293
+ expect(audits.map(a => a.target_id)).toEqual([card("toDelete"), card("toDeleteAgent")]);
294
+ expect(JSON.parse(audits[0].detail).notified).toBe(U.member.id);
295
+ expect(JSON.parse(audits[1].detail).notified).toBe(U.member.id); // Agent 的主人
296
+ const dms = inbox(U.member.id);
297
+ expect(dms.length).toBe(before + 2);
298
+ expect(dms.at(-1)!.content).toContain("删除了你负责的任务");
299
+ // 不是本部门的卡:负责人删不了(看不见 = 404)。普通成员删别人的卡照旧被拒。
300
+ expect((await send(U.head.token, "DELETE", `/api/requirements/${card("beOwned")}`)).status).toBe(404);
301
+ expect((await send(U.be.token, "DELETE", `/api/requirements/${card("salesOwned")}`)).status).toBe(403);
302
+ });
303
+ });
304
+
305
+ describe("department_id filter and the read-only node list", () => {
306
+ test("GET /api/requirements?department_id= narrows to the subtree's cards, within what the caller can see", async () => {
307
+ const all = await visible("owner", `&department_id=${D.fe}`);
308
+ expect(all.has(card("memberOwned"))).toBe(true);
309
+ expect(all.has(card("memberAgent"))).toBe(true);
310
+ expect(all.has(card("beOwned"))).toBe(false);
311
+ expect((await visible("owner", `&department_id=${D.rd}`)).has(card("beOwned"))).toBe(true);
312
+ expect((await visible("be", `&department_id=${D.rd}`)).has(card("memberOwned"))).toBe(false); // 仍受可见范围约束
313
+ const bad = await send(U.owner.token, "GET", `/api/requirements?network_id=${NET}&department_id=dept_missing`);
314
+ expect(bad.status).toBe(400);
315
+ expect(bad.body.error).toBe("department_not_found");
316
+ });
317
+
318
+ test("GET …/departments/:dept/nodes: heads (subtree) and owner/admin; read-only status + health", async () => {
319
+ const r = await dept(U.head.token, "GET", `/${D.fe}/nodes`);
320
+ expect(r.status).toBe(200);
321
+ expect(r.body.nodes.map((n: any) => n.node_id)).toEqual(["node_dh_m"]);
322
+ expect(Object.keys(r.body.nodes[0]).sort()).toEqual(["alias", "degraded", "display_name", "health", "last_seen_at", "node_id", "owner_user_id", "status"]);
323
+ expect((await dept(U.head.token, "GET", `/${D.be}/nodes`)).body.error).toBe("department_scope_denied");
324
+ expect((await dept(U.member.token, "GET", `/${D.fe}/nodes`)).body.error).toBe("department_scope_denied");
325
+ expect((await dept(U.owner.token, "GET", `/${D.rd}/nodes`)).body.nodes.length).toBe(1);
326
+ expect((await dept(U.head.token, "POST", `/${D.fe}/nodes`, {})).status).toBe(405);
327
+ expect((await send(NODE_TOKEN, "GET", `/api/networks/${NET}/departments/${D.fe}/nodes`)).body.error).toBe("department_scope_denied");
328
+ });
329
+ });
@@ -0,0 +1,226 @@
1
+ // 部门负责人权限(RFC-040,看板 #455)—— 组织架构 v2。
2
+ //
3
+ // 负责人 = 本部门子树的「小管理员」。不新增角色、不存展开结果:每次请求按 network_departments.leader_user_id 现算
4
+ // 「我负责的部门 ∪ 它们的全部下级」,撤掉负责人,权限当场消失。viewer 当负责人不获得任何东西(viewer 永远只读)。
5
+ // 只加不减:这里给出的东西(负责人看 / 改 / 删本部门的卡、部门项目授权)在 task-access.ts 里和已有来源取并集,
6
+ // 从不改写按人授权、task_access、参与人。节点令牌(Agent)不经过这里。
7
+ //
8
+ // 「本部门的卡」按卡的负责人判:owner 是本部门成员,或 agent_owner 的 owner_user_id 是本部门成员 ——
9
+ // 不按创建者 / 参与人(否则谁都能把卡「挂」进别的部门)。
10
+ //
11
+ // 新表只增(network_department_project_grants):项目授权给部门 D ⇒ D 及其下级部门的成员都获得(行存在 = 可看,
12
+ // can_edit=1 = 可改)。旧代码不读它,回滚安全。递归在 JS 里算(部门 ≤ 500),不依赖 WITH RECURSIVE 的方言差异。
13
+
14
+ import { db } from "./db.js";
15
+
16
+ db.exec(`
17
+ CREATE TABLE IF NOT EXISTS network_department_project_grants (
18
+ network_id TEXT NOT NULL,
19
+ department_id TEXT NOT NULL,
20
+ project_id TEXT NOT NULL,
21
+ can_edit INTEGER NOT NULL DEFAULT 0,
22
+ created_by TEXT,
23
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
24
+ PRIMARY KEY (network_id, department_id, project_id)
25
+ );
26
+ CREATE INDEX IF NOT EXISTS idx_dept_project_grants_project ON network_department_project_grants(project_id);
27
+ `);
28
+
29
+ export const DEPARTMENT_SCOPE_DENIED = "department_scope_denied";
30
+
31
+ type TreeRow = { department_id: string; parent_id: string | null; leader_user_id: string | null };
32
+ type Tree = { parent: Map<string, string | null>; children: Map<string, string[]>; rows: TreeRow[] };
33
+
34
+ function tree(networkId: string): Tree {
35
+ const rows = db.all<TreeRow>("SELECT department_id, parent_id, leader_user_id FROM network_departments WHERE network_id = ?1 ORDER BY sort, created_at, department_id", networkId);
36
+ const parent = new Map<string, string | null>();
37
+ const children = new Map<string, string[]>();
38
+ for (const r of rows) parent.set(r.department_id, r.parent_id);
39
+ for (const r of rows) {
40
+ if (r.parent_id === null || !parent.has(r.parent_id)) continue;
41
+ const list = children.get(r.parent_id) ?? [];
42
+ list.push(r.department_id);
43
+ children.set(r.parent_id, list);
44
+ }
45
+ return { parent, children, rows };
46
+ }
47
+
48
+ function collect(t: Tree, root: string, into: Set<string>): void {
49
+ const stack = [root];
50
+ while (stack.length) {
51
+ const id = stack.pop()!;
52
+ if (into.has(id)) continue;
53
+ into.add(id);
54
+ for (const c of t.children.get(id) ?? []) stack.push(c);
55
+ }
56
+ }
57
+
58
+ /** deptId 和它的全部下级(部门不存在 → 空集)。 */
59
+ export function departmentSubtree(networkId: string, deptId: string): Set<string> {
60
+ const t = tree(networkId);
61
+ const out = new Set<string>();
62
+ if (t.parent.has(deptId)) collect(t, deptId, out);
63
+ return out;
64
+ }
65
+
66
+ /** deptId 和它的全部上级(从自己往上;部门不存在 → 空)。 */
67
+ function ancestorsAndSelf(t: Tree, deptId: string): string[] {
68
+ const out: string[] = [];
69
+ const seen = new Set<string>();
70
+ let cur: string | null | undefined = deptId;
71
+ while (cur && t.parent.has(cur) && !seen.has(cur)) {
72
+ seen.add(cur);
73
+ out.push(cur);
74
+ cur = t.parent.get(cur);
75
+ }
76
+ return out;
77
+ }
78
+
79
+ /** 能当负责人的成员:本网络成员、不是 viewer。(网络角色 owner / admin 本来就全管,这里也照算,无害。) */
80
+ function eligibleLeader(networkId: string, userId: string): boolean {
81
+ const row = db.get<{ role: string | null }>("SELECT role FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId);
82
+ return !!row && row.role !== "viewer";
83
+ }
84
+
85
+ export type HeadScope = {
86
+ /** 我负责的部门 ∪ 它们的全部下级(含我负责的那几个本身)。 */
87
+ managed: Set<string>;
88
+ /** 我能改名 / 移动 / 删除 / 换负责人的部门:我负责的部门的严格下级(自己负责的那个归上一级或管理员)。 */
89
+ strict: Set<string>;
90
+ };
91
+ const EMPTY: HeadScope = { managed: new Set(), strict: new Set() };
92
+
93
+ export function headScope(networkId: string, userId: string | null | undefined): HeadScope {
94
+ if (!userId || !networkId) return EMPTY;
95
+ if (!db.get("SELECT 1 AS x FROM network_departments WHERE network_id = ?1 AND leader_user_id = ?2", networkId, userId)) return EMPTY;
96
+ if (!eligibleLeader(networkId, userId)) return EMPTY;
97
+ const t = tree(networkId);
98
+ const managed = new Set<string>();
99
+ const strict = new Set<string>();
100
+ for (const r of t.rows) {
101
+ if (r.leader_user_id !== userId) continue;
102
+ collect(t, r.department_id, managed);
103
+ for (const c of t.children.get(r.department_id) ?? []) collect(t, c, strict);
104
+ }
105
+ return { managed, strict };
106
+ }
107
+
108
+ /** /api/auth/me 的 managed_department_ids:按部门树的顺序。 */
109
+ export function managedDepartmentIds(networkId: string, userId: string): string[] {
110
+ const { managed } = headScope(networkId, userId);
111
+ if (!managed.size) return [];
112
+ return tree(networkId).rows.map((r) => r.department_id).filter((id) => managed.has(id));
113
+ }
114
+
115
+ /** 部门集合里的成员(department_id 落在集合里)。 */
116
+ export function membersIn(networkId: string, departments: ReadonlySet<string>): Set<string> {
117
+ if (!departments.size) return new Set();
118
+ const rows = db.all<{ user_id: string; department_id: string | null }>("SELECT user_id, department_id FROM network_members WHERE network_id = ?1 AND department_id IS NOT NULL", networkId);
119
+ return new Set(rows.filter((r) => departments.has(r.department_id!)).map((r) => r.user_id));
120
+ }
121
+
122
+ /** 这些成员拥有的节点(nodes.owner_user_id)。 */
123
+ export function nodesOwnedBy(networkId: string, userIds: ReadonlySet<string>): Set<string> {
124
+ if (!userIds.size) return new Set();
125
+ const rows = db.all<{ node_id: string; owner_user_id: string | null }>("SELECT node_id, owner_user_id FROM nodes WHERE network_id = ?1 AND owner_user_id IS NOT NULL", networkId);
126
+ return new Set(rows.filter((r) => userIds.has(r.owner_user_id!)).map((r) => r.node_id));
127
+ }
128
+
129
+ /** 卡的负责人 / 负责 Agent 落在这组成员 / 节点里吗(「本部门的卡」)。 */
130
+ export type DepartmentCards = { users: Set<string>; nodes: Set<string> };
131
+ export function departmentCardsFor(networkId: string, departments: ReadonlySet<string>): DepartmentCards {
132
+ const users = membersIn(networkId, departments);
133
+ return { users, nodes: nodesOwnedBy(networkId, users) };
134
+ }
135
+ const refId = (json: string | null, kind: "user" | "node"): string | null => {
136
+ if (!json) return null;
137
+ try {
138
+ const ref = JSON.parse(json) as { kind?: unknown; id?: unknown };
139
+ return ref && ref.kind === kind && typeof ref.id === "string" ? ref.id : null;
140
+ } catch { return null; }
141
+ };
142
+ export function isDepartmentCard(scope: DepartmentCards, row: { owner_json: string | null; agent_owner_json?: string | null }): boolean {
143
+ const owner = refId(row.owner_json, "user");
144
+ if (owner && scope.users.has(owner)) return true;
145
+ const agent = refId(row.agent_owner_json ?? null, "node");
146
+ return !!agent && scope.nodes.has(agent);
147
+ }
148
+
149
+ /** 负责人对这张卡的权力:卡是我负责的部门(含下级)的卡。 */
150
+ export function headCardsFor(networkId: string, userId: string): DepartmentCards | null {
151
+ const { managed } = headScope(networkId, userId);
152
+ return managed.size ? departmentCardsFor(networkId, managed) : null;
153
+ }
154
+
155
+ // ── 部门项目授权 ──
156
+
157
+ export type DepartmentProjectGrant = { department_id: string; project_id: string; can_edit: boolean };
158
+
159
+ /** 这个成员从部门(自己的部门 + 全部上级)得到的项目授权:project_id → can_edit(多条取「能改」)。 */
160
+ export function departmentGrantsForMember(networkId: string, userId: string): Map<string, boolean> {
161
+ const out = new Map<string, boolean>();
162
+ const dept = db.get<{ department_id: string | null }>("SELECT department_id FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId)?.department_id;
163
+ if (!dept) return out;
164
+ const chain = ancestorsAndSelf(tree(networkId), dept);
165
+ if (!chain.length) return out;
166
+ const rows = db.all<{ department_id: string; project_id: string; can_edit: number }>("SELECT department_id, project_id, can_edit FROM network_department_project_grants WHERE network_id = ?1", networkId);
167
+ const inChain = new Set(chain);
168
+ for (const r of rows) if (inChain.has(r.department_id)) out.set(r.project_id, out.get(r.project_id) === true || r.can_edit === 1);
169
+ return out;
170
+ }
171
+
172
+ export function listDepartmentProjectGrants(networkId: string, departmentId?: string): DepartmentProjectGrant[] {
173
+ const rows = departmentId === undefined
174
+ ? db.all<{ department_id: string; project_id: string; can_edit: number }>("SELECT department_id, project_id, can_edit FROM network_department_project_grants WHERE network_id = ?1 ORDER BY department_id, project_id", networkId)
175
+ : db.all<{ department_id: string; project_id: string; can_edit: number }>("SELECT department_id, project_id, can_edit FROM network_department_project_grants WHERE network_id = ?1 AND department_id = ?2 ORDER BY project_id", networkId, departmentId);
176
+ return rows.map((r) => ({ department_id: r.department_id, project_id: r.project_id, can_edit: r.can_edit === 1 }));
177
+ }
178
+
179
+ const MAX_DEPARTMENT_GRANTS = 500;
180
+ export type ReplaceDepartmentGrantsResult =
181
+ | { ok: true; project_grants: Array<{ project_id: string; can_edit: boolean }> }
182
+ | { ok: false; status: number; error: string; detail?: unknown };
183
+
184
+ /** 整体替换一个部门的项目授权。项目必须是本网络的;任何一条不合格 ⇒ 整批不写。 */
185
+ export function replaceDepartmentProjectGrants(networkId: string, departmentId: string, grants: unknown, actorUserId: string): ReplaceDepartmentGrantsResult {
186
+ if (!db.get("SELECT 1 AS x FROM network_departments WHERE network_id = ?1 AND department_id = ?2", networkId, departmentId)) return { ok: false, status: 404, error: "department_not_found" };
187
+ if (!Array.isArray(grants)) return { ok: false, status: 400, error: "project_grants_must_be_array" };
188
+ if (grants.length > MAX_DEPARTMENT_GRANTS) return { ok: false, status: 400, error: "too_many_grants", detail: { limit: MAX_DEPARTMENT_GRANTS } };
189
+ const rows: Array<{ project_id: string; can_edit: number }> = [];
190
+ const seen = new Set<string>();
191
+ for (const [index, raw] of grants.entries()) {
192
+ const item = typeof raw === "string" ? { project_id: raw } : (raw && typeof raw === "object" && !Array.isArray(raw) ? raw as { project_id?: unknown; can_edit?: unknown } : {});
193
+ const projectId = typeof item.project_id === "string" ? item.project_id.trim() : "";
194
+ if (!projectId) return { ok: false, status: 400, error: "project_grant_needs_project_id", detail: { index } };
195
+ if (item.can_edit !== undefined && typeof item.can_edit !== "boolean") return { ok: false, status: 400, error: "can_edit_must_be_boolean", detail: { index } };
196
+ if (!db.get("SELECT 1 AS x FROM requirement_projects WHERE project_id = ?1 AND network_id = ?2", projectId, networkId)) {
197
+ return { ok: false, status: 400, error: "project_not_in_network", detail: { index, project_id: projectId } };
198
+ }
199
+ if (seen.has(projectId)) continue;
200
+ seen.add(projectId);
201
+ rows.push({ project_id: projectId, can_edit: item.can_edit === true ? 1 : 0 });
202
+ }
203
+ db.transaction(() => {
204
+ db.run("DELETE FROM network_department_project_grants WHERE network_id = ?1 AND department_id = ?2", [networkId, departmentId]);
205
+ for (const r of rows) {
206
+ db.run("INSERT INTO network_department_project_grants (network_id, department_id, project_id, can_edit, created_by) VALUES (?1, ?2, ?3, ?4, ?5)",
207
+ [networkId, departmentId, r.project_id, r.can_edit, actorUserId]);
208
+ }
209
+ });
210
+ return { ok: true, project_grants: listDepartmentProjectGrants(networkId, departmentId).map(({ project_id, can_edit }) => ({ project_id, can_edit })) };
211
+ }
212
+
213
+ export function deleteDepartmentGrants(networkId: string, departmentId: string): void {
214
+ db.run("DELETE FROM network_department_project_grants WHERE network_id = ?1 AND department_id = ?2", [networkId, departmentId]);
215
+ }
216
+ export function deleteDepartmentGrantsForNetwork(networkId: string): void {
217
+ db.run("DELETE FROM network_department_project_grants WHERE network_id = ?1", [networkId]);
218
+ }
219
+ export function deleteDepartmentGrantsForProject(projectId: string): void {
220
+ db.run("DELETE FROM network_department_project_grants WHERE project_id = ?1", [projectId]);
221
+ }
222
+
223
+ /** 当前所有(有效的)负责人:组织架构变了,这些人的「本部门」可能跟着变。 */
224
+ export function departmentLeaders(networkId: string): string[] {
225
+ return db.all<{ leader_user_id: string }>("SELECT DISTINCT leader_user_id FROM network_departments WHERE network_id = ?1 AND leader_user_id IS NOT NULL", networkId).map((r) => r.leader_user_id);
226
+ }