@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.
- package/README.md +5 -3
- package/package.json +1 -1
- package/src/admin-delete-network-http.test.ts +177 -0
- package/src/agent-access.ts +196 -2
- package/src/agent-acl-groups-http.test.ts +247 -0
- package/src/agent-groups-perf.test.ts +167 -0
- package/src/auth.ts +76 -1
- package/src/db-adapter-translate.test.ts +112 -0
- package/src/db-adapter.ts +182 -60
- package/src/db.ts +39 -2
- package/src/pg-worker.ts +87 -0
- package/src/requirements-etag-http.test.ts +91 -0
- package/src/requirements-http.test.ts +1 -1
- package/src/requirements-start-date.test.ts +125 -0
- package/src/requirements.ts +43 -9
- package/src/scheduled-tasks-http.test.ts +71 -20
- package/src/scheduled-tasks.ts +3 -2
- package/src/server.ts +117 -11
- package/src/side-thread-command-transport.test.ts +2 -2
- package/src/side-thread-command-transport.ts +4 -4
- package/src/status-alias-filter-http.test.ts +106 -0
- package/src/tools.ts +6 -5
package/README.md
CHANGED
|
@@ -150,9 +150,11 @@ delivered/acked/running → reassign → delivered (new agent)
|
|
|
150
150
|
|
|
151
151
|
## PostgreSQL (community extension point — not on the maintained roadmap)
|
|
152
152
|
|
|
153
|
+
> **PostgreSQL support is still in development and does not work yet** (PostgreSQL 支持尚在开发中,目前不可用). With `DATABASE_URL=postgres://…` the hub exits while creating its schema. Progress: [RFC-039](https://github.com/sleep2agi/agent-network/blob/main/docs/rfcs/RFC-039-hub-postgresql-backend.md).
|
|
154
|
+
|
|
153
155
|
> v0.8+ product direction is **SQLite only** (see [docs/v3-postgresql-design.md banner](https://github.com/sleep2agi/agent-network/blob/main/docs/v3-postgresql-design.md)). The PostgreSQL adapter interface is preserved as a community extension point — no E2E coverage on the current stable line; **not recommended for mainline production**.
|
|
154
156
|
|
|
155
|
-
|
|
157
|
+
Design intent (not working today): setting `DATABASE_URL` switches to PostgreSQL, and the SQL layer translates SQLite-isms (datetime, parameter placeholders). Requires `bun add pg`.
|
|
156
158
|
|
|
157
159
|
```bash
|
|
158
160
|
DATABASE_URL=postgres://user:pass@host:5432/commhub bunx @sleep2agi/commhub-server
|
|
@@ -166,7 +168,7 @@ DATABASE_URL=postgres://user:pass@host:5432/commhub bunx @sleep2agi/commhub-serv
|
|
|
166
168
|
| `HOST` | `0.0.0.0` in the server package, `127.0.0.1` when launched by `anet hub start` | listen address |
|
|
167
169
|
| `COMMHUB_AUTH_TOKEN` | (none) | Bearer token gate (legacy) |
|
|
168
170
|
| `COMMHUB_DB` | `~/.commhub/commhub.db` | SQLite path |
|
|
169
|
-
| `DATABASE_URL` | (none) | switches to PostgreSQL when set
|
|
171
|
+
| `DATABASE_URL` | (none) | switches to PostgreSQL when set — **does not work yet**, see above |
|
|
170
172
|
|
|
171
173
|
## Running the unit tests
|
|
172
174
|
|
|
@@ -274,7 +276,7 @@ main 上 66 处写的是 `SOURCE_COMMIT`,另有若干写成 `TEST<编号>_SOURCE
|
|
|
274
276
|
## Not verified
|
|
275
277
|
|
|
276
278
|
- `/api/license*` — experimental legacy trial/pro-license endpoints.
|
|
277
|
-
- PostgreSQL backend —
|
|
279
|
+
- PostgreSQL backend — in development, does not work yet (hub exits while creating its schema; RFC-039).
|
|
278
280
|
- Telegram / WeChat / Feishu channel endpoints — channel code exists, but only Telegram-oriented agent-node paths are actively exercised.
|
|
279
281
|
|
|
280
282
|
## License
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sleep2agi/commhub-server",
|
|
3
|
-
"version": "0.9.0-preview.
|
|
3
|
+
"version": "0.9.0-preview.71",
|
|
4
4
|
"description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
// Hub 管理员删别人的**空**网络(清理旧测试账号留下的个人网络)。
|
|
2
|
+
// 真 HTTP:走 DELETE /api/networks/:id,库和上传目录都是本文件私有的临时目录。
|
|
3
|
+
// bun test server/src/admin-delete-network-http.test.ts
|
|
4
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
5
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
|
|
9
|
+
const PRIVATE_DIR = mkdtempSync(join(tmpdir(), "anet-admin-delete-network-"));
|
|
10
|
+
process.env.COMMHUB_DB ||= join(PRIVATE_DIR, "hub.db");
|
|
11
|
+
process.env.COMMHUB_UPLOADS_DIR = join(PRIVATE_DIR, "uploads");
|
|
12
|
+
|
|
13
|
+
let server: ReturnType<typeof Bun.serve>;
|
|
14
|
+
let base = "";
|
|
15
|
+
let db: typeof import("./db.js").db;
|
|
16
|
+
let auth: typeof import("./auth.js");
|
|
17
|
+
let adminToken = "";
|
|
18
|
+
let adminUserId = "";
|
|
19
|
+
let adminNetworkId = "";
|
|
20
|
+
let memberToken = "";
|
|
21
|
+
let memberUserId = "";
|
|
22
|
+
let memberNodeToken = "";
|
|
23
|
+
const stamp = Date.now();
|
|
24
|
+
|
|
25
|
+
function del(networkId: string, token: string) {
|
|
26
|
+
return fetch(`${base}/api/networks/${networkId}`, { method: "DELETE", headers: { Authorization: `Bearer ${token}` } });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function memberNetwork(name: string): string {
|
|
30
|
+
const created = auth.createNetwork(memberUserId, `${name}-${stamp}`);
|
|
31
|
+
expect(created.ok).toBe(true);
|
|
32
|
+
return created.network_id!;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function rowCount(table: string, networkId: string): number {
|
|
36
|
+
return db.get<{ cnt: number }>(`SELECT COUNT(*) AS cnt FROM ${table} WHERE network_id = ?1`, networkId)!.cnt;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// 同 admin-networks-http.test.ts:register() 跑 KDF + bootServer,给 30s 而不是 bun 默认 5s。
|
|
40
|
+
beforeAll(async () => {
|
|
41
|
+
({ db } = await import("./db.js"));
|
|
42
|
+
auth = await import("./auth.js");
|
|
43
|
+
|
|
44
|
+
// 第一个注册的是 Hub 管理员;第二个是普通用户,不在管理员的网络里。
|
|
45
|
+
const admin = auth.register(`admin_delnet_${stamp}`, "AdminDelNet-Strong-1!", undefined, "admin");
|
|
46
|
+
expect(admin.ok).toBe(true);
|
|
47
|
+
adminToken = admin.token!;
|
|
48
|
+
adminNetworkId = admin.network_id!;
|
|
49
|
+
adminUserId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", `admin_delnet_${stamp}`)!.user_id;
|
|
50
|
+
|
|
51
|
+
const member = auth.register(`member_delnet_${stamp}`, "MemberDelNet-Strong-1!", undefined, "member");
|
|
52
|
+
expect(member.ok).toBe(true);
|
|
53
|
+
memberToken = member.token!;
|
|
54
|
+
memberUserId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", `member_delnet_${stamp}`)!.user_id;
|
|
55
|
+
expect(db.get<{ role: string }>("SELECT role FROM users WHERE user_id = ?1", adminUserId)!.role).toBe("admin");
|
|
56
|
+
expect(db.get<{ role: string }>("SELECT role FROM users WHERE user_id = ?1", memberUserId)!.role).not.toBe("admin");
|
|
57
|
+
// free 档只能拥有 2 个网络;本文件要给它建七八个。plan 只影响配额,不影响任何权限判定。
|
|
58
|
+
db.run("UPDATE users SET plan = 'pro' WHERE user_id = ?1", [memberUserId]);
|
|
59
|
+
|
|
60
|
+
const minted = auth.createNetworkTokenForNode(memberUserId, member.network_id!, "delnet-node");
|
|
61
|
+
expect(minted.ok).toBe(true);
|
|
62
|
+
memberNodeToken = minted.token!;
|
|
63
|
+
|
|
64
|
+
const mod = await import("./server.js");
|
|
65
|
+
server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
66
|
+
base = `http://127.0.0.1:${server.port}`;
|
|
67
|
+
}, 30_000);
|
|
68
|
+
|
|
69
|
+
afterAll(() => {
|
|
70
|
+
try { server?.stop(true); } catch {}
|
|
71
|
+
try { rmSync(PRIVATE_DIR, { recursive: true, force: true }); } catch {}
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe("DELETE /api/networks/:id — hub admin override for empty foreign networks", () => {
|
|
75
|
+
test("admin deletes an empty foreign network: 200, network + members + scoped tokens gone, audit has admin_override", async () => {
|
|
76
|
+
const netId = memberNetwork("empty");
|
|
77
|
+
const minted = auth.createNetworkTokenForNode(memberUserId, netId, "leftover-node");
|
|
78
|
+
expect(minted.ok).toBe(true);
|
|
79
|
+
expect(rowCount("network_members", netId)).toBe(1);
|
|
80
|
+
expect(rowCount("api_tokens", netId)).toBe(1);
|
|
81
|
+
|
|
82
|
+
const res = await del(netId, adminToken);
|
|
83
|
+
const body = await res.json() as any;
|
|
84
|
+
expect(res.status).toBe(200);
|
|
85
|
+
expect(body.ok).toBe(true);
|
|
86
|
+
expect(body.admin_override).toBe(true);
|
|
87
|
+
|
|
88
|
+
expect(db.get("SELECT 1 AS x FROM networks WHERE network_id = ?1", netId)).toBeFalsy();
|
|
89
|
+
expect(rowCount("network_members", netId)).toBe(0);
|
|
90
|
+
expect(rowCount("api_tokens", netId)).toBe(0);
|
|
91
|
+
// 被删网络的节点令牌不再能解析。
|
|
92
|
+
expect(auth.resolveToken(minted.token!)).toBeFalsy();
|
|
93
|
+
|
|
94
|
+
const audit = db.get<{ user_id: string; detail: string; network_id: string }>(
|
|
95
|
+
"SELECT user_id, detail, network_id FROM audit_log WHERE action = 'network_deleted' AND target_id = ?1", netId);
|
|
96
|
+
expect(audit).toBeTruthy();
|
|
97
|
+
expect(audit!.user_id).toBe(adminUserId);
|
|
98
|
+
const detail = JSON.parse(audit!.detail);
|
|
99
|
+
expect(detail.admin_override).toBe(true);
|
|
100
|
+
expect(detail.owner_id).toBe(memberUserId);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("admin is refused with 409 and counts when the network has a node and a session", async () => {
|
|
104
|
+
const netId = memberNetwork("busy");
|
|
105
|
+
db.run("INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, ?2, ?2, ?3, datetime('now'), datetime('now'))",
|
|
106
|
+
[`node-${stamp}`, `busy-node-${stamp}`, netId]);
|
|
107
|
+
db.run("INSERT INTO sessions (resume_id, alias, network_id) VALUES (?1, ?2, ?3)", [`resume-${stamp}`, `busy-node-${stamp}`, netId]);
|
|
108
|
+
|
|
109
|
+
const res = await del(netId, adminToken);
|
|
110
|
+
const body = await res.json() as any;
|
|
111
|
+
expect(res.status).toBe(409);
|
|
112
|
+
expect(body.ok).toBe(false);
|
|
113
|
+
expect(body.counts).toEqual({ nodes: 1, sessions: 1 });
|
|
114
|
+
expect(body.error).toContain("nodes=1");
|
|
115
|
+
expect(db.get("SELECT 1 AS x FROM networks WHERE network_id = ?1", netId)).toBeTruthy();
|
|
116
|
+
expect(rowCount("network_members", netId)).toBe(1);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("admin is refused when the only content left is an uploaded file (disk index, not the DB)", async () => {
|
|
120
|
+
const netId = memberNetwork("files");
|
|
121
|
+
const indexDir = join(process.env.COMMHUB_UPLOADS_DIR!, ".index");
|
|
122
|
+
mkdirSync(indexDir, { recursive: true });
|
|
123
|
+
writeFileSync(join(indexDir, `f${stamp}.json`), JSON.stringify({ file_id: `f${stamp}`, network_id: netId }));
|
|
124
|
+
|
|
125
|
+
const res = await del(netId, adminToken);
|
|
126
|
+
const body = await res.json() as any;
|
|
127
|
+
expect(res.status).toBe(409);
|
|
128
|
+
expect(body.counts).toEqual({ files: 1 });
|
|
129
|
+
expect(db.get("SELECT 1 AS x FROM networks WHERE network_id = ?1", netId)).toBeTruthy();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("admin cannot delete the 'default' network even when it is empty", async () => {
|
|
133
|
+
db.run("INSERT INTO networks (network_id, network_name, owner_id) VALUES ('default', ?1, ?2)", [`legacy-default-${stamp}`, memberUserId]);
|
|
134
|
+
const res = await del("default", adminToken);
|
|
135
|
+
expect(res.status).toBe(409);
|
|
136
|
+
expect(((await res.json()) as any).error).toContain("default");
|
|
137
|
+
expect(db.get("SELECT 1 AS x FROM networks WHERE network_id = 'default'")).toBeTruthy();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("a non-admin user cannot delete someone else's network", async () => {
|
|
141
|
+
const res = await del(adminNetworkId, memberToken);
|
|
142
|
+
expect(res.status).toBe(400);
|
|
143
|
+
expect(((await res.json()) as any).error).toBe("not your network");
|
|
144
|
+
expect(db.get("SELECT 1 AS x FROM networks WHERE network_id = ?1", adminNetworkId)).toBeTruthy();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("a node token is refused with user_token_required", async () => {
|
|
148
|
+
const netId = memberNetwork("ntok");
|
|
149
|
+
const res = await del(netId, memberNodeToken);
|
|
150
|
+
expect(res.status).toBe(403);
|
|
151
|
+
expect(((await res.json()) as any).error).toBe("user_token_required");
|
|
152
|
+
expect(db.get("SELECT 1 AS x FROM networks WHERE network_id = ?1", netId)).toBeTruthy();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("owner path is unchanged: owner deletes own network (even with a node row), sessions still block it", async () => {
|
|
156
|
+
const withNode = memberNetwork("owner-node");
|
|
157
|
+
db.run("INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, ?2, ?2, ?3, datetime('now'), datetime('now'))",
|
|
158
|
+
[`owner-node-${stamp}`, `owner-node-${stamp}`, withNode]);
|
|
159
|
+
const ok = await del(withNode, memberToken);
|
|
160
|
+
expect(ok.status).toBe(200);
|
|
161
|
+
expect(await ok.json()).toEqual({ ok: true });
|
|
162
|
+
|
|
163
|
+
const withSession = memberNetwork("owner-session");
|
|
164
|
+
db.run("INSERT INTO sessions (resume_id, alias, network_id) VALUES (?1, ?2, ?3)", [`owner-resume-${stamp}`, `owner-s-${stamp}`, withSession]);
|
|
165
|
+
const blocked = await del(withSession, memberToken);
|
|
166
|
+
expect(blocked.status).toBe(400);
|
|
167
|
+
expect(((await blocked.json()) as any).error).toContain("active session");
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("admin deleting its OWN network goes through the owner path (no admin_override)", async () => {
|
|
171
|
+
const created = auth.createNetwork(adminUserId, `admin-own-${stamp}`);
|
|
172
|
+
expect(created.ok).toBe(true);
|
|
173
|
+
const res = await del(created.network_id!, adminToken);
|
|
174
|
+
expect(res.status).toBe(200);
|
|
175
|
+
expect(await res.json()).toEqual({ ok: true });
|
|
176
|
+
});
|
|
177
|
+
});
|
package/src/agent-access.ts
CHANGED
|
@@ -93,7 +93,10 @@ export function visibleAgents(userId: string, networkId: string): VisibleAgents
|
|
|
93
93
|
const nodeIds = new Set<string>();
|
|
94
94
|
const messageAliases = new Set<string>();
|
|
95
95
|
const messageNodeIds = new Set<string>();
|
|
96
|
-
|
|
96
|
+
// RFC-038 §8:组授权展开成「该组此刻的成员节点」—— 每次现算,组里新加的节点下一次请求就可见。
|
|
97
|
+
// 只并同网络、且 nodes 表里还在的节点;可对话取并集(直接授权或任一组给了就算)。
|
|
98
|
+
const grants: AgentGrant[] = [...listAgentGrants(networkId, userId), ...groupGrantNodes(networkId, userId)];
|
|
99
|
+
for (const grant of grants) {
|
|
97
100
|
const grantAliases: string[] = [];
|
|
98
101
|
if (grant.node_id) {
|
|
99
102
|
nodeIds.add(grant.node_id);
|
|
@@ -121,6 +124,162 @@ export function visibleAgents(userId: string, networkId: string): VisibleAgents
|
|
|
121
124
|
};
|
|
122
125
|
}
|
|
123
126
|
|
|
127
|
+
// ══════════════ Agent 分组(RFC-038 §8) ══════════════
|
|
128
|
+
|
|
129
|
+
export type AgentGroupGrant = { group_id: string; name: string; can_message: boolean };
|
|
130
|
+
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 };
|
|
131
|
+
|
|
132
|
+
/** 该成员被授权的组,展开成节点级授权(只取同网络、仍存在的节点)。 */
|
|
133
|
+
function groupGrantNodes(networkId: string, userId: string): AgentGrant[] {
|
|
134
|
+
return db.all<{ node_id: string; can_message: number }>(
|
|
135
|
+
`SELECT m.node_id, g.can_message
|
|
136
|
+
FROM network_member_group_grants g
|
|
137
|
+
JOIN agent_groups ag ON ag.group_id = g.group_id AND ag.network_id = g.network_id
|
|
138
|
+
JOIN agent_group_members m ON m.group_id = g.group_id
|
|
139
|
+
JOIN nodes n ON n.node_id = m.node_id AND n.network_id = g.network_id
|
|
140
|
+
WHERE g.network_id = ?1 AND g.user_id = ?2`,
|
|
141
|
+
networkId, userId,
|
|
142
|
+
).map((row) => ({ node_id: row.node_id, alias: null, can_message: row.can_message === 1 }));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function listGroupGrants(networkId: string, userId: string): AgentGroupGrant[] {
|
|
146
|
+
return db.all<{ group_id: string; name: string; can_message: number }>(
|
|
147
|
+
`SELECT g.group_id, ag.name, g.can_message
|
|
148
|
+
FROM network_member_group_grants g JOIN agent_groups ag ON ag.group_id = g.group_id
|
|
149
|
+
WHERE g.network_id = ?1 AND g.user_id = ?2 ORDER BY ag.name`,
|
|
150
|
+
networkId, userId,
|
|
151
|
+
).map((row) => ({ group_id: row.group_id, name: row.name, can_message: row.can_message === 1 }));
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function listAgentGroups(networkId: string): AgentGroup[] {
|
|
155
|
+
const groups = db.all<{ group_id: string; network_id: string; name: string; description: string | null; created_at: string; updated_at: string | null }>(
|
|
156
|
+
"SELECT group_id, network_id, name, description, created_at, updated_at FROM agent_groups WHERE network_id = ?1 ORDER BY name",
|
|
157
|
+
networkId,
|
|
158
|
+
);
|
|
159
|
+
return groups.map((g) => {
|
|
160
|
+
const nodeIds = db.all<{ node_id: string }>(
|
|
161
|
+
`SELECT m.node_id FROM agent_group_members m JOIN nodes n ON n.node_id = m.node_id AND n.network_id = ?2
|
|
162
|
+
WHERE m.group_id = ?1 ORDER BY m.node_id`,
|
|
163
|
+
g.group_id, networkId,
|
|
164
|
+
).map((row) => row.node_id);
|
|
165
|
+
const granted = db.get<{ c: number }>("SELECT COUNT(*) AS c FROM network_member_group_grants WHERE group_id = ?1", g.group_id)?.c ?? 0;
|
|
166
|
+
return { ...g, node_ids: nodeIds, member_count: nodeIds.length, granted_user_count: granted };
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function getAgentGroup(networkId: string, groupId: string): AgentGroup | null {
|
|
171
|
+
return listAgentGroups(networkId).find((g) => g.group_id === groupId) ?? null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** 这个组上有授权的成员(组变化时要断开他们的实时流,让其按新权限重连)。 */
|
|
175
|
+
export function usersGrantedGroup(groupId: string): string[] {
|
|
176
|
+
return db.all<{ user_id: string }>("SELECT user_id FROM network_member_group_grants WHERE group_id = ?1", groupId).map((r) => r.user_id);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const MAX_GROUP_NAME = 64;
|
|
180
|
+
const MAX_GROUP_MEMBERS = 1000;
|
|
181
|
+
|
|
182
|
+
export type GroupResult = { ok: true; group: AgentGroup } | { ok: false; error: string; status: number; detail?: unknown };
|
|
183
|
+
|
|
184
|
+
function normalizeName(raw: unknown): string | null {
|
|
185
|
+
if (typeof raw !== "string") return null;
|
|
186
|
+
const name = raw.trim();
|
|
187
|
+
if (!name || name.length > MAX_GROUP_NAME) return null;
|
|
188
|
+
return name;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** node_ids 必须都是本网络里存在的节点;任何一个不合格 ⇒ 整批拒绝。去重。 */
|
|
192
|
+
function validateNodeIds(networkId: string, raw: unknown): { ok: true; ids: string[] } | { ok: false; error: string; status: number; detail?: unknown } {
|
|
193
|
+
if (!Array.isArray(raw)) return { ok: false, error: "node_ids_must_be_array", status: 400 };
|
|
194
|
+
if (raw.length > MAX_GROUP_MEMBERS) return { ok: false, error: "too_many_members", status: 400, detail: { limit: MAX_GROUP_MEMBERS } };
|
|
195
|
+
const ids: string[] = [];
|
|
196
|
+
const seen = new Set<string>();
|
|
197
|
+
for (const [index, item] of raw.entries()) {
|
|
198
|
+
const id = typeof item === "string" ? item.trim() : "";
|
|
199
|
+
if (!id) return { ok: false, error: "invalid_node_id", status: 400, detail: { index } };
|
|
200
|
+
if (!db.get("SELECT 1 FROM nodes WHERE node_id = ?1 AND network_id = ?2", id, networkId)) {
|
|
201
|
+
return { ok: false, error: "agent_not_in_network", status: 400, detail: { index, node_id: id } };
|
|
202
|
+
}
|
|
203
|
+
if (!seen.has(id)) { seen.add(id); ids.push(id); }
|
|
204
|
+
}
|
|
205
|
+
return { ok: true, ids };
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function createAgentGroup(input: { networkId: string; name: unknown; description?: unknown; nodeIds?: unknown; actorUserId: string }): GroupResult {
|
|
209
|
+
const name = normalizeName(input.name);
|
|
210
|
+
if (!name) return { ok: false, error: "invalid_group_name", status: 400, detail: { max_length: MAX_GROUP_NAME } };
|
|
211
|
+
if (db.get("SELECT 1 FROM agent_groups WHERE network_id = ?1 AND name = ?2", input.networkId, name)) {
|
|
212
|
+
return { ok: false, error: "group_name_taken", status: 409 };
|
|
213
|
+
}
|
|
214
|
+
let ids: string[] = [];
|
|
215
|
+
if (input.nodeIds !== undefined) {
|
|
216
|
+
const v = validateNodeIds(input.networkId, input.nodeIds);
|
|
217
|
+
if (!v.ok) return v;
|
|
218
|
+
ids = v.ids;
|
|
219
|
+
}
|
|
220
|
+
const description = typeof input.description === "string" ? input.description.trim().slice(0, 500) || null : null;
|
|
221
|
+
const groupId = `agrp_${crypto.randomUUID().replace(/-/g, "")}`;
|
|
222
|
+
db.transaction(() => {
|
|
223
|
+
db.run("INSERT INTO agent_groups (group_id, network_id, name, description, created_by) VALUES (?1, ?2, ?3, ?4, ?5)",
|
|
224
|
+
[groupId, input.networkId, name, description, input.actorUserId]);
|
|
225
|
+
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]);
|
|
226
|
+
});
|
|
227
|
+
return { ok: true, group: getAgentGroup(input.networkId, groupId)! };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function updateAgentGroup(input: { networkId: string; groupId: string; name?: unknown; description?: unknown }): GroupResult {
|
|
231
|
+
if (!db.get("SELECT 1 FROM agent_groups WHERE group_id = ?1 AND network_id = ?2", input.groupId, input.networkId)) {
|
|
232
|
+
return { ok: false, error: "group_not_found", status: 404 };
|
|
233
|
+
}
|
|
234
|
+
if (input.name !== undefined) {
|
|
235
|
+
const name = normalizeName(input.name);
|
|
236
|
+
if (!name) return { ok: false, error: "invalid_group_name", status: 400, detail: { max_length: MAX_GROUP_NAME } };
|
|
237
|
+
if (db.get("SELECT 1 FROM agent_groups WHERE network_id = ?1 AND name = ?2 AND group_id != ?3", input.networkId, name, input.groupId)) {
|
|
238
|
+
return { ok: false, error: "group_name_taken", status: 409 };
|
|
239
|
+
}
|
|
240
|
+
db.run("UPDATE agent_groups SET name = ?1, updated_at = datetime('now') WHERE group_id = ?2", [name, input.groupId]);
|
|
241
|
+
}
|
|
242
|
+
if (input.description !== undefined) {
|
|
243
|
+
const description = typeof input.description === "string" ? input.description.trim().slice(0, 500) || null : null;
|
|
244
|
+
db.run("UPDATE agent_groups SET description = ?1, updated_at = datetime('now') WHERE group_id = ?2", [description, input.groupId]);
|
|
245
|
+
}
|
|
246
|
+
return { ok: true, group: getAgentGroup(input.networkId, input.groupId)! };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** 整体替换组成员;返回增删 diff(审计只记 diff)。 */
|
|
250
|
+
export function replaceAgentGroupMembers(input: { networkId: string; groupId: string; nodeIds: unknown; actorUserId: string }):
|
|
251
|
+
{ ok: true; group: AgentGroup; added: string[]; removed: string[] } | { ok: false; error: string; status: number; detail?: unknown } {
|
|
252
|
+
if (!db.get("SELECT 1 FROM agent_groups WHERE group_id = ?1 AND network_id = ?2", input.groupId, input.networkId)) {
|
|
253
|
+
return { ok: false, error: "group_not_found", status: 404 };
|
|
254
|
+
}
|
|
255
|
+
const v = validateNodeIds(input.networkId, input.nodeIds);
|
|
256
|
+
if (!v.ok) return v;
|
|
257
|
+
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));
|
|
258
|
+
const after = new Set(v.ids);
|
|
259
|
+
const added = v.ids.filter((id) => !before.has(id));
|
|
260
|
+
const removed = [...before].filter((id) => !after.has(id));
|
|
261
|
+
db.transaction(() => {
|
|
262
|
+
for (const id of removed) db.run("DELETE FROM agent_group_members WHERE group_id = ?1 AND node_id = ?2", [input.groupId, id]);
|
|
263
|
+
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]);
|
|
264
|
+
db.run("UPDATE agent_groups SET updated_at = datetime('now') WHERE group_id = ?1", [input.groupId]);
|
|
265
|
+
});
|
|
266
|
+
return { ok: true, group: getAgentGroup(input.networkId, input.groupId)!, added, removed };
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** 删组:连同组成员与组上的全部授权。返回受影响的成员(调用方断开其实时流、写审计)。 */
|
|
270
|
+
export function deleteAgentGroup(networkId: string, groupId: string): { ok: true; affected_user_ids: string[] } | { ok: false; error: string; status: number } {
|
|
271
|
+
if (!db.get("SELECT 1 FROM agent_groups WHERE group_id = ?1 AND network_id = ?2", groupId, networkId)) {
|
|
272
|
+
return { ok: false, error: "group_not_found", status: 404 };
|
|
273
|
+
}
|
|
274
|
+
const affected = usersGrantedGroup(groupId);
|
|
275
|
+
db.transaction(() => {
|
|
276
|
+
db.run("DELETE FROM network_member_group_grants WHERE group_id = ?1", [groupId]);
|
|
277
|
+
db.run("DELETE FROM agent_group_members WHERE group_id = ?1", [groupId]);
|
|
278
|
+
db.run("DELETE FROM agent_groups WHERE group_id = ?1", [groupId]);
|
|
279
|
+
});
|
|
280
|
+
return { ok: true, affected_user_ids: affected };
|
|
281
|
+
}
|
|
282
|
+
|
|
124
283
|
/** 这个用户名在该网络里是否同时是某个 Agent 的 alias(sessions 或 nodes)。 */
|
|
125
284
|
export function usernameIsAgentAlias(networkId: string, username: string): boolean {
|
|
126
285
|
if (!username) return false;
|
|
@@ -155,7 +314,7 @@ export function canMessageAgent(userId: string, networkId: string | null | undef
|
|
|
155
314
|
export type AgentGrantInput = { node_id?: unknown; alias?: unknown; can_message?: unknown };
|
|
156
315
|
|
|
157
316
|
export type ReplaceGrantsResult =
|
|
158
|
-
| { ok: true; agent_access: AgentAccessMode; grants: AgentGrant[] }
|
|
317
|
+
| { ok: true; agent_access: AgentAccessMode; grants: AgentGrant[]; group_grants: AgentGroupGrant[] }
|
|
159
318
|
| { ok: false; error: string; status: number; detail?: unknown };
|
|
160
319
|
|
|
161
320
|
const MAX_GRANTS = 1000;
|
|
@@ -169,6 +328,8 @@ export function replaceAgentGrants(input: {
|
|
|
169
328
|
userId: string;
|
|
170
329
|
grants: unknown;
|
|
171
330
|
agentAccess?: unknown;
|
|
331
|
+
/** RFC-038 §8:组授权。undefined = 保持原样(旧 app 的 PUT 不带它,不能把组授权清掉)。 */
|
|
332
|
+
groupGrants?: unknown;
|
|
172
333
|
actorUserId: string;
|
|
173
334
|
}): ReplaceGrantsResult {
|
|
174
335
|
const member = db.get<{ role: string }>(
|
|
@@ -215,7 +376,38 @@ export function replaceAgentGrants(input: {
|
|
|
215
376
|
}
|
|
216
377
|
}
|
|
217
378
|
|
|
379
|
+
let groupRows: Array<{ group_id: string; can_message: number }> | undefined;
|
|
380
|
+
if (input.groupGrants !== undefined) {
|
|
381
|
+
if (!Array.isArray(input.groupGrants)) return { ok: false, error: "group_grants_must_be_array", status: 400 };
|
|
382
|
+
if (input.groupGrants.length > MAX_GRANTS) return { ok: false, error: "too_many_grants", status: 400, detail: { limit: MAX_GRANTS } };
|
|
383
|
+
const seenGroups = new Set<string>();
|
|
384
|
+
groupRows = [];
|
|
385
|
+
for (const [index, raw] of input.groupGrants.entries()) {
|
|
386
|
+
const item = typeof raw === "string" ? { group_id: raw } : (raw && typeof raw === "object" && !Array.isArray(raw) ? raw as { group_id?: unknown; can_message?: unknown } : {});
|
|
387
|
+
const groupId = typeof item.group_id === "string" ? item.group_id.trim() : "";
|
|
388
|
+
if (!groupId) return { ok: false, error: "group_grant_needs_group_id", status: 400, detail: { index } };
|
|
389
|
+
if (item.can_message !== undefined && typeof item.can_message !== "boolean") {
|
|
390
|
+
return { ok: false, error: "can_message_must_be_boolean", status: 400, detail: { index } };
|
|
391
|
+
}
|
|
392
|
+
if (!db.get("SELECT 1 FROM agent_groups WHERE group_id = ?1 AND network_id = ?2", groupId, input.networkId)) {
|
|
393
|
+
return { ok: false, error: "group_not_in_network", status: 400, detail: { index, group_id: groupId } };
|
|
394
|
+
}
|
|
395
|
+
if (seenGroups.has(groupId)) continue;
|
|
396
|
+
seenGroups.add(groupId);
|
|
397
|
+
groupRows.push({ group_id: groupId, can_message: item.can_message === false ? 0 : 1 });
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
218
401
|
db.transaction(() => {
|
|
402
|
+
if (groupRows) {
|
|
403
|
+
db.run("DELETE FROM network_member_group_grants WHERE network_id = ?1 AND user_id = ?2", [input.networkId, input.userId]);
|
|
404
|
+
for (const row of groupRows) {
|
|
405
|
+
db.run(
|
|
406
|
+
`INSERT INTO network_member_group_grants (network_id, user_id, group_id, can_message, created_by) VALUES (?1, ?2, ?3, ?4, ?5)`,
|
|
407
|
+
[input.networkId, input.userId, row.group_id, row.can_message, input.actorUserId],
|
|
408
|
+
);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
219
411
|
if (mode) {
|
|
220
412
|
db.run("UPDATE network_members SET agent_access = ?1 WHERE network_id = ?2 AND user_id = ?3", [mode, input.networkId, input.userId]);
|
|
221
413
|
}
|
|
@@ -235,12 +427,14 @@ export function replaceAgentGrants(input: {
|
|
|
235
427
|
ok: true,
|
|
236
428
|
agent_access: getAgentAccessMode(input.networkId, input.userId) ?? "granted",
|
|
237
429
|
grants: listAgentGrants(input.networkId, input.userId),
|
|
430
|
+
group_grants: listGroupGrants(input.networkId, input.userId),
|
|
238
431
|
};
|
|
239
432
|
}
|
|
240
433
|
|
|
241
434
|
/** 成员被移出网络时一并清掉授权,重新加入时从零开始。 */
|
|
242
435
|
export function deleteAgentGrants(networkId: string, userId: string): void {
|
|
243
436
|
db.run("DELETE FROM network_member_agent_grants WHERE network_id = ?1 AND user_id = ?2", [networkId, userId]);
|
|
437
|
+
db.run("DELETE FROM network_member_group_grants WHERE network_id = ?1 AND user_id = ?2", [networkId, userId]);
|
|
244
438
|
}
|
|
245
439
|
|
|
246
440
|
// 多用户 Agent 权限 —— 受限成员(只看授权 Agent 的成员)能调的 MCP 工具。每一个都逐条审过:
|