@sleep2agi/commhub-server 0.9.0-preview.70 → 0.9.0-preview.72

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.
@@ -88,25 +88,45 @@ export function listAgentGrants(networkId: string, userId: string): AgentGrant[]
88
88
  * node_id 授权同时带出:nodes 表里这个节点的 alias、sessions 表里同 node_id 的会话 alias
89
89
  * (改名后 sessions 先于 nodes 更新的窗口里两者可能不同,两个都算)。都限定在同一网络。
90
90
  */
91
+ /** visibleAgents 一次查询里最多放多少个节点 id(绑定参数上限;1000 个授权节点 = 2 次查询)。 */
92
+ export const VISIBLE_AGENTS_CHUNK = 500;
93
+
91
94
  export function visibleAgents(userId: string, networkId: string): VisibleAgents {
92
95
  const aliases = new Set<string>();
93
96
  const nodeIds = new Set<string>();
94
97
  const messageAliases = new Set<string>();
95
98
  const messageNodeIds = new Set<string>();
96
- for (const grant of listAgentGrants(networkId, userId)) {
97
- const grantAliases: string[] = [];
99
+ // RFC-038 §8:组授权展开成「该组此刻的成员节点」—— 每次现算,组里新加的节点下一次请求就可见。
100
+ // 只并同网络、且 nodes 表里还在的节点;可对话取并集(直接授权或任一组给了就算)。
101
+ const grants: AgentGrant[] = [...listAgentGrants(networkId, userId), ...groupGrantNodes(networkId, userId)];
102
+ // #2133:所有授权节点的 alias 一次查完(原来每个节点单独查一次 nodes ∪ sessions:300 条授权 ≈ 300 次查询,
103
+ // 而受限成员的每次 /api/status 轮询都走这里)。结果与原实现逐字一致:授权按上面数组的顺序,
104
+ // 同一节点内的 alias 按 UNION 的去重升序 —— agent-access-batch.test.ts 在随机夹具上逐字比对。
105
+ // 分块只为绑定参数个数的上限;同一节点的所有 alias 必在同一块里,顺序不受分块影响。
106
+ const grantedNodeIds = [...new Set(grants.map((grant) => grant.node_id).filter((id): id is string => !!id))];
107
+ const aliasesByNode = new Map<string, string[]>();
108
+ for (let start = 0; start < grantedNodeIds.length; start += VISIBLE_AGENTS_CHUNK) {
109
+ const chunk = grantedNodeIds.slice(start, start + VISIBLE_AGENTS_CHUNK);
110
+ const inList = chunk.map((_, i) => `?${i + 2}`).join(", ");
111
+ for (const row of db.all<{ node_id: string; alias: string | null }>(
112
+ `SELECT node_id, alias FROM nodes WHERE network_id = ?1 AND node_id IN (${inList})
113
+ UNION SELECT node_id, alias FROM sessions WHERE network_id = ?1 AND node_id IN (${inList})
114
+ ORDER BY node_id, alias`,
115
+ networkId, ...chunk,
116
+ )) {
117
+ if (!row.alias) continue;
118
+ const list = aliasesByNode.get(row.node_id);
119
+ if (list) list.push(row.alias); else aliasesByNode.set(row.node_id, [row.alias]);
120
+ }
121
+ }
122
+ for (const grant of grants) {
123
+ let grantAliases: readonly string[] = [];
98
124
  if (grant.node_id) {
99
125
  nodeIds.add(grant.node_id);
100
126
  if (grant.can_message) messageNodeIds.add(grant.node_id);
101
- for (const row of db.all<{ alias: string | null }>(
102
- `SELECT alias FROM nodes WHERE node_id = ?1 AND network_id = ?2
103
- UNION SELECT alias FROM sessions WHERE node_id = ?1 AND network_id = ?2`,
104
- grant.node_id, networkId,
105
- )) {
106
- if (row.alias) grantAliases.push(row.alias);
107
- }
127
+ grantAliases = aliasesByNode.get(grant.node_id) ?? [];
108
128
  } else if (grant.alias) {
109
- grantAliases.push(grant.alias);
129
+ grantAliases = [grant.alias];
110
130
  }
111
131
  for (const alias of grantAliases) {
112
132
  aliases.add(alias);
@@ -121,6 +141,162 @@ export function visibleAgents(userId: string, networkId: string): VisibleAgents
121
141
  };
122
142
  }
123
143
 
144
+ // ══════════════ Agent 分组(RFC-038 §8) ══════════════
145
+
146
+ export type AgentGroupGrant = { group_id: string; name: string; can_message: boolean };
147
+ export type AgentGroup = { group_id: string; network_id: string; name: string; description: string | null; node_ids: string[]; member_count: number; granted_user_count: number; created_at: string; updated_at: string | null };
148
+
149
+ /** 该成员被授权的组,展开成节点级授权(只取同网络、仍存在的节点)。 */
150
+ function groupGrantNodes(networkId: string, userId: string): AgentGrant[] {
151
+ return db.all<{ node_id: string; can_message: number }>(
152
+ `SELECT m.node_id, g.can_message
153
+ FROM network_member_group_grants g
154
+ JOIN agent_groups ag ON ag.group_id = g.group_id AND ag.network_id = g.network_id
155
+ JOIN agent_group_members m ON m.group_id = g.group_id
156
+ JOIN nodes n ON n.node_id = m.node_id AND n.network_id = g.network_id
157
+ WHERE g.network_id = ?1 AND g.user_id = ?2`,
158
+ networkId, userId,
159
+ ).map((row) => ({ node_id: row.node_id, alias: null, can_message: row.can_message === 1 }));
160
+ }
161
+
162
+ export function listGroupGrants(networkId: string, userId: string): AgentGroupGrant[] {
163
+ return db.all<{ group_id: string; name: string; can_message: number }>(
164
+ `SELECT g.group_id, ag.name, g.can_message
165
+ FROM network_member_group_grants g JOIN agent_groups ag ON ag.group_id = g.group_id
166
+ WHERE g.network_id = ?1 AND g.user_id = ?2 ORDER BY ag.name`,
167
+ networkId, userId,
168
+ ).map((row) => ({ group_id: row.group_id, name: row.name, can_message: row.can_message === 1 }));
169
+ }
170
+
171
+ export function listAgentGroups(networkId: string): AgentGroup[] {
172
+ const groups = db.all<{ group_id: string; network_id: string; name: string; description: string | null; created_at: string; updated_at: string | null }>(
173
+ "SELECT group_id, network_id, name, description, created_at, updated_at FROM agent_groups WHERE network_id = ?1 ORDER BY name",
174
+ networkId,
175
+ );
176
+ return groups.map((g) => {
177
+ const nodeIds = db.all<{ node_id: string }>(
178
+ `SELECT m.node_id FROM agent_group_members m JOIN nodes n ON n.node_id = m.node_id AND n.network_id = ?2
179
+ WHERE m.group_id = ?1 ORDER BY m.node_id`,
180
+ g.group_id, networkId,
181
+ ).map((row) => row.node_id);
182
+ const granted = db.get<{ c: number }>("SELECT COUNT(*) AS c FROM network_member_group_grants WHERE group_id = ?1", g.group_id)?.c ?? 0;
183
+ return { ...g, node_ids: nodeIds, member_count: nodeIds.length, granted_user_count: granted };
184
+ });
185
+ }
186
+
187
+ export function getAgentGroup(networkId: string, groupId: string): AgentGroup | null {
188
+ return listAgentGroups(networkId).find((g) => g.group_id === groupId) ?? null;
189
+ }
190
+
191
+ /** 这个组上有授权的成员(组变化时要断开他们的实时流,让其按新权限重连)。 */
192
+ export function usersGrantedGroup(groupId: string): string[] {
193
+ return db.all<{ user_id: string }>("SELECT user_id FROM network_member_group_grants WHERE group_id = ?1", groupId).map((r) => r.user_id);
194
+ }
195
+
196
+ const MAX_GROUP_NAME = 64;
197
+ const MAX_GROUP_MEMBERS = 1000;
198
+
199
+ export type GroupResult = { ok: true; group: AgentGroup } | { ok: false; error: string; status: number; detail?: unknown };
200
+
201
+ function normalizeName(raw: unknown): string | null {
202
+ if (typeof raw !== "string") return null;
203
+ const name = raw.trim();
204
+ if (!name || name.length > MAX_GROUP_NAME) return null;
205
+ return name;
206
+ }
207
+
208
+ /** node_ids 必须都是本网络里存在的节点;任何一个不合格 ⇒ 整批拒绝。去重。 */
209
+ function validateNodeIds(networkId: string, raw: unknown): { ok: true; ids: string[] } | { ok: false; error: string; status: number; detail?: unknown } {
210
+ if (!Array.isArray(raw)) return { ok: false, error: "node_ids_must_be_array", status: 400 };
211
+ if (raw.length > MAX_GROUP_MEMBERS) return { ok: false, error: "too_many_members", status: 400, detail: { limit: MAX_GROUP_MEMBERS } };
212
+ const ids: string[] = [];
213
+ const seen = new Set<string>();
214
+ for (const [index, item] of raw.entries()) {
215
+ const id = typeof item === "string" ? item.trim() : "";
216
+ if (!id) return { ok: false, error: "invalid_node_id", status: 400, detail: { index } };
217
+ if (!db.get("SELECT 1 FROM nodes WHERE node_id = ?1 AND network_id = ?2", id, networkId)) {
218
+ return { ok: false, error: "agent_not_in_network", status: 400, detail: { index, node_id: id } };
219
+ }
220
+ if (!seen.has(id)) { seen.add(id); ids.push(id); }
221
+ }
222
+ return { ok: true, ids };
223
+ }
224
+
225
+ export function createAgentGroup(input: { networkId: string; name: unknown; description?: unknown; nodeIds?: unknown; actorUserId: string }): GroupResult {
226
+ const name = normalizeName(input.name);
227
+ if (!name) return { ok: false, error: "invalid_group_name", status: 400, detail: { max_length: MAX_GROUP_NAME } };
228
+ if (db.get("SELECT 1 FROM agent_groups WHERE network_id = ?1 AND name = ?2", input.networkId, name)) {
229
+ return { ok: false, error: "group_name_taken", status: 409 };
230
+ }
231
+ let ids: string[] = [];
232
+ if (input.nodeIds !== undefined) {
233
+ const v = validateNodeIds(input.networkId, input.nodeIds);
234
+ if (!v.ok) return v;
235
+ ids = v.ids;
236
+ }
237
+ const description = typeof input.description === "string" ? input.description.trim().slice(0, 500) || null : null;
238
+ const groupId = `agrp_${crypto.randomUUID().replace(/-/g, "")}`;
239
+ db.transaction(() => {
240
+ db.run("INSERT INTO agent_groups (group_id, network_id, name, description, created_by) VALUES (?1, ?2, ?3, ?4, ?5)",
241
+ [groupId, input.networkId, name, description, input.actorUserId]);
242
+ for (const id of ids) db.run("INSERT INTO agent_group_members (group_id, node_id, added_by) VALUES (?1, ?2, ?3)", [groupId, id, input.actorUserId]);
243
+ });
244
+ return { ok: true, group: getAgentGroup(input.networkId, groupId)! };
245
+ }
246
+
247
+ export function updateAgentGroup(input: { networkId: string; groupId: string; name?: unknown; description?: unknown }): GroupResult {
248
+ if (!db.get("SELECT 1 FROM agent_groups WHERE group_id = ?1 AND network_id = ?2", input.groupId, input.networkId)) {
249
+ return { ok: false, error: "group_not_found", status: 404 };
250
+ }
251
+ if (input.name !== undefined) {
252
+ const name = normalizeName(input.name);
253
+ if (!name) return { ok: false, error: "invalid_group_name", status: 400, detail: { max_length: MAX_GROUP_NAME } };
254
+ if (db.get("SELECT 1 FROM agent_groups WHERE network_id = ?1 AND name = ?2 AND group_id != ?3", input.networkId, name, input.groupId)) {
255
+ return { ok: false, error: "group_name_taken", status: 409 };
256
+ }
257
+ db.run("UPDATE agent_groups SET name = ?1, updated_at = datetime('now') WHERE group_id = ?2", [name, input.groupId]);
258
+ }
259
+ if (input.description !== undefined) {
260
+ const description = typeof input.description === "string" ? input.description.trim().slice(0, 500) || null : null;
261
+ db.run("UPDATE agent_groups SET description = ?1, updated_at = datetime('now') WHERE group_id = ?2", [description, input.groupId]);
262
+ }
263
+ return { ok: true, group: getAgentGroup(input.networkId, input.groupId)! };
264
+ }
265
+
266
+ /** 整体替换组成员;返回增删 diff(审计只记 diff)。 */
267
+ export function replaceAgentGroupMembers(input: { networkId: string; groupId: string; nodeIds: unknown; actorUserId: string }):
268
+ { ok: true; group: AgentGroup; added: string[]; removed: string[] } | { ok: false; error: string; status: number; detail?: unknown } {
269
+ if (!db.get("SELECT 1 FROM agent_groups WHERE group_id = ?1 AND network_id = ?2", input.groupId, input.networkId)) {
270
+ return { ok: false, error: "group_not_found", status: 404 };
271
+ }
272
+ const v = validateNodeIds(input.networkId, input.nodeIds);
273
+ if (!v.ok) return v;
274
+ const before = new Set(db.all<{ node_id: string }>("SELECT node_id FROM agent_group_members WHERE group_id = ?1", input.groupId).map((r) => r.node_id));
275
+ const after = new Set(v.ids);
276
+ const added = v.ids.filter((id) => !before.has(id));
277
+ const removed = [...before].filter((id) => !after.has(id));
278
+ db.transaction(() => {
279
+ for (const id of removed) db.run("DELETE FROM agent_group_members WHERE group_id = ?1 AND node_id = ?2", [input.groupId, id]);
280
+ for (const id of added) db.run("INSERT INTO agent_group_members (group_id, node_id, added_by) VALUES (?1, ?2, ?3)", [input.groupId, id, input.actorUserId]);
281
+ db.run("UPDATE agent_groups SET updated_at = datetime('now') WHERE group_id = ?1", [input.groupId]);
282
+ });
283
+ return { ok: true, group: getAgentGroup(input.networkId, input.groupId)!, added, removed };
284
+ }
285
+
286
+ /** 删组:连同组成员与组上的全部授权。返回受影响的成员(调用方断开其实时流、写审计)。 */
287
+ export function deleteAgentGroup(networkId: string, groupId: string): { ok: true; affected_user_ids: string[] } | { ok: false; error: string; status: number } {
288
+ if (!db.get("SELECT 1 FROM agent_groups WHERE group_id = ?1 AND network_id = ?2", groupId, networkId)) {
289
+ return { ok: false, error: "group_not_found", status: 404 };
290
+ }
291
+ const affected = usersGrantedGroup(groupId);
292
+ db.transaction(() => {
293
+ db.run("DELETE FROM network_member_group_grants WHERE group_id = ?1", [groupId]);
294
+ db.run("DELETE FROM agent_group_members WHERE group_id = ?1", [groupId]);
295
+ db.run("DELETE FROM agent_groups WHERE group_id = ?1", [groupId]);
296
+ });
297
+ return { ok: true, affected_user_ids: affected };
298
+ }
299
+
124
300
  /** 这个用户名在该网络里是否同时是某个 Agent 的 alias(sessions 或 nodes)。 */
125
301
  export function usernameIsAgentAlias(networkId: string, username: string): boolean {
126
302
  if (!username) return false;
@@ -155,7 +331,7 @@ export function canMessageAgent(userId: string, networkId: string | null | undef
155
331
  export type AgentGrantInput = { node_id?: unknown; alias?: unknown; can_message?: unknown };
156
332
 
157
333
  export type ReplaceGrantsResult =
158
- | { ok: true; agent_access: AgentAccessMode; grants: AgentGrant[] }
334
+ | { ok: true; agent_access: AgentAccessMode; grants: AgentGrant[]; group_grants: AgentGroupGrant[] }
159
335
  | { ok: false; error: string; status: number; detail?: unknown };
160
336
 
161
337
  const MAX_GRANTS = 1000;
@@ -169,6 +345,8 @@ export function replaceAgentGrants(input: {
169
345
  userId: string;
170
346
  grants: unknown;
171
347
  agentAccess?: unknown;
348
+ /** RFC-038 §8:组授权。undefined = 保持原样(旧 app 的 PUT 不带它,不能把组授权清掉)。 */
349
+ groupGrants?: unknown;
172
350
  actorUserId: string;
173
351
  }): ReplaceGrantsResult {
174
352
  const member = db.get<{ role: string }>(
@@ -215,7 +393,38 @@ export function replaceAgentGrants(input: {
215
393
  }
216
394
  }
217
395
 
396
+ let groupRows: Array<{ group_id: string; can_message: number }> | undefined;
397
+ if (input.groupGrants !== undefined) {
398
+ if (!Array.isArray(input.groupGrants)) return { ok: false, error: "group_grants_must_be_array", status: 400 };
399
+ if (input.groupGrants.length > MAX_GRANTS) return { ok: false, error: "too_many_grants", status: 400, detail: { limit: MAX_GRANTS } };
400
+ const seenGroups = new Set<string>();
401
+ groupRows = [];
402
+ for (const [index, raw] of input.groupGrants.entries()) {
403
+ const item = typeof raw === "string" ? { group_id: raw } : (raw && typeof raw === "object" && !Array.isArray(raw) ? raw as { group_id?: unknown; can_message?: unknown } : {});
404
+ const groupId = typeof item.group_id === "string" ? item.group_id.trim() : "";
405
+ if (!groupId) return { ok: false, error: "group_grant_needs_group_id", status: 400, detail: { index } };
406
+ if (item.can_message !== undefined && typeof item.can_message !== "boolean") {
407
+ return { ok: false, error: "can_message_must_be_boolean", status: 400, detail: { index } };
408
+ }
409
+ if (!db.get("SELECT 1 FROM agent_groups WHERE group_id = ?1 AND network_id = ?2", groupId, input.networkId)) {
410
+ return { ok: false, error: "group_not_in_network", status: 400, detail: { index, group_id: groupId } };
411
+ }
412
+ if (seenGroups.has(groupId)) continue;
413
+ seenGroups.add(groupId);
414
+ groupRows.push({ group_id: groupId, can_message: item.can_message === false ? 0 : 1 });
415
+ }
416
+ }
417
+
218
418
  db.transaction(() => {
419
+ if (groupRows) {
420
+ db.run("DELETE FROM network_member_group_grants WHERE network_id = ?1 AND user_id = ?2", [input.networkId, input.userId]);
421
+ for (const row of groupRows) {
422
+ db.run(
423
+ `INSERT INTO network_member_group_grants (network_id, user_id, group_id, can_message, created_by) VALUES (?1, ?2, ?3, ?4, ?5)`,
424
+ [input.networkId, input.userId, row.group_id, row.can_message, input.actorUserId],
425
+ );
426
+ }
427
+ }
219
428
  if (mode) {
220
429
  db.run("UPDATE network_members SET agent_access = ?1 WHERE network_id = ?2 AND user_id = ?3", [mode, input.networkId, input.userId]);
221
430
  }
@@ -235,12 +444,14 @@ export function replaceAgentGrants(input: {
235
444
  ok: true,
236
445
  agent_access: getAgentAccessMode(input.networkId, input.userId) ?? "granted",
237
446
  grants: listAgentGrants(input.networkId, input.userId),
447
+ group_grants: listGroupGrants(input.networkId, input.userId),
238
448
  };
239
449
  }
240
450
 
241
451
  /** 成员被移出网络时一并清掉授权,重新加入时从零开始。 */
242
452
  export function deleteAgentGrants(networkId: string, userId: string): void {
243
453
  db.run("DELETE FROM network_member_agent_grants WHERE network_id = ?1 AND user_id = ?2", [networkId, userId]);
454
+ db.run("DELETE FROM network_member_group_grants WHERE network_id = ?1 AND user_id = ?2", [networkId, userId]);
244
455
  }
245
456
 
246
457
  // 多用户 Agent 权限 —— 受限成员(只看授权 Agent 的成员)能调的 MCP 工具。每一个都逐条审过:
@@ -0,0 +1,247 @@
1
+ // Agent 分组(RFC-038 §8)—— HTTP 集成测试(真实 Bun.serve,私有端口,临时库)。
2
+ //
3
+ // 场景:Hub 管理员 admin 的网络 NET 里有 3 个 Agent(X、Y、Z);受限成员 alice 没有任何直接授权。
4
+ // 正向:组授权 ⇒ 看得见、能派活;往组里加节点 ⇒ 下一次请求就可见(动态);直接授权与组授权取并集。
5
+ // 反向:移出组 ⇒ 403 且与不存在的 alias 逐字节相同;删组 ⇒ 访问消失;跨网络 id ⇒ 400;
6
+ // 节点令牌 / 受限成员碰组接口 ⇒ 403。
7
+ // 兼容:旧 app 形状的 PUT(只带 grants)不动组授权;agent_access='all' 的成员不看组。
8
+
9
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
10
+ import { mkdtempSync, rmSync } from "fs";
11
+ import { join } from "path";
12
+ import { tmpdir } from "os";
13
+ import { register } from "./auth.js";
14
+ import { db } from "./db.js";
15
+
16
+ const DIR = mkdtempSync(join(tmpdir(), "anet-agent-groups-"));
17
+ let BASE = "";
18
+ let hub: any = null;
19
+
20
+ const PW = "GroupTestPassw0rd!x";
21
+ let adminToken = "";
22
+ let adminId = "";
23
+ let NET = "";
24
+ let OTHER_NET = "";
25
+ let aliceToken = "";
26
+ let aliceId = "";
27
+ let carolToken = "";
28
+ let carolId = "";
29
+ let nodeToken = "";
30
+
31
+ const X = { alias: "grp-agent-x", node: "node_grp_x" };
32
+ const Y = { alias: "grp-agent-y", node: "node_grp_y" };
33
+ const Z = { alias: "grp-agent-z", node: "node_grp_z" };
34
+ const FOREIGN = { alias: "grp-agent-foreign", node: "node_grp_foreign" };
35
+
36
+ const auth = (token: string) => ({ Authorization: `Bearer ${token}` });
37
+ const json = (token: string) => ({ ...auth(token), "Content-Type": "application/json" });
38
+
39
+ function seedAgent(a: { alias: string; node: string }, net: string) {
40
+ db.run(
41
+ `INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, updated_at) VALUES (?1, ?2, ?2, ?3, ?4, datetime('now'))`,
42
+ [a.node, a.alias, net, adminId],
43
+ );
44
+ db.run(
45
+ `INSERT INTO sessions (resume_id, alias, node_id, status, network_id, updated_at, last_seen_at)
46
+ VALUES (?1, ?2, ?3, 'idle', ?4, datetime('now'), datetime('now'))`,
47
+ [`resume_${a.node}`, a.alias, a.node, net],
48
+ );
49
+ }
50
+
51
+ async function send(token: string, method: string, path: string, payload?: unknown): Promise<{ status: number; body: any; text: string }> {
52
+ const res = await fetch(`${BASE}${path}`, { method, headers: json(token), body: payload === undefined ? undefined : JSON.stringify(payload) });
53
+ const text = await res.text();
54
+ let body: any = null;
55
+ try { body = JSON.parse(text); } catch {}
56
+ return { status: res.status, body, text };
57
+ }
58
+ const get = (token: string, path: string) => send(token, "GET", path);
59
+ const seen = async (token: string) => {
60
+ const r = await get(token, `/api/status?network_id=${NET}`);
61
+ expect(r.status).toBe(200);
62
+ return (r.body.sessions ?? []).map((s: any) => s.alias).filter((a: string) => a.startsWith("grp-agent-")).sort();
63
+ };
64
+ // 每次内容不同:hub 按内容哈希 5 分钟去重(send_dedup),同样的探针第二次会 429。
65
+ let probeSeq = 0;
66
+ const task = (token: string, alias: string) => send(token, "POST", "/api/task", { alias, task: `probe ${alias} #${++probeSeq}`, network_id: NET });
67
+ const grantsPath = (uid: string) => `/api/networks/${NET}/members/${uid}/agent-grants`;
68
+ const groupsPath = `/api/networks/${"__NET__"}/agent-groups`;
69
+ const gp = (suffix = "") => groupsPath.replace("__NET__", NET) + suffix;
70
+
71
+ beforeAll(async () => {
72
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
73
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
74
+ process.env.HOST = "127.0.0.1";
75
+
76
+ const admin = register(`grp_admin_${Date.now()}`, PW, undefined, "Admin");
77
+ expect(admin.ok).toBe(true);
78
+ adminToken = admin.token!;
79
+ adminId = admin.user!.user_id;
80
+ NET = admin.network_id!;
81
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [adminId]);
82
+ for (const a of [X, Y, Z]) seedAgent(a, NET);
83
+
84
+ const mod: any = await import("./server.js");
85
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
86
+ BASE = `http://127.0.0.1:${hub.port}`;
87
+
88
+ // 另一个网络(admin 也是 owner),放一个外网节点,用来测跨网络 id。
89
+ const other = await send(adminToken, "POST", "/api/networks", { name: `grp-other-${Date.now()}` });
90
+ expect(other.status).toBe(200);
91
+ OTHER_NET = other.body.network_id ?? other.body.network?.network_id;
92
+ expect(typeof OTHER_NET).toBe("string");
93
+ seedAgent(FOREIGN, OTHER_NET);
94
+
95
+ const mk = async (username: string, agentAccess?: "all") => {
96
+ const r = await send(adminToken, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role: "member" });
97
+ expect(r.status).toBe(200);
98
+ const login = await send("", "POST", "/api/auth/login", { username, password: PW });
99
+ expect(login.status).toBe(200);
100
+ if (agentAccess) db.run("UPDATE network_members SET agent_access = 'all' WHERE network_id = ?1 AND user_id = ?2", [NET, login.body.user.user_id]);
101
+ return { token: login.body.token as string, id: login.body.user.user_id as string };
102
+ };
103
+ const alice = await mk(`grp_alice_${Date.now()}`);
104
+ aliceToken = alice.token; aliceId = alice.id;
105
+ const carol = await mk(`grp_carol_${Date.now()}`, "all");
106
+ carolToken = carol.token; carolId = carol.id;
107
+
108
+ const nt = await send(adminToken, "POST", "/api/auth/node-token", { network_id: NET, node_name: X.alias, node_id: X.node });
109
+ expect(nt.status).toBe(200);
110
+ nodeToken = nt.body.token;
111
+ }, 30_000);
112
+
113
+ afterAll(() => {
114
+ try { hub?.stop?.(true); } catch {}
115
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
116
+ });
117
+
118
+ let groupId = "";
119
+
120
+ describe("Agent 分组:CRUD 与权限", () => {
121
+ test("owner 建组(带成员);重名 409;外网节点 400 且整批不写", async () => {
122
+ const r = await send(adminToken, "POST", gp(), { name: "前端组", node_ids: [X.node] });
123
+ expect(r.status).toBe(200);
124
+ groupId = r.body.group.group_id;
125
+ expect(r.body.group.node_ids).toEqual([X.node]);
126
+ expect((await send(adminToken, "POST", gp(), { name: "前端组" })).status).toBe(409);
127
+ const bad = await send(adminToken, "POST", gp(), { name: "坏组", node_ids: [Y.node, FOREIGN.node] });
128
+ expect(bad.status).toBe(400);
129
+ expect(bad.body.error).toBe("agent_not_in_network");
130
+ const list = await get(adminToken, gp());
131
+ expect(list.body.groups.map((g: any) => g.name)).toEqual(["前端组"]);
132
+ });
133
+
134
+ test("受限成员、节点令牌碰组接口 ⇒ 403", async () => {
135
+ expect((await get(aliceToken, gp())).status).toBe(403);
136
+ expect((await send(aliceToken, "POST", gp(), { name: "偷偷建" })).status).toBe(403);
137
+ expect((await get(nodeToken, gp())).status).toBe(403);
138
+ });
139
+
140
+ test("审计:建组写 agent_group_created(带 network_id)", () => {
141
+ const row = db.get<{ network_id: string; detail: string }>("SELECT network_id, detail FROM audit_log WHERE action = 'agent_group_created' AND target_id = ?1", groupId);
142
+ expect(row?.network_id).toBe(NET);
143
+ expect(row?.detail).toContain("前端组");
144
+ });
145
+ });
146
+
147
+ describe("组授权:动态生效", () => {
148
+ test("基线:alice 没有任何授权 ⇒ 看不到、发不了", async () => {
149
+ expect(await seen(aliceToken)).toEqual([]);
150
+ expect((await task(aliceToken, X.alias)).status).toBe(403);
151
+ });
152
+
153
+ test("授权给组 ⇒ 看到组里的 X、能派活;GET 回 group_grants", async () => {
154
+ const put = await send(adminToken, "PUT", grantsPath(aliceId), { group_grants: [{ group_id: groupId, can_message: true }] });
155
+ expect(put.status).toBe(200);
156
+ expect(put.body.group_grants).toEqual([{ group_id: groupId, name: "前端组", can_message: true }]);
157
+ expect(put.body.restricted).toBe(true);
158
+ expect(await seen(aliceToken)).toEqual([X.alias]);
159
+ expect((await task(aliceToken, X.alias)).status).toBe(200);
160
+ expect((await task(aliceToken, Y.alias)).status).toBe(403);
161
+ const g = await get(adminToken, grantsPath(aliceId));
162
+ expect(g.body.grants).toEqual([]);
163
+ expect(g.body.group_grants.length).toBe(1);
164
+ });
165
+
166
+ test("往组里加 Y ⇒ alice 下一次请求就看得见 Y(不用改她的授权)", async () => {
167
+ const r = await send(adminToken, "PUT", gp(`/${groupId}/members`), { node_ids: [X.node, Y.node] });
168
+ expect(r.status).toBe(200);
169
+ expect(r.body.added).toEqual([Y.node]);
170
+ expect(await seen(aliceToken)).toEqual([X.alias, Y.alias]);
171
+ expect((await task(aliceToken, Y.alias)).status).toBe(200);
172
+ const audit = db.get<{ detail: string }>("SELECT detail FROM audit_log WHERE action = 'agent_group_members_changed' AND target_id = ?1 ORDER BY id DESC LIMIT 1", groupId);
173
+ expect(JSON.parse(audit!.detail)).toEqual({ added: [Y.node], removed: [] });
174
+ });
175
+
176
+ test("从组里移出 Y ⇒ 看不见,派活 403 且与不存在的 alias 逐字节相同", async () => {
177
+ await send(adminToken, "PUT", gp(`/${groupId}/members`), { node_ids: [X.node] });
178
+ expect(await seen(aliceToken)).toEqual([X.alias]);
179
+ const y = await task(aliceToken, Y.alias);
180
+ const ghost = await task(aliceToken, "grp-agent-does-not-exist");
181
+ expect(y.status).toBe(403);
182
+ expect(y.text).toBe(ghost.text);
183
+ });
184
+
185
+ test("直接授权与组授权取并集;可对话任一来源给了就算", async () => {
186
+ // 组授权只读,Z 直接授权可对话
187
+ const put = await send(adminToken, "PUT", grantsPath(aliceId), {
188
+ grants: [{ node_id: Z.node, can_message: true }],
189
+ group_grants: [{ group_id: groupId, can_message: false }],
190
+ });
191
+ expect(put.status).toBe(200);
192
+ expect(await seen(aliceToken)).toEqual([X.alias, Z.alias]);
193
+ expect((await task(aliceToken, X.alias)).status).toBe(403); // 组只读
194
+ expect((await task(aliceToken, Z.alias)).status).toBe(200); // 直接可对话
195
+ // X 再给一条直接可对话授权 ⇒ 并集 ⇒ 可以发
196
+ await send(adminToken, "PUT", grantsPath(aliceId), { grants: [{ node_id: Z.node, can_message: true }, { node_id: X.node, can_message: true }] });
197
+ expect((await task(aliceToken, X.alias)).status).toBe(200);
198
+ });
199
+
200
+ test("兼容:旧 app 形状的 PUT(只带 grants,不带 group_grants)不动组授权", async () => {
201
+ const before = await get(adminToken, grantsPath(aliceId));
202
+ expect(before.body.group_grants.length).toBe(1);
203
+ const old = await send(adminToken, "PUT", grantsPath(aliceId), { grants: [{ node_id: Z.node, can_message: true }] });
204
+ expect(old.status).toBe(200);
205
+ expect(old.body.group_grants.length).toBe(1);
206
+ expect(await seen(aliceToken)).toEqual([X.alias, Z.alias]);
207
+ });
208
+
209
+ test("组授权:外网 / 不存在的 group_id ⇒ 400,整批不写", async () => {
210
+ const other = await send(adminToken, "POST", `/api/networks/${OTHER_NET}/agent-groups`, { name: "外网组" });
211
+ expect(other.status).toBe(200);
212
+ const bad = await send(adminToken, "PUT", grantsPath(aliceId), { group_grants: [{ group_id: other.body.group.group_id }] });
213
+ expect(bad.status).toBe(400);
214
+ expect(bad.body.error).toBe("group_not_in_network");
215
+ expect((await get(adminToken, grantsPath(aliceId))).body.group_grants.map((g: any) => g.group_id)).toEqual([groupId]);
216
+ });
217
+
218
+ test("agent_access='all' 的成员不看组:照旧全部可见", async () => {
219
+ expect(await seen(carolToken)).toEqual([X.alias, Y.alias, Z.alias]);
220
+ });
221
+
222
+ test("成员列表带 agent_group_count", async () => {
223
+ const r = await get(adminToken, `/api/networks/${NET}/members`);
224
+ const alice = r.body.members.find((m: any) => m.user_id === aliceId);
225
+ expect(alice.agent_group_count).toBe(1);
226
+ });
227
+
228
+ test("删组 ⇒ 组授权随之消失,只剩直接授权;响应带受影响成员;审计", async () => {
229
+ const del = await send(adminToken, "DELETE", gp(`/${groupId}`));
230
+ expect(del.status).toBe(200);
231
+ expect(del.body.affected_user_ids).toEqual([aliceId]);
232
+ expect(await seen(aliceToken)).toEqual([Z.alias]);
233
+ expect((await task(aliceToken, X.alias)).status).toBe(403);
234
+ expect((await get(adminToken, grantsPath(aliceId))).body.group_grants).toEqual([]);
235
+ expect(db.get("SELECT 1 FROM audit_log WHERE action = 'agent_group_deleted' AND target_id = ?1", groupId)).toBeTruthy();
236
+ expect((await send(adminToken, "DELETE", gp(`/${groupId}`))).status).toBe(404);
237
+ });
238
+
239
+ test("移出网络 ⇒ 组授权一并清掉", async () => {
240
+ const g2 = await send(adminToken, "POST", gp(), { name: "第二组", node_ids: [Y.node] });
241
+ await send(adminToken, "PUT", grantsPath(aliceId), { group_grants: [g2.body.group.group_id] });
242
+ expect(db.get("SELECT 1 FROM network_member_group_grants WHERE user_id = ?1", aliceId)).toBeTruthy();
243
+ const rm = await send(adminToken, "DELETE", `/api/networks/${NET}/members/${aliceId}`);
244
+ expect(rm.status).toBe(200);
245
+ expect(db.get("SELECT 1 FROM network_member_group_grants WHERE user_id = ?1", aliceId)).toBeFalsy();
246
+ });
247
+ });
@@ -368,6 +368,14 @@ describe("requirements: agent references alice cannot see are hidden and protect
368
368
  const r = await get(aliceToken, `/api/requirements?network_id=${NET}&agent_owner=node:${Z.node}`);
369
369
  expect(r.body.requirements).toEqual([]);
370
370
  });
371
+ test("q= does not match on a hidden agent's name (no 'who owns this card' probe); admin's q= does", async () => {
372
+ const zName = db.get<{ name: string }>("SELECT COALESCE(NULLIF(display_name,''), NULLIF(alias,''), node_name) AS name FROM nodes WHERE node_id = ?1", Z.node)!.name;
373
+ const ids = async (token: string, q: string) => (await get(token, `/api/requirements?network_id=${NET}&q=${encodeURIComponent(q)}`)).body.requirements.map((r: any) => r.id);
374
+ expect(await ids(aliceToken, zName)).not.toContain(reqId);
375
+ expect(await ids(adminToken, zName)).toContain(reqId);
376
+ // 看得见的字段照样能搜到(标题)
377
+ expect(await ids(aliceToken, "card")).toContain(reqId);
378
+ });
371
379
  });
372
380
 
373
381
  describe("cannot be abused", () => {