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

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,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
+ });
@@ -0,0 +1,167 @@
1
+ // Agent 分组的性能守卫(RFC-038 §8):visibleAgents() 每个请求多做一次「组授权 → 组成员」展开,
2
+ // 这里量它的额外开销,并用 EXPLAIN QUERY PLAN 钉住展开查询走索引(不是全表扫)。
3
+ //
4
+ // 场景:NET 里 300 个节点;50 个组,每组 6 个节点(合起来覆盖全部 300 个)。
5
+ // baseline —— bob 直接授权这 300 个节点(没有组);
6
+ // groups —— alice 授权这 50 个组(没有直接授权),可见集合与 bob 完全相同。
7
+ // 两人看到的 Agent 一样多,差值就是「按组展开」本身的代价(交替量 15 轮,取每轮差值的中位数)。团队要求 ≈2 ms 以内(本机实测 −0.5~+1.9 ms);
8
+ // 断言放宽到 max(5 ms, 基线的 15%)—— CI 容器比本机慢时两边一起变慢,绝对差也跟着放大。数字打印出来。
9
+
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 } from "./auth.js";
15
+ import { db } from "./db.js";
16
+ import { visibleAgents } from "./agent-access.js";
17
+
18
+ const DIR = mkdtempSync(join(tmpdir(), "anet-agent-groups-perf-"));
19
+ let BASE = "";
20
+ let hub: any = null;
21
+ const PW = "GroupPerfPassw0rd!x";
22
+ let NET = "";
23
+ let adminToken = "";
24
+ let adminId = "";
25
+ let alice = { token: "", id: "" };
26
+ let bob = { token: "", id: "" };
27
+ const NODES = 300;
28
+ const GROUPS = 50;
29
+ const PER_GROUP = NODES / GROUPS;
30
+ const BOUND_MS = 5;
31
+ const bound = (base: number) => Math.max(BOUND_MS, base * 0.15);
32
+ // CI 容器里一次 visibleAgents() 约 65 ms(本机约 28 ms):原来 40 次 × 两边再加预热,顶破了 bun 默认的 5 s 单测超时(#2131 CI 实测)。
33
+ // 15 次取中位数足够稳;性能用例另给显式超时。
34
+ const WARMUP = 3;
35
+ const ITER = 15;
36
+ const PERF_TIMEOUT_MS = 60_000;
37
+
38
+ const json = (token: string) => ({ Authorization: `Bearer ${token}`, "Content-Type": "application/json" });
39
+ async function send(token: string, method: string, path: string, payload?: unknown) {
40
+ const res = await fetch(`${BASE}${path}`, { method, headers: json(token), body: payload === undefined ? undefined : JSON.stringify(payload) });
41
+ return { status: res.status, body: await res.json().catch(() => null) as any };
42
+ }
43
+ const median = (xs: number[]) => { const s = [...xs].sort((a, b) => a - b); return s[Math.floor(s.length / 2)]; };
44
+ /**
45
+ * 交替计时:每一轮先量 A 再量 B,取「B − A」的中位数(再附上两边各自的中位数给人看)。
46
+ * 不能 A 连量 N 次再 B 连量 N 次:test638 在同一台机器上并发跑两份 aggregate,负载随时间变,
47
+ * 两段分开量时差值被负载漂移主导(--cpus=1 实测差出 26–33 ms,真实差值 < 1 ms)。
48
+ */
49
+ function pairedDelta(a: () => void, b: () => void, n: number): { a: number; b: number; delta: number } {
50
+ for (let i = 0; i < WARMUP; i++) { a(); b(); }
51
+ const as: number[] = [], bs: number[] = [], ds: number[] = [];
52
+ for (let i = 0; i < n; i++) {
53
+ let t = performance.now(); a(); const ta = performance.now() - t;
54
+ t = performance.now(); b(); const tb = performance.now() - t;
55
+ as.push(ta); bs.push(tb); ds.push(tb - ta);
56
+ }
57
+ return { a: median(as), b: median(bs), delta: median(ds) };
58
+ }
59
+ async function pairedDeltaAsync(a: () => Promise<void>, b: () => Promise<void>, n: number): Promise<{ a: number; b: number; delta: number }> {
60
+ for (let i = 0; i < WARMUP; i++) { await a(); await b(); }
61
+ const as: number[] = [], bs: number[] = [], ds: number[] = [];
62
+ for (let i = 0; i < n; i++) {
63
+ let t = performance.now(); await a(); const ta = performance.now() - t;
64
+ t = performance.now(); await b(); const tb = performance.now() - t;
65
+ as.push(ta); bs.push(tb); ds.push(tb - ta);
66
+ }
67
+ return { a: median(as), b: median(bs), delta: median(ds) };
68
+ }
69
+
70
+ beforeAll(async () => {
71
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
72
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
73
+ process.env.HOST = "127.0.0.1";
74
+ const admin = register(`perf_admin_${Date.now()}`, PW, undefined, "Admin");
75
+ expect(admin.ok).toBe(true);
76
+ adminToken = admin.token!;
77
+ adminId = admin.user!.user_id;
78
+ NET = admin.network_id!;
79
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [adminId]);
80
+ db.transaction(() => {
81
+ for (let i = 0; i < NODES; i++) {
82
+ const node = `node_perf_${i}`;
83
+ const alias = `perf-agent-${i}`;
84
+ db.run(`INSERT INTO nodes (node_id, node_name, alias, network_id, owner_user_id, updated_at) VALUES (?1, ?2, ?2, ?3, ?4, datetime('now'))`, [node, alias, NET, adminId]);
85
+ db.run(`INSERT INTO sessions (resume_id, alias, node_id, status, network_id, updated_at, last_seen_at) VALUES (?1, ?2, ?3, 'idle', ?4, datetime('now'), datetime('now'))`, [`resume_${node}`, alias, node, NET]);
86
+ }
87
+ });
88
+ const mod: any = await import("./server.js");
89
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
90
+ BASE = `http://127.0.0.1:${hub.port}`;
91
+
92
+ const mk = async (username: string) => {
93
+ const r = await send(adminToken, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role: "member" });
94
+ expect(r.status).toBe(200);
95
+ const login = await send("", "POST", "/api/auth/login", { username, password: PW });
96
+ return { token: login.body.token as string, id: login.body.user.user_id as string };
97
+ };
98
+ alice = await mk(`perf_alice_${Date.now()}`);
99
+ bob = await mk(`perf_bob_${Date.now()}`);
100
+
101
+ const groupIds: string[] = [];
102
+ for (let g = 0; g < GROUPS; g++) {
103
+ const nodeIds = Array.from({ length: PER_GROUP }, (_, k) => `node_perf_${g * PER_GROUP + k}`);
104
+ const r = await send(adminToken, "POST", `/api/networks/${NET}/agent-groups`, { name: `perf-group-${g}`, node_ids: nodeIds });
105
+ expect(r.status).toBe(200);
106
+ groupIds.push(r.body.group.group_id);
107
+ }
108
+ expect((await send(adminToken, "PUT", `/api/networks/${NET}/members/${alice.id}/agent-grants`, { group_grants: groupIds })).status).toBe(200);
109
+ expect((await send(adminToken, "PUT", `/api/networks/${NET}/members/${bob.id}/agent-grants`, { grants: Array.from({ length: NODES }, (_, i) => `node_perf_${i}`) })).status).toBe(200);
110
+ }, 60_000);
111
+
112
+ afterAll(() => {
113
+ try { hub?.stop?.(true); } catch {}
114
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
115
+ });
116
+
117
+ describe("Agent 分组:性能守卫(50 组 × 300 节点)", () => {
118
+ test("两人可见集合相同(比较才公平)", () => {
119
+ const a = visibleAgents(alice.id, NET);
120
+ const b = visibleAgents(bob.id, NET);
121
+ expect(a.nodeIds.length).toBe(NODES);
122
+ expect([...a.nodeIds].sort()).toEqual([...b.nodeIds].sort());
123
+ });
124
+
125
+ test(`visibleAgents():组展开的额外开销 ≤ max(${BOUND_MS} ms, 基线 15%)(目标 ≈2 ms)`, () => {
126
+ const r = pairedDelta(() => { visibleAgents(bob.id, NET); }, () => { visibleAgents(alice.id, NET); }, ITER);
127
+ console.log(`[perf] visibleAgents median: direct-300=${r.a.toFixed(2)}ms groups-50x6=${r.b.toFixed(2)}ms paired-delta=${r.delta.toFixed(2)}ms bound=${bound(r.a).toFixed(2)}ms`);
128
+ expect(r.delta).toBeLessThanOrEqual(bound(r.a));
129
+ }, PERF_TIMEOUT_MS);
130
+
131
+ test(`受限成员 GET /api/status:组授权比等量直接授权多出 ≤ max(${BOUND_MS} ms, 基线 15%)`, async () => {
132
+ const hit = async (token: string) => {
133
+ const r = await fetch(`${BASE}/api/status?network_id=${NET}`, { headers: json(token) });
134
+ const body = await r.json() as any;
135
+ if ((body.sessions ?? []).length !== NODES) throw new Error(`expected ${NODES} sessions, got ${(body.sessions ?? []).length}`);
136
+ };
137
+ const r = await pairedDeltaAsync(() => hit(bob.token), () => hit(alice.token), ITER);
138
+ console.log(`[perf] GET /api/status median: direct-300=${r.a.toFixed(2)}ms groups-50x6=${r.b.toFixed(2)}ms paired-delta=${r.delta.toFixed(2)}ms bound=${bound(r.a).toFixed(2)}ms`);
139
+ expect(r.delta).toBeLessThanOrEqual(bound(r.a));
140
+ }, PERF_TIMEOUT_MS);
141
+
142
+ test("EXPLAIN QUERY PLAN:组展开走索引,不全表扫描", () => {
143
+ const plan = db.all<{ detail: string }>(
144
+ `EXPLAIN QUERY PLAN
145
+ SELECT m.node_id, g.can_message
146
+ FROM network_member_group_grants g
147
+ JOIN agent_groups ag ON ag.group_id = g.group_id AND ag.network_id = g.network_id
148
+ JOIN agent_group_members m ON m.group_id = g.group_id
149
+ JOIN nodes n ON n.node_id = m.node_id AND n.network_id = g.network_id
150
+ WHERE g.network_id = ?1 AND g.user_id = ?2`,
151
+ NET, alice.id,
152
+ ).map((r) => r.detail);
153
+ console.log(`[perf] plan:\n ${plan.join("\n ")}`);
154
+ // 四张表都是 SEARCH(按主键 / 索引定位),没有一张是 SCAN。
155
+ expect(plan.length).toBeGreaterThanOrEqual(4);
156
+ expect(plan.filter((d) => /^SCAN\b/.test(d))).toEqual([]);
157
+ expect(plan.every((d) => /^SEARCH\b/.test(d) && /(PRIMARY KEY|INDEX)/.test(d))).toBe(true);
158
+ });
159
+
160
+ test("EXPLAIN QUERY PLAN:按组找被授权成员(改组成员时断流用)走 group_id 索引", () => {
161
+ const plan = db.all<{ detail: string }>(
162
+ "EXPLAIN QUERY PLAN SELECT user_id FROM network_member_group_grants WHERE group_id = ?1", "x",
163
+ ).map((r) => r.detail);
164
+ console.log(`[perf] plan (usersGrantedGroup): ${plan.join(" | ")}`);
165
+ expect(plan.some((d) => /SEARCH .*idx_group_grants_group/.test(d))).toBe(true);
166
+ });
167
+ });
package/src/auth.ts CHANGED
@@ -582,7 +582,8 @@ export function getNetworkMembers(networkId: string) {
582
582
  return db.all<any>(
583
583
  `SELECT nm.user_id, nm.role, nm.joined_at, nm.invited_by, u.username, u.display_name,
584
584
  CASE WHEN u.role = 'admin' OR nm.role IN ('owner', 'admin') OR nm.agent_access = 'all' THEN 'all' ELSE 'granted' END AS agent_access,
585
- (SELECT COUNT(*) FROM network_member_agent_grants g WHERE g.network_id = nm.network_id AND g.user_id = nm.user_id) AS agent_grant_count
585
+ (SELECT COUNT(*) FROM network_member_agent_grants g WHERE g.network_id = nm.network_id AND g.user_id = nm.user_id) AS agent_grant_count,
586
+ (SELECT COUNT(*) FROM network_member_group_grants gg WHERE gg.network_id = nm.network_id AND gg.user_id = nm.user_id) AS agent_group_count
586
587
  FROM network_members nm JOIN users u ON nm.user_id = u.user_id
587
588
  WHERE nm.network_id = ?1 ORDER BY nm.joined_at`,
588
589
  networkId);
@@ -793,3 +794,77 @@ export function listUsersWithMemberships() {
793
794
  }
794
795
  return users.map((u) => ({ ...u, networks: byUser.get(u.user_id) ?? [] }));
795
796
  }
797
+
798
+ // Hub 管理员删**别人的**网络(清理旧测试账号留下的个人网络)。只删空网络:
799
+ // 下面每张表里有一行就拒(409),并把计数原样回给调用方。表是按 schema 里所有带
800
+ // network_id 的表逐个分类的,不是凭记忆 —— 新加一张带 network_id 的表要在这里归一类。
801
+ // 阻断:真实内容,或会在删后重新长出节点的在途请求。
802
+ // 清理:只描述「这个网络」本身的元数据/历史,网络没了它们就悬空。
803
+ // 保留:audit_log(删除记录本身要留下)。
804
+ const NETWORK_CONTENT_CHECKS: Array<[label: string, sql: string]> = [
805
+ ["nodes", "SELECT COUNT(*) AS cnt FROM nodes WHERE network_id = ?1"],
806
+ ["sessions", "SELECT COUNT(*) AS cnt FROM sessions WHERE network_id = ?1"],
807
+ ["tasks", "SELECT COUNT(*) AS cnt FROM tasks WHERE network_id = ?1"],
808
+ ["inbox", "SELECT COUNT(*) AS cnt FROM inbox WHERE network_id = ?1"],
809
+ ["user_inbox", "SELECT COUNT(*) AS cnt FROM user_inbox WHERE network_id = ?1"],
810
+ ["requirements", "SELECT COUNT(*) AS cnt FROM requirements WHERE network_id = ?1"],
811
+ ["requirement_projects", "SELECT COUNT(*) AS cnt FROM requirement_projects WHERE network_id = ?1"],
812
+ ["scheduled_tasks", "SELECT COUNT(*) AS cnt FROM scheduled_tasks WHERE network_id = ?1"],
813
+ ["providers", "SELECT COUNT(*) AS cnt FROM providers WHERE network_id = ?1"],
814
+ ["network_secrets", "SELECT COUNT(*) AS cnt FROM network_secrets WHERE network_id = ?1"],
815
+ ["skillhub_skills", "SELECT COUNT(*) AS cnt FROM skillhub_skills WHERE network_id = ?1"],
816
+ ["side_chats", "SELECT COUNT(*) AS cnt FROM side_chats WHERE network_id = ?1"],
817
+ ["pending_node_create_requests", "SELECT COUNT(*) AS cnt FROM node_create_requests WHERE network_id = ?1 AND status IN ('pending', 'delivered')"],
818
+ ["pending_node_start_requests", "SELECT COUNT(*) AS cnt FROM node_start_requests WHERE network_id = ?1 AND status IN ('pending', 'delivered')"],
819
+ ];
820
+ const NETWORK_CLEANUP_TABLES = [
821
+ "network_members", "network_member_agent_grants", "network_invites", "api_tokens",
822
+ "node_create_requests", "node_start_requests", "node_stop_requests", "node_rules_requests",
823
+ "node_config_updates", "rename_txn", "probe_results", "agent_telemetry", "completions",
824
+ "task_events", "task_terminal_events", "scheduled_task_runs", "external_schedule_edits",
825
+ ];
826
+
827
+ function tableExists(name: string): boolean {
828
+ return db.dialect === "postgres"
829
+ ? !!db.get("SELECT 1 AS x FROM information_schema.tables WHERE table_name = ?1", name)
830
+ : !!db.get("SELECT 1 AS x FROM sqlite_master WHERE type = 'table' AND name = ?1", name);
831
+ }
832
+
833
+ export function countNetworkContents(networkId: string): Record<string, number> {
834
+ const counts: Record<string, number> = {};
835
+ for (const [label, sql] of NETWORK_CONTENT_CHECKS) {
836
+ const table = sql.match(/FROM (\w+)/)![1];
837
+ // 按需建表的模块(requirements / side-thread)在没用过的库上可能还没有这张表 —— 那就是 0 行。
838
+ if (!tableExists(table)) continue;
839
+ const cnt = db.get<{ cnt: number }>(sql, networkId)?.cnt ?? 0;
840
+ if (cnt > 0) counts[label] = cnt;
841
+ }
842
+ return counts;
843
+ }
844
+
845
+ export type AdminDeleteNetworkResult =
846
+ | { ok: true; owner_id: string; network_name: string; cleaned: Record<string, number> }
847
+ | { ok: false; status: number; error: string; counts?: Record<string, number> };
848
+
849
+ // extraCounts:库外的内容(上传文件的索引在磁盘上),由调用方数好传进来,和库内计数一起判。
850
+ export function adminDeleteEmptyNetwork(networkId: string, extraCounts: Record<string, number> = {}): AdminDeleteNetworkResult {
851
+ if (networkId === "default") return { ok: false, status: 409, error: "the default network cannot be deleted" };
852
+ return db.transaction((): AdminDeleteNetworkResult => {
853
+ const net = db.get<{ owner_id: string; network_name: string }>("SELECT owner_id, network_name FROM networks WHERE network_id = ?1", networkId);
854
+ if (!net) return { ok: false, status: 404, error: "network not found" };
855
+ const counts = { ...countNetworkContents(networkId) };
856
+ for (const [k, v] of Object.entries(extraCounts)) if (v > 0) counts[k] = v;
857
+ if (Object.keys(counts).length > 0) {
858
+ const summary = Object.entries(counts).map(([k, v]) => `${k}=${v}`).join(", ");
859
+ return { ok: false, status: 409, error: `network is not empty (${summary}); only empty networks can be deleted by a hub admin`, counts };
860
+ }
861
+ const cleaned: Record<string, number> = {};
862
+ for (const table of NETWORK_CLEANUP_TABLES) {
863
+ if (!tableExists(table)) continue;
864
+ const changes = db.run(`DELETE FROM ${table} WHERE network_id = ?1`, [networkId]).changes;
865
+ if (changes > 0) cleaned[table] = changes;
866
+ }
867
+ db.run("DELETE FROM networks WHERE network_id = ?1", [networkId]);
868
+ return { ok: true, owner_id: net.owner_id, network_name: net.network_name, cleaned };
869
+ });
870
+ }
@@ -0,0 +1,112 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { PG_EXPERIMENTAL_ENV, PgAdapter, SQLiteAdapter, splitSqlStatements, sqliteToPostgres } from "./db-adapter";
4
+
5
+ const UTC_NOW = "to_char((NOW()) AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')";
6
+
7
+ describe("RFC-039 S2a sqliteToPostgres", () => {
8
+ test("datetime('now') stays SQLite-format UTC text", () => {
9
+ expect(sqliteToPostgres("SELECT datetime('now')")).toBe(`SELECT ${UTC_NOW}`);
10
+ });
11
+
12
+ test("DEFAULT (datetime('now')) keeps the column TEXT", () => {
13
+ const out = sqliteToPostgres("created_at TEXT NOT NULL DEFAULT (datetime('now'))");
14
+ expect(out).toBe(`created_at TEXT NOT NULL DEFAULT (${UTC_NOW})`);
15
+ expect(out).not.toContain("TIMESTAMP");
16
+ });
17
+
18
+ test("literal offset", () => {
19
+ expect(sqliteToPostgres("x < datetime('now', '+14 days')")).toBe(
20
+ "x < to_char((NOW() + INTERVAL '14 days') AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')",
21
+ );
22
+ });
23
+
24
+ test("parameter offset is cast so PG can infer its type", () => {
25
+ expect(sqliteToPostgres("x < datetime('now', ?2)")).toBe(
26
+ "x < to_char((NOW() + CAST($2 AS TEXT)::INTERVAL) AT TIME ZONE 'UTC', 'YYYY-MM-DD HH24:MI:SS')",
27
+ );
28
+ });
29
+
30
+ test("BLOB → BYTEA, identifiers containing blob untouched", () => {
31
+ expect(sqliteToPostgres("iv BLOB NOT NULL, blob_ref TEXT")).toBe("iv BYTEA NOT NULL, blob_ref TEXT");
32
+ });
33
+
34
+ test("placeholders and AUTOINCREMENT unchanged in behaviour", () => {
35
+ expect(sqliteToPostgres("SELECT * FROM t WHERE a = ?1 AND b = ?2")).toBe("SELECT * FROM t WHERE a = $1 AND b = $2");
36
+ expect(sqliteToPostgres("a = ? AND b = ?")).toBe("a = $1 AND b = $2");
37
+ expect(sqliteToPostgres("id INTEGER PRIMARY KEY AUTOINCREMENT")).toBe("id SERIAL PRIMARY KEY");
38
+ });
39
+ });
40
+
41
+ describe("RFC-039 S2a splitSqlStatements", () => {
42
+ test("a `;` inside a -- comment does not split (db.ts first schema block)", () => {
43
+ expect(splitSqlStatements("CREATE TABLE a (x TEXT);\n-- note; more words\nCREATE TABLE b (y TEXT);")).toEqual([
44
+ "CREATE TABLE a (x TEXT)",
45
+ "CREATE TABLE b (y TEXT)",
46
+ ]);
47
+ });
48
+
49
+ test("block comments are dropped", () => {
50
+ expect(splitSqlStatements("SELECT 1 /* a; b */ ; SELECT 2")).toEqual(["SELECT 1", "SELECT 2"]);
51
+ });
52
+
53
+ test("quoted strings and identifiers keep their `;` and doubled quotes", () => {
54
+ expect(splitSqlStatements(`INSERT INTO t VALUES ('a;b', 'it''s;'); SELECT "c;d" FROM t`)).toEqual([
55
+ `INSERT INTO t VALUES ('a;b', 'it''s;')`,
56
+ `SELECT "c;d" FROM t`,
57
+ ]);
58
+ });
59
+
60
+ test("a plpgsql $$ body stays one statement", () => {
61
+ const fn = "CREATE FUNCTION f() RETURNS trigger AS $$\nBEGIN\n INSERT INTO t VALUES (1);\n RETURN NEW;\nEND;\n$$ LANGUAGE plpgsql";
62
+ expect(splitSqlStatements(`${fn};\nDROP TRIGGER IF EXISTS x ON t;`)).toEqual([fn, "DROP TRIGGER IF EXISTS x ON t"]);
63
+ });
64
+
65
+ test("tagged dollar quotes; $1 placeholders are not dollar quotes", () => {
66
+ expect(splitSqlStatements("SELECT $body$a;b$body$; UPDATE t SET a = $1 WHERE b = $2")).toEqual([
67
+ "SELECT $body$a;b$body$",
68
+ "UPDATE t SET a = $1 WHERE b = $2",
69
+ ]);
70
+ });
71
+
72
+ test("empty statements are dropped", () => {
73
+ expect(splitSqlStatements(" ; ;\n SELECT 1 ;; ")).toEqual(["SELECT 1"]);
74
+ });
75
+ });
76
+
77
+ describe("RFC-039 transactional feature gates", () => {
78
+ // A PgAdapter without its constructor: no worker, no connection.
79
+ function pgWith(atomic: boolean) {
80
+ const pg = Object.create(PgAdapter.prototype);
81
+ Object.defineProperty(pg, "atomicTransactions", { value: atomic });
82
+ return pg as PgAdapter;
83
+ }
84
+ function withFlag<T>(value: string | undefined, fn: () => T): T {
85
+ const saved = process.env[PG_EXPERIMENTAL_ENV];
86
+ if (value === undefined) delete process.env[PG_EXPERIMENTAL_ENV];
87
+ else process.env[PG_EXPERIMENTAL_ENV] = value;
88
+ try { return fn(); } finally {
89
+ if (saved === undefined) delete process.env[PG_EXPERIMENTAL_ENV];
90
+ else process.env[PG_EXPERIMENTAL_ENV] = saved;
91
+ }
92
+ }
93
+
94
+ test("SQLite: open, flag irrelevant", () => {
95
+ const sqlite = new SQLiteAdapter(new Database(":memory:"));
96
+ expect(withFlag(undefined, () => sqlite.transactionalFeaturesRefusal)).toBeNull();
97
+ expect(withFlag("1", () => sqlite.transactionalFeaturesRefusal)).toBeNull();
98
+ sqlite.close();
99
+ });
100
+
101
+ test("PostgreSQL with real transactions: closed by default, message names the variable", () => {
102
+ const refusal = withFlag(undefined, () => pgWith(true).transactionalFeaturesRefusal);
103
+ expect(refusal).toContain(`${PG_EXPERIMENTAL_ENV}=1`);
104
+ expect(refusal).toContain("experimental");
105
+ expect(withFlag("true", () => pgWith(true).transactionalFeaturesRefusal)).not.toBeNull();
106
+ });
107
+
108
+ test("PostgreSQL: opens only with atomic transactions AND the opt-in", () => {
109
+ expect(withFlag("1", () => pgWith(true).transactionalFeaturesRefusal)).toBeNull();
110
+ expect(withFlag("1", () => pgWith(false).transactionalFeaturesRefusal)).toContain("not atomic");
111
+ });
112
+ });