@sleep2agi/commhub-server 0.9.0-preview.89 → 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.
@@ -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
+ }
@@ -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);
@@ -56,11 +56,12 @@ export type EventCard = {
56
56
  archived: number | null;
57
57
  };
58
58
 
59
- export type EventKind = "created" | "changed" | "deleted";
59
+ export type EventKind = "created" | "changed" | "deleted" | "comment";
60
60
  /**
61
61
  * field(kind = changed):column / title / priority / due / start / assignee / owner / agent_owner / participants /
62
62
  * tags / checklist_item(勾选或取消一项:{id,text,done})/ checklist(增删改条目:{total,done})/
63
63
  * description(只记字数,不存正文)/ project / parent / archived。
64
+ * kind = comment(#474):field 为 null,new = { text }(评论正文,≤ 4000 字),只追加,不对应任何字段的改动。
64
65
  */
65
66
  export type EventDraft = { kind: EventKind; field: string | null; old: unknown; new: unknown };
66
67
 
@@ -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
+ }