@sleep2agi/commhub-server 0.9.0-preview.115 → 0.9.0-preview.116
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agent-teams-http.test.ts +323 -0
- package/src/agent-teams.ts +222 -0
- package/src/auth.ts +8 -5
- package/src/db.ts +3 -0
- package/src/department-heads-http.test.ts +2 -1
- package/src/requirements-comment-http.test.ts +2 -0
- package/src/requirements-stats-http.test.ts +1 -1
- package/src/requirements-status-participants-http.test.ts +2 -0
- package/src/scheduled-doorbell-nodeless-session.test.ts +9 -5
- package/src/scheduled-offline.test.ts +131 -0
- package/src/scheduled-offline.ts +66 -0
- package/src/scheduled-tasks.ts +33 -4
- package/src/server.ts +61 -4
- package/src/task-access-http.test.ts +38 -15
- package/src/task-access.ts +13 -6
- package/src/task-expiry-notice.ts +9 -7
- package/src/task-orphan-http.test.ts +287 -0
- package/src/task-orphan.ts +119 -0
- package/src/tools.ts +2 -0
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.116",
|
|
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,323 @@
|
|
|
1
|
+
// #764:Agent 团队 —— GET/POST/PATCH/DELETE /api/networks/:id/agent-teams[/:team_id] + PUT …/nodes/:node_id/agent-team。
|
|
2
|
+
// HTTP 集成测试(真实 Bun.serve,私有端口,临时库;PG 阶梯里用同一个文件跑真 PostgreSQL)。
|
|
3
|
+
//
|
|
4
|
+
// 夹具(全是占位名):网络 owner;成员 ownerA(管「平台」子树)、ownerB(管「运维」,和平台是兄弟)、member;
|
|
5
|
+
// 节点 n1..n3(owner 建),nodeTok 是 n1 的节点令牌;另一个网络 NET2(owner2)有自己的团队和节点。
|
|
6
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
7
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
8
|
+
import { join } from "path";
|
|
9
|
+
import { tmpdir } from "os";
|
|
10
|
+
import { register, createNetworkTokenForNode } from "./auth.js";
|
|
11
|
+
import { db } from "./db.js";
|
|
12
|
+
|
|
13
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-agent-teams-"));
|
|
14
|
+
let BASE = "";
|
|
15
|
+
let hub: any = null;
|
|
16
|
+
const PW = "AgentTeamsPassw0rd!xyz";
|
|
17
|
+
let NET = "";
|
|
18
|
+
let NET2 = "";
|
|
19
|
+
const U: Record<string, { token: string; id: string }> = {};
|
|
20
|
+
const T: Record<string, string> = {};
|
|
21
|
+
let NODE_TOKEN = "";
|
|
22
|
+
const N = { n1: "node_at_1", n2: "node_at_2", n3: "node_at_3", x1: "node_at_x1" };
|
|
23
|
+
let before: Record<string, string> = {};
|
|
24
|
+
|
|
25
|
+
async function raw(token: string, method: string, path: string, body?: unknown) {
|
|
26
|
+
const res = await fetch(`${BASE}${path}`, { method, headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" }, body: body === undefined ? undefined : JSON.stringify(body) });
|
|
27
|
+
return { status: res.status, text: await res.text() };
|
|
28
|
+
}
|
|
29
|
+
async function send(token: string, method: string, path: string, body?: unknown) {
|
|
30
|
+
const r = await raw(token, method, path, body);
|
|
31
|
+
let json: any = null;
|
|
32
|
+
try { json = JSON.parse(r.text); } catch {}
|
|
33
|
+
return { status: r.status, body: json };
|
|
34
|
+
}
|
|
35
|
+
const tok = (who: string) => (who === "node" ? NODE_TOKEN : U[who].token);
|
|
36
|
+
const create = (who: string, name: string, parent_id?: string | null, net = NET) => send(tok(who), "POST", `/api/networks/${net}/agent-teams`, { name, parent_id });
|
|
37
|
+
const patch = (who: string, id: string, body: unknown, net = NET) => send(tok(who), "PATCH", `/api/networks/${net}/agent-teams/${id}`, body);
|
|
38
|
+
const del = (who: string, id: string) => send(tok(who), "DELETE", `/api/networks/${NET}/agent-teams/${id}`);
|
|
39
|
+
const assign = (who: string, node: string, team_id: string | null, net = NET) => send(tok(who), "PUT", `/api/networks/${net}/nodes/${node}/agent-team`, { team_id });
|
|
40
|
+
const teams = async (who = "owner") => (await send(tok(who), "GET", `/api/networks/${NET}/agent-teams`)).body.teams as any[];
|
|
41
|
+
const team = async (id: string) => (await teams()).find((t) => t.id === id);
|
|
42
|
+
const teamOf = async (node: string) => (await teams()).find((t) => t.members.some((m: any) => m.node_id === node))?.id ?? null;
|
|
43
|
+
const denied = (r: { status: number; body: any }) => { expect(r.status).toBe(403); expect(r.body.error).toBe("agent_team_scope_denied"); };
|
|
44
|
+
const snapshot = async () => ({
|
|
45
|
+
departments: (await raw(U.owner.token, "GET", `/api/networks/${NET}/departments`)).text,
|
|
46
|
+
humans: (await raw(U.owner.token, "GET", `/api/networks/${NET}/humans`)).text,
|
|
47
|
+
node: (await raw(U.owner.token, "GET", `/api/nodes/${N.n1}/config`)).text,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
beforeAll(async () => {
|
|
51
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
52
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
53
|
+
process.env.HOST = "127.0.0.1";
|
|
54
|
+
const stamp = Date.now();
|
|
55
|
+
const a = register(`at_owner_${stamp}`, PW, undefined, "Owner");
|
|
56
|
+
U.owner = { token: a.token!, id: a.user!.user_id };
|
|
57
|
+
NET = a.network_id!;
|
|
58
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [U.owner.id]);
|
|
59
|
+
const b = register(`at_owner2_${stamp}`, PW, undefined, "Owner2");
|
|
60
|
+
U.owner2 = { token: b.token!, id: b.user!.user_id };
|
|
61
|
+
NET2 = b.network_id!;
|
|
62
|
+
const mod: any = await import("./server.js");
|
|
63
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
64
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
65
|
+
for (const k of ["ownerA", "ownerB", "member"]) {
|
|
66
|
+
const username = `at_${k}_${stamp}`;
|
|
67
|
+
expect((await send(U.owner.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role: "member" })).status).toBe(200);
|
|
68
|
+
const login = await send("", "POST", "/api/auth/login", { username, password: PW });
|
|
69
|
+
U[k] = { token: login.body.token, id: login.body.user.user_id };
|
|
70
|
+
}
|
|
71
|
+
db.run("UPDATE users SET display_name = ?1 WHERE user_id = ?2", ["平台负责人", U.ownerA.id]);
|
|
72
|
+
for (const [alias, id] of [["示例节点一", N.n1], ["示例节点二", N.n2], ["示例节点三", N.n3]]) {
|
|
73
|
+
const t = createNetworkTokenForNode(U.owner.id, NET, alias, id);
|
|
74
|
+
expect(t.error ?? null).toBe(null);
|
|
75
|
+
if (id === N.n1) NODE_TOKEN = t.token!;
|
|
76
|
+
}
|
|
77
|
+
expect(createNetworkTokenForNode(U.owner2.id, NET2, "外网节点", N.x1).error ?? null).toBe(null);
|
|
78
|
+
db.run("UPDATE nodes SET display_name = ?1 WHERE node_id = ?2", ["节点一号", N.n1]);
|
|
79
|
+
// 成员默认「只看授权 Agent」:ownerA 看得见 n1、n2(看不见 n3),ownerB 看得见 n2、n3。
|
|
80
|
+
const grant = (k: string, ids: string[]) => send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U[k].id}/agent-grants`, { agent_access: "granted", grants: ids.map((node_id) => ({ node_id })) });
|
|
81
|
+
expect((await grant("ownerA", [N.n1, N.n2])).status).toBe(200);
|
|
82
|
+
expect((await grant("ownerB", [N.n2, N.n3])).status).toBe(200);
|
|
83
|
+
before = await snapshot();
|
|
84
|
+
}, 60_000);
|
|
85
|
+
|
|
86
|
+
afterAll(() => {
|
|
87
|
+
try { hub?.stop?.(true); } catch {}
|
|
88
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
describe("#764 CRUD (network owner)", () => {
|
|
92
|
+
test("empty tree, then create roots and children", async () => {
|
|
93
|
+
expect(await teams()).toEqual([]);
|
|
94
|
+
const p = await create("owner", "平台");
|
|
95
|
+
expect(p.status).toBe(201);
|
|
96
|
+
T.plat = p.body.team.id;
|
|
97
|
+
T.ops = (await create("owner", "运维")).body.team.id;
|
|
98
|
+
T.api = (await create("owner", "接口", T.plat)).body.team.id;
|
|
99
|
+
T.web = (await create("owner", "前端", T.plat)).body.team.id;
|
|
100
|
+
const t = await team(T.api);
|
|
101
|
+
expect(t).toMatchObject({ id: T.api, name: "接口", parent_id: T.plat, lead: null, owner: null, members: [] });
|
|
102
|
+
});
|
|
103
|
+
test("validation: empty name 400, duplicate sibling 409, unknown parent 400, wrong method 405", async () => {
|
|
104
|
+
expect((await create("owner", " ")).body.error).toBe("invalid_team_name");
|
|
105
|
+
const dup = await create("owner", "接口", T.plat);
|
|
106
|
+
expect(dup.status).toBe(409); expect(dup.body.error).toBe("team_name_taken");
|
|
107
|
+
expect((await create("owner", "接口", T.ops)).status).toBe(201); // 不同上级可以同名
|
|
108
|
+
expect((await create("owner", "x", "team_nope")).body.error).toBe("parent_not_found");
|
|
109
|
+
expect((await send(U.owner.token, "PUT", `/api/networks/${NET}/agent-teams`, {})).status).toBe(405);
|
|
110
|
+
});
|
|
111
|
+
test("rename, sort, lead, owner; GET shows display names", async () => {
|
|
112
|
+
expect((await patch("owner", T.plat, { name: "平台组", sort: 5, lead_node_id: N.n1, owner_user_id: U.ownerA.id })).status).toBe(200);
|
|
113
|
+
expect((await patch("owner", T.ops, { owner_user_id: U.ownerB.id })).status).toBe(200);
|
|
114
|
+
const t = await team(T.plat);
|
|
115
|
+
expect(t.name).toBe("平台组"); expect(t.sort).toBe(5);
|
|
116
|
+
expect(t.lead).toEqual({ node_id: N.n1, alias: "示例节点一", display_name: "节点一号" });
|
|
117
|
+
expect(t.owner).toEqual({ user_id: U.ownerA.id, display_name: "平台负责人" });
|
|
118
|
+
expect((await team(T.ops)).owner).toEqual({ user_id: U.ownerB.id, display_name: "" });
|
|
119
|
+
const nope = await patch("owner", T.plat, { lead_node_id: "node_nope" });
|
|
120
|
+
expect(nope.status).toBe(404); expect(nope.body.error).toBe("node_not_found");
|
|
121
|
+
expect((await patch("owner", T.plat, { owner_user_id: U.owner2.id })).body.error).toBe("owner_not_member");
|
|
122
|
+
expect((await patch("owner", T.plat, {})).body.error).toBe("empty_patch");
|
|
123
|
+
});
|
|
124
|
+
test("cycle rejected (self and descendant)", async () => {
|
|
125
|
+
for (const p of [T.plat, T.api]) {
|
|
126
|
+
const r = await patch("owner", T.plat, { parent_id: p });
|
|
127
|
+
expect(r.status).toBe(400); expect(r.body.error).toBe("team_cycle");
|
|
128
|
+
}
|
|
129
|
+
expect((await team(T.plat)).parent_id).toBe(null);
|
|
130
|
+
});
|
|
131
|
+
test("delete with children rejected; leaf delete clears memberships", async () => {
|
|
132
|
+
const r = await del("owner", T.plat);
|
|
133
|
+
expect(r.status).toBe(409); expect(r.body.error).toBe("team_has_children"); expect(r.body.children).toBe(2);
|
|
134
|
+
const tmp = (await create("owner", "临时", T.ops)).body.team.id;
|
|
135
|
+
expect((await assign("owner", N.n3, tmp)).status).toBe(200);
|
|
136
|
+
expect(await teamOf(N.n3)).toBe(tmp);
|
|
137
|
+
expect((await del("owner", tmp)).status).toBe(200);
|
|
138
|
+
expect(await teamOf(N.n3)).toBe(null);
|
|
139
|
+
expect((await del("owner", tmp)).status).toBe(404);
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
describe("#764 membership set / move / clear", () => {
|
|
144
|
+
test("set, move, clear", async () => {
|
|
145
|
+
const r = await assign("owner", N.n2, T.api);
|
|
146
|
+
expect(r.status).toBe(200);
|
|
147
|
+
expect(r.body).toEqual({ ok: true, node_id: N.n2, team_id: T.api });
|
|
148
|
+
expect((await team(T.api)).members).toEqual([{ node_id: N.n2, alias: "示例节点二", display_name: null }]);
|
|
149
|
+
expect((await assign("owner", N.n2, T.ops)).status).toBe(200);
|
|
150
|
+
expect(await teamOf(N.n2)).toBe(T.ops);
|
|
151
|
+
expect((await team(T.api)).members).toEqual([]);
|
|
152
|
+
expect((await assign("owner", N.n2, null)).status).toBe(200);
|
|
153
|
+
expect(await teamOf(N.n2)).toBe(null);
|
|
154
|
+
expect((await assign("owner", N.n2, "team_nope")).body.error).toBe("team_not_found");
|
|
155
|
+
expect((await assign("owner", "node_nope", T.api)).status).toBe(404);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("#764 subtree owner vs sibling owner", () => {
|
|
160
|
+
test("ownerA manages the 平台 subtree", async () => {
|
|
161
|
+
expect((await patch("ownerA", T.plat, { name: "平台", lead_node_id: N.n2 })).status).toBe(200);
|
|
162
|
+
const c = await create("ownerA", "网关", T.api);
|
|
163
|
+
expect(c.status).toBe(201);
|
|
164
|
+
T.gw = c.body.team.id;
|
|
165
|
+
expect((await patch("ownerA", T.gw, { parent_id: T.web })).status).toBe(200); // 子树内挪动
|
|
166
|
+
expect((await patch("ownerA", T.web, { owner_user_id: U.ownerA.id })).status).toBe(200); // 下级的 owner 可以换
|
|
167
|
+
expect((await assign("ownerA", N.n1, T.gw)).status).toBe(200);
|
|
168
|
+
expect((await assign("ownerA", N.n1, T.api)).status).toBe(200);
|
|
169
|
+
expect((await assign("ownerA", N.n1, null)).status).toBe(200);
|
|
170
|
+
expect((await del("ownerA", T.gw)).status).toBe(200);
|
|
171
|
+
});
|
|
172
|
+
test("ownerA cannot change its own team's owner, move it out, delete it, or create a root", async () => {
|
|
173
|
+
denied(await patch("ownerA", T.plat, { owner_user_id: U.ownerA.id }));
|
|
174
|
+
denied(await patch("ownerA", T.plat, { parent_id: T.ops }));
|
|
175
|
+
denied(await patch("ownerA", T.api, { parent_id: T.ops })); // 挪出子树
|
|
176
|
+
denied(await patch("ownerA", T.api, { parent_id: null }));
|
|
177
|
+
denied(await del("ownerA", T.plat));
|
|
178
|
+
denied(await create("ownerA", "根"));
|
|
179
|
+
});
|
|
180
|
+
test("sibling owner (ownerB) denied on the 平台 subtree", async () => {
|
|
181
|
+
denied(await patch("ownerB", T.plat, { name: "抢" }));
|
|
182
|
+
denied(await patch("ownerB", T.api, { lead_node_id: N.n3 }));
|
|
183
|
+
denied(await create("ownerB", "插", T.plat));
|
|
184
|
+
denied(await del("ownerB", T.web));
|
|
185
|
+
denied(await assign("ownerB", N.n3, T.api));
|
|
186
|
+
expect((await assign("owner", N.n3, T.api)).status).toBe(200);
|
|
187
|
+
denied(await assign("ownerB", N.n3, T.ops)); // 从别人的子树拉走
|
|
188
|
+
denied(await assign("ownerB", N.n3, null));
|
|
189
|
+
expect(await teamOf(N.n3)).toBe(T.api);
|
|
190
|
+
expect((await assign("ownerB", N.n2, T.ops)).status).toBe(200); // 自己子树里可以
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("#764 read-only callers", () => {
|
|
195
|
+
test("plain member and node token: GET ok, every write denied", async () => {
|
|
196
|
+
for (const who of ["member", "node"]) {
|
|
197
|
+
expect((await send(tok(who), "GET", `/api/networks/${NET}/agent-teams`)).status).toBe(200);
|
|
198
|
+
denied(await create(who, "x"));
|
|
199
|
+
denied(await create(who, "x", T.plat));
|
|
200
|
+
denied(await patch(who, T.plat, { name: "x" }));
|
|
201
|
+
denied(await del(who, T.web));
|
|
202
|
+
denied(await assign(who, N.n1, T.plat));
|
|
203
|
+
}
|
|
204
|
+
expect((await teams("node")).find((t) => t.id === T.plat).lead.node_id).toBe(N.n2);
|
|
205
|
+
});
|
|
206
|
+
test("agent-restricted member does not see ungranted nodes", async () => {
|
|
207
|
+
const t = (await teams("member")).find((x) => x.id === T.plat);
|
|
208
|
+
expect(t.lead).toBe(null);
|
|
209
|
+
expect((await teams("member")).every((x) => x.members.length === 0)).toBe(true);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("#764 restricted subtree owner: invisible nodes are the same as missing ones", () => {
|
|
214
|
+
test("cannot set an invisible lead; cannot assign an invisible node; 404 identical to a nonexistent node", async () => {
|
|
215
|
+
expect((await assign("owner", N.n3, null)).status).toBe(200);
|
|
216
|
+
const leadHidden = await raw(U.ownerA.token, "PATCH", `/api/networks/${NET}/agent-teams/${T.api}`, { lead_node_id: N.n3 });
|
|
217
|
+
const leadMissing = await raw(U.ownerA.token, "PATCH", `/api/networks/${NET}/agent-teams/${T.api}`, { lead_node_id: "node_nope" });
|
|
218
|
+
expect(leadHidden.status).toBe(404);
|
|
219
|
+
expect(leadHidden.text).toBe(leadMissing.text);
|
|
220
|
+
expect((await team(T.api)).lead).toBe(null);
|
|
221
|
+
const putHidden = await raw(U.ownerA.token, "PUT", `/api/networks/${NET}/nodes/${N.n3}/agent-team`, { team_id: T.api });
|
|
222
|
+
const putMissing = await raw(U.ownerA.token, "PUT", `/api/networks/${NET}/nodes/node_nope/agent-team`, { team_id: T.api });
|
|
223
|
+
expect(putHidden.status).toBe(404);
|
|
224
|
+
expect(putHidden.text).toBe(putMissing.text.replace("node_nope", N.n3));
|
|
225
|
+
expect(await teamOf(N.n3)).toBe(null);
|
|
226
|
+
// 能看见的节点照常可设
|
|
227
|
+
const ok = await patch("ownerA", T.api, { lead_node_id: N.n1 });
|
|
228
|
+
expect(ok.status).toBe(200); expect(ok.body.team.lead.node_id).toBe(N.n1);
|
|
229
|
+
});
|
|
230
|
+
test("PATCH response never echoes an invisible lead", async () => {
|
|
231
|
+
expect((await patch("owner", T.web, { lead_node_id: N.n3 })).status).toBe(200);
|
|
232
|
+
const r = await patch("ownerA", T.web, { name: "前端组" });
|
|
233
|
+
expect(r.status).toBe(200);
|
|
234
|
+
expect(r.body.team.lead).toBe(null);
|
|
235
|
+
expect(JSON.stringify(r.body)).not.toContain(N.n3);
|
|
236
|
+
});
|
|
237
|
+
test("owner_not_member is only reported after the permission check", async () => {
|
|
238
|
+
denied(await patch("ownerA", T.plat, { owner_user_id: "user_nope" }));
|
|
239
|
+
denied(await patch("ownerB", T.plat, { owner_user_id: "user_nope" }));
|
|
240
|
+
denied(await patch("member", T.plat, { owner_user_id: "user_nope" }));
|
|
241
|
+
});
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
describe("#764 network owner (not a Hub admin)", () => {
|
|
245
|
+
test("owner2 manages its own network's teams end to end", async () => {
|
|
246
|
+
expect(db.get<{ role: string }>("SELECT role FROM users WHERE user_id = ?1", U.owner2.id)?.role).not.toBe("admin");
|
|
247
|
+
const c = await create("owner2", "外网根", null, NET2);
|
|
248
|
+
expect(c.status).toBe(201);
|
|
249
|
+
const id = c.body.team.id;
|
|
250
|
+
const child = (await create("owner2", "外网子", id, NET2)).body.team.id;
|
|
251
|
+
expect((await patch("owner2", child, { name: "外网子组", lead_node_id: N.x1, owner_user_id: U.owner2.id }, NET2)).status).toBe(200);
|
|
252
|
+
expect((await assign("owner2", N.x1, child, NET2)).status).toBe(200);
|
|
253
|
+
expect((await assign("owner2", N.x1, null, NET2)).status).toBe(200);
|
|
254
|
+
expect((await send(U.owner2.token, "DELETE", `/api/networks/${NET2}/agent-teams/${child}`)).status).toBe(200);
|
|
255
|
+
expect((await send(U.owner2.token, "DELETE", `/api/networks/${NET2}/agent-teams/${id}`)).status).toBe(200);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
describe("#764 cross-network", () => {
|
|
260
|
+
test("foreign network's ids are not usable here", async () => {
|
|
261
|
+
const foreign = (await create("owner2", "外网团队", null, NET2)).body.team.id;
|
|
262
|
+
expect((await patch("owner", foreign, { name: "x" })).status).toBe(404);
|
|
263
|
+
expect((await del("owner", foreign)).status).toBe(404);
|
|
264
|
+
expect((await create("owner", "x", foreign)).body.error).toBe("parent_not_found");
|
|
265
|
+
expect((await patch("owner", T.api, { parent_id: foreign })).body.error).toBe("parent_not_found");
|
|
266
|
+
expect((await assign("owner", N.n1, foreign)).body.error).toBe("team_not_found");
|
|
267
|
+
expect((await assign("owner", N.x1, T.api)).status).toBe(404);
|
|
268
|
+
expect((await patch("owner", T.api, { lead_node_id: N.x1 })).status).toBe(404);
|
|
269
|
+
expect((await assign("owner2", N.x1, T.api, NET2)).body.error).toBe("team_not_found");
|
|
270
|
+
// 非成员 / 别的网络的节点令牌
|
|
271
|
+
expect((await send(U.owner2.token, "GET", `/api/networks/${NET}/agent-teams`)).status).toBe(403);
|
|
272
|
+
expect((await send(NODE_TOKEN, "GET", `/api/networks/${NET2}/agent-teams`)).status).toBe(403);
|
|
273
|
+
expect((await (await send(U.owner2.token, "GET", `/api/networks/${NET2}/agent-teams`)).body.teams).length).toBe(1);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
describe("#764 node delete cleans up", () => {
|
|
278
|
+
test("deleting a node removes its membership and nulls the lead", async () => {
|
|
279
|
+
expect((await assign("owner", N.n2, T.plat)).status).toBe(200);
|
|
280
|
+
expect((await send(U.owner.token, "DELETE", `/api/nodes/${N.n2}`)).status).toBe(200);
|
|
281
|
+
expect(await teamOf(N.n2)).toBe(null);
|
|
282
|
+
expect((await team(T.plat)).lead).toBe(null);
|
|
283
|
+
expect(db.get("SELECT 1 AS x FROM network_agent_team_members WHERE node_id = ?1", N.n2) ?? null).toBe(null);
|
|
284
|
+
expect(db.get("SELECT 1 AS x FROM network_agent_teams WHERE lead_node_id = ?1", N.n2) ?? null).toBe(null);
|
|
285
|
+
});
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
describe("#764 network deletion removes team rows", () => {
|
|
289
|
+
const rowsIn = (net: string) => ({
|
|
290
|
+
teams: db.get<{ n: number }>("SELECT COUNT(*) AS n FROM network_agent_teams WHERE network_id = ?1", net)!.n,
|
|
291
|
+
members: db.get<{ n: number }>("SELECT COUNT(*) AS n FROM network_agent_team_members WHERE network_id = ?1", net)!.n,
|
|
292
|
+
});
|
|
293
|
+
const seed = async (who: string) => {
|
|
294
|
+
const r = register(`at_${who}_${Date.now()}`, PW, undefined, who);
|
|
295
|
+
U[who] = { token: r.token!, id: r.user!.user_id };
|
|
296
|
+
const team_id = (await create(who, "将删", null, r.network_id!)).body.team.id;
|
|
297
|
+
db.run("INSERT INTO network_agent_team_members (network_id, node_id, team_id) VALUES (?1, ?2, ?3)", [r.network_id!, `node_gone_${who}`, team_id]);
|
|
298
|
+
expect(rowsIn(r.network_id!)).toEqual({ teams: 1, members: 1 });
|
|
299
|
+
return r.network_id!;
|
|
300
|
+
};
|
|
301
|
+
test("Hub admin deleting an empty network (NETWORK_CLEANUP_TABLES)", async () => {
|
|
302
|
+
const net = await seed("owner3");
|
|
303
|
+
const r = await send(U.owner.token, "DELETE", `/api/networks/${net}`);
|
|
304
|
+
expect(r.status).toBe(200);
|
|
305
|
+
expect(r.body.cleaned.network_agent_teams).toBe(1);
|
|
306
|
+
expect(r.body.cleaned.network_agent_team_members).toBe(1);
|
|
307
|
+
expect(rowsIn(net)).toEqual({ teams: 0, members: 0 });
|
|
308
|
+
});
|
|
309
|
+
test("owner deleting its own network", async () => {
|
|
310
|
+
const net = await seed("owner4");
|
|
311
|
+
expect((await send(U.owner4.token, "DELETE", `/api/networks/${net}`)).status).toBe(200);
|
|
312
|
+
expect(rowsIn(net)).toEqual({ teams: 0, members: 0 });
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
describe("#764 existing endpoints unchanged", () => {
|
|
317
|
+
test("departments / humans / node config byte-identical after all team operations", async () => {
|
|
318
|
+
const after = await snapshot();
|
|
319
|
+
expect(after.departments).toBe(before.departments);
|
|
320
|
+
expect(after.humans).toBe(before.humans);
|
|
321
|
+
expect(after.node).toBe(before.node);
|
|
322
|
+
});
|
|
323
|
+
});
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// Agent 团队(看板 #764,父卡 #750):Agent(节点)自己的组织树,和人的部门树(network_departments)完全独立。
|
|
2
|
+
//
|
|
3
|
+
// 只增:两张新表。network_agent_teams = 团队(多层,同一上级下不重名);network_agent_team_members = 节点归哪个团队
|
|
4
|
+
// (一个节点最多一个团队;行存在 = 在这个团队)。nodes / 部门表一概不动,已有接口的响应不变。
|
|
5
|
+
//
|
|
6
|
+
// 权限(每次请求按 owner_user_id 现算,不存展开结果):
|
|
7
|
+
// 网络 owner / admin、Hub 管理员:全部;
|
|
8
|
+
// 团队的 owner_user_id:管这个团队的子树 —— 改名 / 排序 / 设 lead、在子树里建子团队、在子树里挪动、把节点放进 / 移出子树;
|
|
9
|
+
// 但不能改自己那个团队的 owner,也不能把它挪出去(这两样要求「上级也在我的范围里」);
|
|
10
|
+
// 其他成员、节点令牌:只读。viewer 当 owner 也不获得写权限。
|
|
11
|
+
// 读的时候 JOIN nodes:节点删了,它的归属 / lead 自然消失;删节点的两条路径另外调 clearNodeFromAgentTeams 真删行。
|
|
12
|
+
|
|
13
|
+
import { db } from "./db.js";
|
|
14
|
+
|
|
15
|
+
db.exec(`
|
|
16
|
+
CREATE TABLE IF NOT EXISTS network_agent_teams (
|
|
17
|
+
network_id TEXT NOT NULL,
|
|
18
|
+
team_id TEXT NOT NULL,
|
|
19
|
+
name TEXT NOT NULL,
|
|
20
|
+
parent_id TEXT,
|
|
21
|
+
lead_node_id TEXT,
|
|
22
|
+
owner_user_id TEXT,
|
|
23
|
+
sort INTEGER NOT NULL DEFAULT 0,
|
|
24
|
+
created_by TEXT,
|
|
25
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
26
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
27
|
+
PRIMARY KEY (network_id, team_id)
|
|
28
|
+
);
|
|
29
|
+
CREATE INDEX IF NOT EXISTS idx_network_agent_teams_parent ON network_agent_teams(network_id, parent_id);
|
|
30
|
+
CREATE TABLE IF NOT EXISTS network_agent_team_members (
|
|
31
|
+
network_id TEXT NOT NULL,
|
|
32
|
+
node_id TEXT NOT NULL,
|
|
33
|
+
team_id TEXT NOT NULL,
|
|
34
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
35
|
+
PRIMARY KEY (network_id, node_id)
|
|
36
|
+
);
|
|
37
|
+
CREATE INDEX IF NOT EXISTS idx_network_agent_team_members_team ON network_agent_team_members(network_id, team_id);
|
|
38
|
+
`);
|
|
39
|
+
|
|
40
|
+
export const AGENT_TEAM_SCOPE_DENIED = "agent_team_scope_denied";
|
|
41
|
+
const MAX_DEPTH = 10;
|
|
42
|
+
const MAX_TEAMS = 500;
|
|
43
|
+
const NAME_MAX = 40;
|
|
44
|
+
|
|
45
|
+
type Row = { team_id: string; name: string; parent_id: string | null; lead_node_id: string | null; owner_user_id: string | null; sort: number; created_at: string; updated_at: string };
|
|
46
|
+
type Fail = { ok: false; status: number; error: string };
|
|
47
|
+
const fail = (status: number, error: string): Fail => ({ ok: false, status, error });
|
|
48
|
+
const nil = (v: unknown) => v === undefined || v === null || v === "";
|
|
49
|
+
|
|
50
|
+
function rows(networkId: string): Row[] {
|
|
51
|
+
return db.all<Row>(
|
|
52
|
+
"SELECT team_id, name, parent_id, lead_node_id, owner_user_id, sort, created_at, updated_at FROM network_agent_teams WHERE network_id = ?1 ORDER BY sort, created_at, team_id",
|
|
53
|
+
networkId,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
function one(networkId: string, id: unknown): Row | null {
|
|
57
|
+
if (typeof id !== "string") return null;
|
|
58
|
+
return rows(networkId).find((r) => r.team_id === id) ?? null;
|
|
59
|
+
}
|
|
60
|
+
function parentOf(networkId: string): Map<string, string | null> {
|
|
61
|
+
return new Map(rows(networkId).map((r) => [r.team_id, r.parent_id]));
|
|
62
|
+
}
|
|
63
|
+
/** id 和它的全部上级(从自己往上)。 */
|
|
64
|
+
function chain(parent: Map<string, string | null>, id: string | null): string[] {
|
|
65
|
+
const out: string[] = [];
|
|
66
|
+
let cur = id;
|
|
67
|
+
while (cur && parent.has(cur) && !out.includes(cur)) { out.push(cur); cur = parent.get(cur) ?? null; }
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
function height(parent: Map<string, string | null>, id: string): number {
|
|
71
|
+
let h = 1;
|
|
72
|
+
for (const [c, p] of parent) if (p === id && c !== id) h = Math.max(h, 1 + height(parent, c));
|
|
73
|
+
return h;
|
|
74
|
+
}
|
|
75
|
+
const isMember = (networkId: string, userId: string) =>
|
|
76
|
+
!!db.get("SELECT user_id FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId);
|
|
77
|
+
type Hidden = (n: NodeRef) => boolean;
|
|
78
|
+
/** 写路径用同一个可见性判据:不在本网络、或调用者看不见(只看授权 Agent)→ 一律当不存在(同一个 404,不能拿来探测)。 */
|
|
79
|
+
function visibleNode(networkId: string, nodeId: unknown, hidden: Hidden): boolean {
|
|
80
|
+
if (typeof nodeId !== "string") return false;
|
|
81
|
+
const n = db.get<NodeRef>("SELECT node_id, alias, display_name FROM nodes WHERE network_id = ?1 AND node_id = ?2", networkId, nodeId);
|
|
82
|
+
return !!n && !hidden(n);
|
|
83
|
+
}
|
|
84
|
+
const publicTeam = (networkId: string, id: string, hidden: Hidden) => listAgentTeams(networkId, hidden).find((t) => t.id === id)!;
|
|
85
|
+
const cleanName = (raw: unknown): string | null => {
|
|
86
|
+
if (typeof raw !== "string") return null;
|
|
87
|
+
const n = raw.trim();
|
|
88
|
+
return n && [...n].length <= NAME_MAX ? n : null;
|
|
89
|
+
};
|
|
90
|
+
function nameTaken(networkId: string, parentId: string | null, name: string, except?: string): boolean {
|
|
91
|
+
return rows(networkId).some((r) => r.parent_id === parentId && r.name === name && r.team_id !== except);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** userId 能管的团队:他当 owner 的团队及全部下级(owner 不在本网络 / 是 viewer → 空)。 */
|
|
95
|
+
export function ownedScope(networkId: string, userId: string): Set<string> {
|
|
96
|
+
const role = db.get<{ role: string | null }>("SELECT role FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId)?.role;
|
|
97
|
+
const out = new Set<string>();
|
|
98
|
+
if (!role || role === "viewer") return out;
|
|
99
|
+
const all = rows(networkId);
|
|
100
|
+
const parent = new Map(all.map((r) => [r.team_id, r.parent_id]));
|
|
101
|
+
const owned = new Set(all.filter((r) => r.owner_user_id === userId).map((r) => r.team_id));
|
|
102
|
+
for (const r of all) if (chain(parent, r.team_id).some((id) => owned.has(id))) out.add(r.team_id);
|
|
103
|
+
return out;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
type NodeRef = { node_id: string; alias: string | null; display_name: string | null };
|
|
107
|
+
/** 整棵树 + 每个团队的成员 / lead / owner。hidden(node) = true 的节点不出现(只看授权 Agent 的成员)。 */
|
|
108
|
+
export function listAgentTeams(networkId: string, hidden: Hidden) {
|
|
109
|
+
const nodes = new Map(db.all<NodeRef>("SELECT node_id, alias, display_name FROM nodes WHERE network_id = ?1", networkId).map((n) => [n.node_id, n]));
|
|
110
|
+
const people = new Map(db.all<{ user_id: string; username: string; display_name: string | null }>(
|
|
111
|
+
"SELECT u.user_id, u.username, u.display_name FROM network_members m JOIN users u ON u.user_id = m.user_id WHERE m.network_id = ?1", networkId,
|
|
112
|
+
).map((u) => [u.user_id, !u.display_name || u.display_name === u.username ? "" : u.display_name])); // 同 requirements-people 的显示名规则
|
|
113
|
+
const seen = (id: string | null) => (id && nodes.has(id) && !hidden(nodes.get(id)!) ? nodes.get(id)! : null);
|
|
114
|
+
const members = new Map<string, NodeRef[]>();
|
|
115
|
+
for (const m of db.all<{ node_id: string; team_id: string }>("SELECT node_id, team_id FROM network_agent_team_members WHERE network_id = ?1 ORDER BY node_id", networkId)) {
|
|
116
|
+
const n = seen(m.node_id);
|
|
117
|
+
if (n) members.set(m.team_id, [...(members.get(m.team_id) ?? []), n]);
|
|
118
|
+
}
|
|
119
|
+
return rows(networkId).map((r) => ({
|
|
120
|
+
id: r.team_id, name: r.name, parent_id: r.parent_id, sort: r.sort,
|
|
121
|
+
lead: seen(r.lead_node_id),
|
|
122
|
+
owner: r.owner_user_id && people.has(r.owner_user_id) ? { user_id: r.owner_user_id, display_name: people.get(r.owner_user_id)! } : null,
|
|
123
|
+
members: members.get(r.team_id) ?? [],
|
|
124
|
+
created_at: r.created_at, updated_at: r.updated_at,
|
|
125
|
+
}));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** scope = null 表示全管;否则是调用者能管的团队集合。 */
|
|
129
|
+
export function createAgentTeam(networkId: string, actor: string, scope: Set<string> | null, hidden: Hidden, body: Record<string, unknown>) {
|
|
130
|
+
const name = cleanName(body.name);
|
|
131
|
+
if (!name) return fail(400, "invalid_team_name");
|
|
132
|
+
const parentId = nil(body.parent_id) ? null : body.parent_id;
|
|
133
|
+
if (parentId !== null && !one(networkId, parentId)) return fail(400, "parent_not_found");
|
|
134
|
+
if (scope && (parentId === null || !scope.has(parentId as string))) return fail(403, AGENT_TEAM_SCOPE_DENIED);
|
|
135
|
+
if (chain(parentOf(networkId), parentId as string | null).length + 1 > MAX_DEPTH) return fail(400, "team_too_deep");
|
|
136
|
+
if (nameTaken(networkId, parentId as string | null, name)) return fail(409, "team_name_taken");
|
|
137
|
+
if (rows(networkId).length >= MAX_TEAMS) return fail(400, "too_many_teams");
|
|
138
|
+
const id = `team_${crypto.randomUUID().replace(/-/g, "").slice(0, 16)}`;
|
|
139
|
+
const sort = rows(networkId).filter((r) => r.parent_id === parentId).length;
|
|
140
|
+
db.run("INSERT INTO network_agent_teams (network_id, team_id, name, parent_id, sort, created_by) VALUES (?1, ?2, ?3, ?4, ?5, ?6)", [networkId, id, name, parentId, sort, actor]);
|
|
141
|
+
return { ok: true as const, team: publicTeam(networkId, id, hidden) };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export function updateAgentTeam(networkId: string, id: string, scope: Set<string> | null, hidden: Hidden, body: Record<string, unknown>) {
|
|
145
|
+
const cur = one(networkId, id);
|
|
146
|
+
if (!cur) return fail(404, "team_not_found");
|
|
147
|
+
// 子树 owner:团队本身要在范围里;改 owner / 挪动还要求它的上级也在范围里(不能改自己那个团队的 owner、不能挪出去)。
|
|
148
|
+
if (scope && !scope.has(id)) return fail(403, AGENT_TEAM_SCOPE_DENIED);
|
|
149
|
+
const parentInScope = !scope || (cur.parent_id !== null && scope.has(cur.parent_id));
|
|
150
|
+
const next = { ...cur };
|
|
151
|
+
let touched = false;
|
|
152
|
+
if (body.name !== undefined) {
|
|
153
|
+
const n = cleanName(body.name);
|
|
154
|
+
if (!n) return fail(400, "invalid_team_name");
|
|
155
|
+
next.name = n; touched = true;
|
|
156
|
+
}
|
|
157
|
+
if (body.parent_id !== undefined) {
|
|
158
|
+
const p = nil(body.parent_id) ? null : body.parent_id;
|
|
159
|
+
if (p !== null && !one(networkId, p)) return fail(400, "parent_not_found");
|
|
160
|
+
if (scope && (!parentInScope || p === null || !scope.has(p as string))) return fail(403, AGENT_TEAM_SCOPE_DENIED);
|
|
161
|
+
const parent = parentOf(networkId);
|
|
162
|
+
if (p !== null && chain(parent, p as string).includes(id)) return fail(400, "team_cycle");
|
|
163
|
+
if (chain(parent, p as string | null).length + height(parent, id) > MAX_DEPTH) return fail(400, "team_too_deep");
|
|
164
|
+
next.parent_id = p as string | null; touched = true;
|
|
165
|
+
}
|
|
166
|
+
if (body.sort !== undefined) {
|
|
167
|
+
if (!Number.isInteger(body.sort)) return fail(400, "invalid_sort");
|
|
168
|
+
next.sort = body.sort as number; touched = true;
|
|
169
|
+
}
|
|
170
|
+
if (body.lead_node_id !== undefined) {
|
|
171
|
+
const l = nil(body.lead_node_id) ? null : body.lead_node_id;
|
|
172
|
+
if (l !== null && !visibleNode(networkId, l, hidden)) return fail(404, "node_not_found");
|
|
173
|
+
next.lead_node_id = l as string | null; touched = true;
|
|
174
|
+
}
|
|
175
|
+
if (body.owner_user_id !== undefined) {
|
|
176
|
+
const o = nil(body.owner_user_id) ? null : body.owner_user_id;
|
|
177
|
+
if (!parentInScope) return fail(403, AGENT_TEAM_SCOPE_DENIED); // 先判权限,再判成员:没权限的人不能借此探测谁是成员
|
|
178
|
+
if (o !== null && (typeof o !== "string" || !isMember(networkId, o))) return fail(400, "owner_not_member");
|
|
179
|
+
next.owner_user_id = o as string | null; touched = true;
|
|
180
|
+
}
|
|
181
|
+
if (!touched) return fail(400, "empty_patch");
|
|
182
|
+
if ((next.name !== cur.name || next.parent_id !== cur.parent_id) && nameTaken(networkId, next.parent_id, next.name, id)) return fail(409, "team_name_taken");
|
|
183
|
+
db.run(
|
|
184
|
+
"UPDATE network_agent_teams SET name = ?3, parent_id = ?4, sort = ?5, lead_node_id = ?6, owner_user_id = ?7, updated_at = datetime('now') WHERE network_id = ?1 AND team_id = ?2",
|
|
185
|
+
[networkId, id, next.name, next.parent_id, next.sort, next.lead_node_id, next.owner_user_id],
|
|
186
|
+
);
|
|
187
|
+
return { ok: true as const, team: publicTeam(networkId, id, hidden) };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** 只删没有子团队的团队;成员归属同一事务清掉(节点变回未分配)。 */
|
|
191
|
+
export function deleteAgentTeam(networkId: string, id: string, scope: Set<string> | null) {
|
|
192
|
+
const cur = one(networkId, id);
|
|
193
|
+
if (!cur) return fail(404, "team_not_found");
|
|
194
|
+
if (scope && (cur.parent_id === null || !scope.has(cur.parent_id))) return fail(403, AGENT_TEAM_SCOPE_DENIED);
|
|
195
|
+
const children = rows(networkId).filter((r) => r.parent_id === id).length;
|
|
196
|
+
if (children) return { ...fail(409, "team_has_children"), children };
|
|
197
|
+
db.transaction(() => {
|
|
198
|
+
db.run("DELETE FROM network_agent_teams WHERE network_id = ?1 AND team_id = ?2", [networkId, id]);
|
|
199
|
+
db.run("DELETE FROM network_agent_team_members WHERE network_id = ?1 AND team_id = ?2", [networkId, id]);
|
|
200
|
+
});
|
|
201
|
+
return { ok: true as const, deleted: id };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** 节点归一个团队(null = 未分配)。子树 owner:目标团队、节点当前的团队(有的话)都要在范围里。 */
|
|
205
|
+
export function setNodeAgentTeam(networkId: string, nodeId: string, scope: Set<string> | null, hidden: Hidden, teamId: unknown) {
|
|
206
|
+
if (!visibleNode(networkId, nodeId, hidden)) return fail(404, "node_not_found");
|
|
207
|
+
const target = nil(teamId) ? null : teamId;
|
|
208
|
+
if (target !== null && !one(networkId, target)) return fail(400, "team_not_found");
|
|
209
|
+
const current = db.get<{ team_id: string }>("SELECT team_id FROM network_agent_team_members WHERE network_id = ?1 AND node_id = ?2", networkId, nodeId)?.team_id ?? null;
|
|
210
|
+
if (scope && ((target === null && current === null) || (target !== null && !scope.has(target as string)) || (current !== null && !scope.has(current)))) return fail(403, AGENT_TEAM_SCOPE_DENIED);
|
|
211
|
+
db.transaction(() => {
|
|
212
|
+
db.run("DELETE FROM network_agent_team_members WHERE network_id = ?1 AND node_id = ?2", [networkId, nodeId]);
|
|
213
|
+
if (target !== null) db.run("INSERT INTO network_agent_team_members (network_id, node_id, team_id) VALUES (?1, ?2, ?3)", [networkId, nodeId, target]);
|
|
214
|
+
});
|
|
215
|
+
return { ok: true as const, node_id: nodeId, team_id: target as string | null };
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** 节点删除时调用:清掉它的团队归属,指向它的 lead 置空。 */
|
|
219
|
+
export function clearNodeFromAgentTeams(nodeId: string): void {
|
|
220
|
+
db.run("DELETE FROM network_agent_team_members WHERE node_id = ?1", [nodeId]);
|
|
221
|
+
db.run("UPDATE network_agent_teams SET lead_node_id = NULL WHERE lead_node_id = ?1", [nodeId]);
|
|
222
|
+
}
|
package/src/auth.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { db, generateId, hashPassword, verifyPassword, hashToken, generateToken,
|
|
|
5
5
|
import { WEAK_PASSWORDS } from "./password-dict.js";
|
|
6
6
|
import { checkNodeTokenClaim, legacyNodeHolder } from "./node-token-ownership.js";
|
|
7
7
|
import { NETWORK_REST_COLUMNS, NETWORK_REST_SELECT, sqlColumns } from "./rest-projections.js";
|
|
8
|
-
import { deleteAgentGrants, isAgentRestricted } from "./agent-access.js"; import { deleteTaskGrantsForMember,
|
|
8
|
+
import { deleteAgentGrants, isAgentRestricted } from "./agent-access.js"; import { deleteTaskGrantsForMember, newMemberTaskAccess, type TaskAccessMode } from "./task-access.js"; // 一行两个 import:文档钉着 auth.ts 的行号
|
|
9
9
|
|
|
10
10
|
// Round-6 A1 hardening — dummy hash for username-enumeration timing
|
|
11
11
|
// close. We compute ONE scrypt hash of a throwaway password and reuse
|
|
@@ -514,7 +514,7 @@ export function deleteNetwork(userId: string, networkId: string): { ok: boolean;
|
|
|
514
514
|
// single-network user to "ambiguous" or auto-resolve writes INTO the
|
|
515
515
|
// deleted network. Remove memberships with the network.
|
|
516
516
|
db.run("DELETE FROM network_members WHERE network_id = ?1", [networkId]);
|
|
517
|
-
deleteNetworkAgentGroups(networkId); deleteDepartmentsForNetwork(networkId); // #2144, #419 (one line: docs pin auth.ts line numbers)
|
|
517
|
+
deleteNetworkAgentGroups(networkId); deleteDepartmentsForNetwork(networkId); for (const t of AGENT_TEAM_TABLES) if (tableExists(t)) db.run(`DELETE FROM ${t} WHERE network_id = ?1`, [networkId]); // #2144, #419, #764 (one line: docs pin auth.ts line numbers)
|
|
518
518
|
});
|
|
519
519
|
return { ok: true };
|
|
520
520
|
}
|
|
@@ -749,8 +749,8 @@ export function usernameCollidesWithAgent(networkId: string, userId: string): bo
|
|
|
749
749
|
export function addNetworkMember(networkId: string, userId: string, role: string, invitedBy?: string, opts: { agentAccess?: "all" | "granted"; taskAccess?: TaskAccessMode } = {}): { ok: boolean; error?: string } {
|
|
750
750
|
if (!MEMBER_ROLES.has(role)) return { ok: false, error: "invalid role" };
|
|
751
751
|
const agentAccess = opts.agentAccess === "all" ? "all" : "granted";
|
|
752
|
-
//
|
|
753
|
-
const taskAccess: TaskAccessMode = opts.taskAccess === "all" || opts.taskAccess === "scoped" ? opts.taskAccess :
|
|
752
|
+
// 任务范围:显式传值优先;否则 newMemberTaskAccess(role)(#746:member/viewer 'scoped',admin 'all')。
|
|
753
|
+
const taskAccess: TaskAccessMode = opts.taskAccess === "all" || opts.taskAccess === "scoped" ? opts.taskAccess : newMemberTaskAccess(role);
|
|
754
754
|
if (!db.get("SELECT 1 FROM users WHERE user_id = ?1", userId)) return { ok: false, error: "user not found" };
|
|
755
755
|
const existing = db.get<any>("SELECT 1 FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId);
|
|
756
756
|
if (existing) return { ok: false, error: "user already a member" };
|
|
@@ -816,7 +816,7 @@ export function joinByInvite(inviteCode: string, userId: string): { ok: boolean;
|
|
|
816
816
|
if (usernameCollidesWithAgent(invite.network_id, userId)) return { ok: false, error: "username_collides_with_agent_alias" };
|
|
817
817
|
// Add member + increment used count
|
|
818
818
|
db.run("INSERT INTO network_members (network_id, user_id, role, invited_by, agent_access, task_access) VALUES (?1, ?2, ?3, ?4, 'granted', ?5)",
|
|
819
|
-
[invite.network_id, userId, invite.role, invite.created_by,
|
|
819
|
+
[invite.network_id, userId, invite.role, invite.created_by, newMemberTaskAccess(invite.role)]);
|
|
820
820
|
db.run("UPDATE network_invites SET used_count = used_count + 1 WHERE invite_code = ?1", [inviteCode]);
|
|
821
821
|
// 受限成员不能持有网络令牌(resolveToken 会拒),别签一个用不了的。
|
|
822
822
|
if (isAgentRestricted(userId, invite.network_id)) return { ok: true, network_id: invite.network_id, role: invite.role };
|
|
@@ -953,6 +953,7 @@ const NETWORK_CONTENT_CHECKS: Array<[label: string, sql: string]> = [
|
|
|
953
953
|
["pending_node_create_requests", "SELECT COUNT(*) AS cnt FROM node_create_requests WHERE network_id = ?1 AND status IN ('pending', 'delivered')"],
|
|
954
954
|
["pending_node_start_requests", "SELECT COUNT(*) AS cnt FROM node_start_requests WHERE network_id = ?1 AND status IN ('pending', 'delivered')"],
|
|
955
955
|
];
|
|
956
|
+
const AGENT_TEAM_TABLES = ["network_agent_teams", "network_agent_team_members"]; // #764
|
|
956
957
|
const NETWORK_CLEANUP_TABLES = [
|
|
957
958
|
"network_members", "network_member_agent_grants", "network_invites", "api_tokens",
|
|
958
959
|
"node_create_requests", "node_start_requests", "node_stop_requests", "node_rules_requests",
|
|
@@ -962,6 +963,8 @@ const NETWORK_CLEANUP_TABLES = [
|
|
|
962
963
|
"network_tags",
|
|
963
964
|
// 组织架构(board #419):部门是网络本身的元数据。
|
|
964
965
|
"network_departments",
|
|
966
|
+
// Agent 团队(#764):Agent 自己的组织树,同样是网络元数据。
|
|
967
|
+
...AGENT_TEAM_TABLES,
|
|
965
968
|
];
|
|
966
969
|
|
|
967
970
|
function tableExists(name: string): boolean {
|
package/src/db.ts
CHANGED
|
@@ -1648,6 +1648,9 @@ try { db.exec("CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_creator_node ON sc
|
|
|
1648
1648
|
// success in between re-arms the alert.
|
|
1649
1649
|
try { db.exec("ALTER TABLE scheduled_tasks ADD COLUMN failure_alert_at TEXT"); } catch {}
|
|
1650
1650
|
try { db.exec("ALTER TABLE scheduled_tasks ADD COLUMN failure_alert_key TEXT"); } catch {}
|
|
1651
|
+
// Board #757 — one warning per continuous target-offline episode. The scheduler
|
|
1652
|
+
// clears this as soon as the target session is online again; older Hubs ignore it.
|
|
1653
|
+
try { db.exec("ALTER TABLE scheduled_tasks ADD COLUMN offline_alert_at TEXT"); } catch {}
|
|
1651
1654
|
|
|
1652
1655
|
// RFC-036 / B4 — owner-authorized edits of node-host managed schedules.
|
|
1653
1656
|
// This is intentionally separate from Hub scheduled_tasks: these rows are
|
|
@@ -55,7 +55,8 @@ beforeAll(async () => {
|
|
|
55
55
|
expect((await send(U.owner.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role })).status).toBe(200);
|
|
56
56
|
const login = await send("", "POST", "/api/auth/login", { username, password: PW });
|
|
57
57
|
U[k] = { token: login.body.token, id: login.body.user.user_id };
|
|
58
|
-
|
|
58
|
+
// #746 起新成员默认就是 scoped;allMember 显式放宽到 'all'(模拟升级前的老成员)。
|
|
59
|
+
if (role !== "admin") expect((await send(U.owner.token, "PUT", `/api/networks/${NET}/members/${U[k].id}/task-grants`, { task_access: scoped ? "scoped" : "all" })).status).toBe(200);
|
|
59
60
|
};
|
|
60
61
|
await add("admin", "admin");
|
|
61
62
|
for (const k of ["head", "parentHead", "member", "be", "sales"]) await add(k, "member");
|
|
@@ -58,6 +58,8 @@ beforeAll(async () => {
|
|
|
58
58
|
const mk = async (username: string, role: string) => {
|
|
59
59
|
expect((await rest(U.owner.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role })).status).toBe(200);
|
|
60
60
|
const login = await rest("", "POST", "/api/auth/login", { username, password: PW });
|
|
61
|
+
// #746 起新成员默认 scoped;这里的 member / viewer 模拟老成员('all'),scoped 那位下面再收窄。
|
|
62
|
+
expect((await rest(U.owner.token, "PUT", `/api/networks/${NET}/members/${login.body.user.user_id}/task-grants`, { task_access: "all" })).status).toBe(200);
|
|
61
63
|
return { token: login.body.token as string, id: login.body.user.user_id as string };
|
|
62
64
|
};
|
|
63
65
|
const stamp = Date.now();
|