@sleep2agi/commhub-server 0.9.0-preview.146 → 0.9.0-preview.148

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.146",
3
+ "version": "0.9.0-preview.148",
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",
@@ -640,3 +640,46 @@ describe("#506 cross-network isolation of /health sse_sessions (same alias, two
640
640
  expect(keys.some((k) => k.startsWith(`${aNet}:`))).toBe(false);
641
641
  });
642
642
  });
643
+
644
+ // #2149 —— 错误路径**不能回显凭据**。
645
+ //
646
+ // 背景:2026-10-23 TMA云鲸 对 CommHub MCP 做只读扫描时收到 502,代理生成的
647
+ // 非 JSON 错误页把本次请求的鉴权相关头回显在正文里。那个 502 页很可能来自
648
+ // 调用链上的代理(本仓没有 502 生成逻辑),但**这条防线必须在本仓也有**:
649
+ // 任何 4xx/5xx 的响应体都不该把调用方递上来的 `Authorization` / `Cookie` /
650
+ // query 里的 token 原样吐回去 —— 一旦有人写出这种错误文案,它就会进日志、
651
+ // 进回执、进诊断归档。
652
+ //
653
+ // 做法是**注入一个形状像真凭据的 canary**,然后打一圈错误路径,断言响应正文
654
+ // 里一次都不出现它。canary 用 `ntok_` 前缀,正是本仓 `redactTokens` 认得的形状。
655
+ describe("#2149 error paths never echo credentials", () => {
656
+ const CANARY = "ntok_LEAKCANARY_0123456789abcdef";
657
+ const COOKIE = "session=COOKIELEAKCANARY0123456789";
658
+
659
+ const paths: Array<{ method: string; path: string; body?: string }> = [
660
+ { method: "GET", path: "/api/nope-not-a-route" },
661
+ { method: "GET", path: "/api/requirements" },
662
+ { method: "GET", path: "/api/nodes" },
663
+ { method: "POST", path: "/mcp", body: "{not json" },
664
+ { method: "POST", path: "/api/auth/login", body: JSON.stringify({ username: "nobody", password: "wrong-password-Aa1!" }) },
665
+ { method: "POST", path: "/api/auth/me/avatar/upload" },
666
+ ];
667
+
668
+ test("no 4xx/5xx body contains the presented token, cookie or query secret", async () => {
669
+ for (const c of paths) {
670
+ const res = await fetch(`${BASE}${c.path}${c.path.includes("?") ? "&" : "?"}access_token=${CANARY}`, {
671
+ method: c.method,
672
+ headers: {
673
+ authorization: `Bearer ${CANARY}`,
674
+ cookie: COOKIE,
675
+ "content-type": "application/json",
676
+ },
677
+ body: c.body,
678
+ });
679
+ const text = await res.text();
680
+ expect(text.includes(CANARY)).toBe(false);
681
+ expect(text.includes("LEAKCANARY")).toBe(false);
682
+ expect(text.includes("COOKIELEAKCANARY")).toBe(false);
683
+ }
684
+ });
685
+ });
@@ -208,7 +208,7 @@ describe("改名 / 合并 / 删除 / 颜色", () => {
208
208
  const t0 = new Date().toISOString();
209
209
  const r = await op(admin.token, { op: "rename", from: "UI", to: "界面" });
210
210
  expect(r.status).toBe(200);
211
- expect(r.body).toEqual({ ok: true, op: "rename", affected: 4 });
211
+ expect(r.body).toEqual({ ok: true, op: "rename", affected: 4, reparented: 0 });
212
212
  expect(cardTags("visible")).toEqual(["界面", "交互", "bug"]);
213
213
  expect(cardTags("hidden")).toEqual(["界面", "secret"]);
214
214
  expect(cardTags("archived")).toEqual(["界面", "旧"]);
@@ -237,7 +237,7 @@ describe("改名 / 合并 / 删除 / 颜色", () => {
237
237
  test("合并:多个来源进一个新标签;目标没颜色就继承来源的;来源颜色行删掉", async () => {
238
238
  expect((await op(admin.token, { op: "color", tag: "旧", color: "#16a34a" })).status).toBe(200);
239
239
  const r = await op(admin.token, { op: "merge", from: ["bug", "旧", "secret", "不存在"], to: "杂项" });
240
- expect(r.body).toEqual({ ok: true, op: "merge", affected: 3 });
240
+ expect(r.body).toEqual({ ok: true, op: "merge", affected: 3, reparented: 0 });
241
241
  expect(cardTags("visible")).toEqual(["界面", "杂项"]);
242
242
  expect(cardTags("hidden")).toEqual(["界面", "杂项"]);
243
243
  expect(cardTags("archived")).toEqual(["界面", "杂项"]);
@@ -253,7 +253,7 @@ describe("改名 / 合并 / 删除 / 颜色", () => {
253
253
 
254
254
  test("删除:从每张卡上拿掉(卡保留),颜色行删掉,审计带 affected", async () => {
255
255
  const r = await op(admin.token, { op: "delete", tag: "界面" });
256
- expect(r.body).toEqual({ ok: true, op: "delete", affected: 4 });
256
+ expect(r.body).toEqual({ ok: true, op: "delete", affected: 4, reparented: 0 });
257
257
  expect(cardTags("visible")).toEqual([]);
258
258
  expect(cardTags("both")).toEqual(["TMWork"]);
259
259
  expect(db.get("SELECT COUNT(*) AS n FROM requirements WHERE network_id = ?1", NET).n).toBe(5);
@@ -0,0 +1,275 @@
1
+ // 标签树(POST /api/requirements/tags/create + GET /api/requirements/tags 的 nodes + ops 维护层级)
2
+ // —— HTTP 集成测试(真实 Bun.serve,私有端口,临时库)。
3
+ //
4
+ // 场景:Hub 管理员 admin 的网络 NET,另一个不相干的网络 NET2。成员:
5
+ // carol —— task_access='all' 的老成员(能管标签);
6
+ // alice —— scoped 成员(不能管);
7
+ // vic —— viewer;
8
+ // nodeToken —— 节点令牌。
9
+ //
10
+ // 正向:建根标签、建子标签并回读 parent_id/depth;0 任务的子标签能在 nodes 里读到;父只在卡片上出现过时
11
+ // 补登记成顶层(parent_registered=true)且子标签仍挂在它下面;改名 / 合并 / 删除让层级跟着动。
12
+ // 反向:重名 409、父不存在 404(不 root fallback)、超过 4 层 400、scoped / viewer / 节点令牌 403 且一行都不写、
13
+ // 同名标签在别的网络互不相干、坏参数 400、原子性(重名失败后不留半条节点)。
14
+
15
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
16
+ import { mkdtempSync, rmSync } from "node:fs";
17
+ import { tmpdir } from "node:os";
18
+ import { join } from "node:path";
19
+
20
+ const DIR = mkdtempSync(join(tmpdir(), "anet-tag-tree-"));
21
+ let BASE = "";
22
+ let hub: any = null;
23
+ let db: any = null;
24
+ const PW = "TagTreePassw0rd!x";
25
+ let NET = "";
26
+ let NET2 = "";
27
+ let admin = { token: "", id: "" };
28
+ let other = { token: "", id: "" };
29
+ let alice = { token: "", id: "" };
30
+ let carol = { token: "", id: "" };
31
+ let vic = { token: "", id: "" };
32
+ let nodeToken = "";
33
+
34
+ type R = { status: number; body: any };
35
+ async function send(token: string, method: string, path: string, payload?: unknown): Promise<R> {
36
+ const res = await fetch(`${BASE}${path}`, {
37
+ method,
38
+ headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), "Content-Type": "application/json" },
39
+ body: payload === undefined ? undefined : JSON.stringify(payload),
40
+ });
41
+ const text = await res.text();
42
+ let body: any = null;
43
+ try { body = JSON.parse(text); } catch {}
44
+ return { status: res.status, body };
45
+ }
46
+
47
+ const create = (token: string, payload: unknown, net = NET) => send(token, "POST", `/api/requirements/tags/create?network_id=${net}`, payload);
48
+ const tagsOf = (token: string, net = NET) => send(token, "GET", `/api/requirements/tags?network_id=${net}`);
49
+ const op = (token: string, payload: unknown, net = NET) => send(token, "POST", `/api/requirements/tags/ops?network_id=${net}`, payload);
50
+ const nodesOf = async (token = admin.token, net = NET): Promise<Record<string, { parent: string | null; depth: number; count: number; registered: boolean }>> => {
51
+ const r = await tagsOf(token, net);
52
+ expect(r.status).toBe(200);
53
+ return Object.fromEntries((r.body.nodes ?? []).map((n: any) => [n.name, { parent: n.parent_id, depth: n.depth, count: n.count, registered: n.registered }]));
54
+ };
55
+ const nodeRows = (net = NET) => db.all("SELECT name, parent_name FROM network_tag_nodes WHERE network_id = ?1 ORDER BY name", [net]);
56
+
57
+ beforeAll(async () => {
58
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
59
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
60
+ process.env.HOST = "127.0.0.1";
61
+ ({ db } = await import("./db.js"));
62
+ const { createNetworkTokenForNode, register } = await import("./auth.js");
63
+ const stamp = Date.now();
64
+ const a = register(`tt_admin_${stamp}`, PW, undefined, "Admin");
65
+ expect(a.ok).toBe(true);
66
+ admin = { token: a.token!, id: a.user!.user_id };
67
+ NET = a.network_id!;
68
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [admin.id]);
69
+ const b = register(`tt_other_${stamp}`, PW, undefined, "Other");
70
+ expect(b.ok).toBe(true);
71
+ other = { token: b.token!, id: b.user!.user_id };
72
+ NET2 = b.network_id!;
73
+ const ntok = createNetworkTokenForNode(admin.id, NET, "tag-node");
74
+ expect(ntok.ok).toBe(true);
75
+ nodeToken = ntok.token!;
76
+
77
+ const mod: any = await import("./server.js");
78
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
79
+ BASE = `http://127.0.0.1:${hub.port}`;
80
+
81
+ const mk = async (username: string, role: string) => {
82
+ const r = await send(admin.token, "POST", "/api/admin/users", { username, password: PW, network_id: NET, role });
83
+ expect(r.status).toBe(200);
84
+ const login = await send("", "POST", "/api/auth/login", { username, password: PW });
85
+ expect(login.status).toBe(200);
86
+ return { token: login.body.token as string, id: login.body.user.user_id as string };
87
+ };
88
+ alice = await mk(`tt_alice_${stamp}`, "member");
89
+ carol = await mk(`tt_carol_${stamp}`, "member");
90
+ vic = await mk(`tt_vic_${stamp}`, "viewer");
91
+ db.run("UPDATE network_members SET task_access = 'scoped' WHERE network_id = ?1 AND user_id = ?2", [NET, alice.id]);
92
+ db.run("UPDATE network_members SET task_access = 'all' WHERE network_id = ?1 AND user_id = ?2", [NET, carol.id]);
93
+
94
+ // 一张只有 legacy 标签的卡:它的标签没有节点行 —— 用来验证「拿在用标签当父」时补登记。
95
+ const card = await send(admin.token, "POST", "/api/requirements", { network_id: NET, name: "legacy", tags: ["legacy"] });
96
+ expect(card.status).toBe(201);
97
+ }, 30_000);
98
+
99
+ afterAll(() => {
100
+ try { hub?.stop?.(true); } catch {}
101
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
102
+ });
103
+
104
+ describe("capability 与权限", () => {
105
+ test("列表 capabilities 带 tag_tree(旧客户端忽略)", async () => {
106
+ const r = await send(admin.token, "GET", `/api/requirements?network_id=${NET}`);
107
+ expect(r.body.capabilities).toContain("tag_tree");
108
+ });
109
+
110
+ test("scoped / viewer / 节点令牌 403,且一行节点都不写", async () => {
111
+ for (const token of [alice.token, vic.token, nodeToken]) {
112
+ const r = await create(token, { name: "被拒" });
113
+ expect(r.status).toBe(403);
114
+ }
115
+ expect(nodeRows()).toEqual([]);
116
+ });
117
+
118
+ test("网络管理员与 task_access='all' 的老成员能建", async () => {
119
+ const r = await create(carol.token, { name: "carol建的" });
120
+ expect(r.status).toBe(201);
121
+ expect(r.body.tag).toEqual({ name: "carol建的", parent_id: null, depth: 1 });
122
+ const del = await op(admin.token, { op: "delete", tag: "carol建的" });
123
+ expect(del.status).toBe(200);
124
+ });
125
+ });
126
+
127
+ describe("建根标签与子标签", () => {
128
+ test("建根标签:201,parent_id=null,depth=1", async () => {
129
+ const r = await create(admin.token, { name: "需求" });
130
+ expect(r.status).toBe(201);
131
+ expect(r.body).toMatchObject({ ok: true, tag: { name: "需求", parent_id: null, depth: 1 }, parent_registered: false });
132
+ });
133
+
134
+ test("0 任务的标签也能在 nodes 里读到(count=0,registered=true)", async () => {
135
+ const nodes = await nodesOf();
136
+ expect(nodes["需求"]).toEqual({ parent: null, depth: 1, count: 0, registered: true });
137
+ // tags(卡片上出现过的)保持旧语义:0 任务的标签不在里面
138
+ const t = await tagsOf(admin.token);
139
+ expect(t.body.tags).not.toContain("需求");
140
+ });
141
+
142
+ test("建子标签:回读 parent_id / depth / count=0", async () => {
143
+ const r = await create(admin.token, { name: "前端", parent_id: "需求" });
144
+ expect(r.status).toBe(201);
145
+ expect(r.body.tag).toEqual({ name: "前端", parent_id: "需求", depth: 2 });
146
+ const nodes = await nodesOf();
147
+ expect(nodes["前端"]).toEqual({ parent: "需求", depth: 2, count: 0, registered: true });
148
+ });
149
+
150
+ test("三代:第 3 层 depth=3;超过 MAX_TAG_DEPTH 的第 4 层子标签 400", async () => {
151
+ const l3 = await create(admin.token, { name: "按钮", parent_id: "前端" });
152
+ expect(l3.status).toBe(201);
153
+ expect(l3.body.tag.depth).toBe(3);
154
+ const l4 = await create(admin.token, { name: "主按钮", parent_id: "按钮" });
155
+ expect(l4.status).toBe(201);
156
+ expect(l4.body.tag.depth).toBe(4);
157
+ const l5 = await create(admin.token, { name: "太深了", parent_id: "主按钮" });
158
+ expect(l5.status).toBe(400);
159
+ expect(l5.body.error).toBe("tag_depth_exceeded");
160
+ expect(nodeRows().some((r: any) => r.name === "太深了")).toBe(false);
161
+ });
162
+
163
+ test("重名 409:已登记的、卡片上在用的、有颜色行的都算重名;失败不留半条", async () => {
164
+ expect((await create(admin.token, { name: "需求" })).body.error).toBe("tag_exists");
165
+ expect((await create(admin.token, { name: "legacy" })).body.error).toBe("tag_exists");
166
+ // 只有颜色行、没有卡片也没有节点的名字也算存在(直接写库构造这种历史状态)
167
+ db.run("INSERT INTO network_tags (network_id, name, color, updated_at) VALUES (?1, ?2, ?3, ?4)", [NET, "有颜色的", "#112233", new Date().toISOString()]);
168
+ const colored = await create(admin.token, { name: "有颜色的" });
169
+ expect(colored.body.error).toBe("tag_exists");
170
+ const before = nodeRows();
171
+ expect(before.map((r: any) => r.name)).not.toContain("有颜色的");
172
+ });
173
+
174
+ test("父不存在 404 tag_parent_not_found —— 不会静默建到根上", async () => {
175
+ const r = await create(admin.token, { name: "孤儿", parent_id: "不存在的父" });
176
+ expect(r.status).toBe(404);
177
+ expect(r.body.error).toBe("tag_parent_not_found");
178
+ expect(nodeRows().some((row: any) => row.name === "孤儿")).toBe(false);
179
+ });
180
+
181
+ test("父只在卡片上出现过:补登记成顶层节点(parent_registered=true),子标签仍挂在它下面", async () => {
182
+ const r = await create(admin.token, { name: "legacy子", parent_id: "legacy" });
183
+ expect(r.status).toBe(201);
184
+ expect(r.body.parent_registered).toBe(true);
185
+ expect(r.body.tag).toEqual({ name: "legacy子", parent_id: "legacy", depth: 2 });
186
+ const nodes = await nodesOf();
187
+ expect(nodes["legacy"]).toMatchObject({ parent: null, depth: 1 });
188
+ expect(nodes["legacy子"]).toMatchObject({ parent: "legacy", depth: 2 });
189
+ });
190
+
191
+ test("坏参数 400:空名 / 太长 / 控制字符 / parent_id 不是字符串", async () => {
192
+ expect((await create(admin.token, { name: "" })).body.error).toBe("invalid_tag");
193
+ expect((await create(admin.token, { name: "一二三四五六七八九十一二三四五六七八九十多" })).body.error).toBe("invalid_tag");
194
+ expect((await create(admin.token, { name: "换\n行" })).body.error).toBe("invalid_tag");
195
+ const bad = await create(admin.token, { name: "正常", parent_id: 42 });
196
+ expect(bad.status).toBe(400);
197
+ expect(bad.body.error).toBe("invalid_tag_parent_id");
198
+ expect((await create(admin.token, {})).body.error).toBe("invalid_tag");
199
+ });
200
+
201
+ test("别的网络同名互不相干:NET2 里能再建「需求」,NET 的树不受影响", async () => {
202
+ const r = await create(other.token, { name: "需求" }, NET2);
203
+ expect(r.status).toBe(201);
204
+ expect(r.body.tag.depth).toBe(1);
205
+ expect((await nodesOf(admin.token, NET2))["需求"]).toMatchObject({ parent: null, depth: 1 });
206
+ expect((await nodesOf(admin.token, NET))["前端"]).toMatchObject({ parent: "需求", depth: 2 });
207
+ });
208
+
209
+ test("受限成员的 nodes 只含他看得见的标签(登记过但没有可见卡的名字不外泄)", async () => {
210
+ const r = await tagsOf(alice.token);
211
+ expect(r.status).toBe(200);
212
+ expect(r.body.nodes).toEqual([]);
213
+ expect(r.body.can_manage).toBe(false);
214
+ });
215
+ });
216
+
217
+ describe("改名 / 合并 / 删除让层级跟着动", () => {
218
+ test("rename:节点与子节点的 parent 一起改,深度不变", async () => {
219
+ const r = await op(admin.token, { op: "rename", from: "需求", to: "需求池" });
220
+ expect(r.status).toBe(200);
221
+ const nodes = await nodesOf();
222
+ expect(nodes["需求池"]).toMatchObject({ parent: null, depth: 1 });
223
+ expect(nodes["前端"]).toMatchObject({ parent: "需求池", depth: 2 });
224
+ expect(nodes["按钮"]).toMatchObject({ parent: "前端", depth: 3 });
225
+ expect(nodes["需求"]).toBeUndefined();
226
+ });
227
+
228
+ test("rename 到已存在的节点 = 合并:来源的子节点挂到目标,且不会自环", async () => {
229
+ await create(admin.token, { name: "临时" });
230
+ await create(admin.token, { name: "临时子", parent_id: "临时" });
231
+ const r = await op(admin.token, { op: "rename", from: "临时", to: "需求池" });
232
+ expect(r.status).toBe(200);
233
+ const nodes = await nodesOf();
234
+ expect(nodes["临时子"]).toMatchObject({ parent: "需求池", depth: 2 });
235
+ // 目标是来源的子孙时不能自环:把「前端」并到它的子节点「按钮」上
236
+ await op(admin.token, { op: "rename", from: "前端", to: "按钮" });
237
+ const after = await nodesOf();
238
+ expect(after["按钮"].parent).not.toBe("按钮");
239
+ expect(nodeRows().every((row: any) => row.name !== row.parent_name)).toBe(true);
240
+ });
241
+
242
+ test("merge:来源节点的子节点挂到目标,来源节点删掉", async () => {
243
+ await create(admin.token, { name: "源A" });
244
+ await create(admin.token, { name: "源A子", parent_id: "源A" });
245
+ await create(admin.token, { name: "目标B" });
246
+ const r = await op(admin.token, { op: "merge", from: ["源A"], to: "目标B" });
247
+ expect(r.status).toBe(200);
248
+ expect(r.body.reparented).toBe(1);
249
+ const nodes = await nodesOf();
250
+ expect(nodes["源A"]).toBeUndefined();
251
+ expect(nodes["源A子"]).toMatchObject({ parent: "目标B", depth: 2 });
252
+ });
253
+
254
+ test("delete:子节点挂到被删节点的父(没有父就成根),不丢层级", async () => {
255
+ const before = await nodesOf();
256
+ const r = await op(admin.token, { op: "delete", tag: "按钮" });
257
+ expect(r.status).toBe(200);
258
+ const nodes = await nodesOf();
259
+ // 「按钮」的父是「需求池」(rename 之后),所以「主按钮」挂到「需求池」
260
+ expect(nodes["主按钮"].parent).toBe(before["按钮"].parent);
261
+ expect(nodes["按钮"]).toBeUndefined();
262
+ });
263
+
264
+ test("0 任务的节点也能上色,改名时颜色跟着走(#tag/ops 不再只认卡片)", async () => {
265
+ await create(admin.token, { name: "上色前" });
266
+ const c = await op(admin.token, { op: "color", tag: "上色前", color: "#ABCDEF" });
267
+ expect(c.status).toBe(200);
268
+ expect((await tagsOf(admin.token)).body.colors["上色前"]).toBe("#abcdef");
269
+ const r = await op(admin.token, { op: "rename", from: "上色前", to: "上色后" });
270
+ expect(r.status).toBe(200);
271
+ const after = await tagsOf(admin.token);
272
+ expect(after.body.colors["上色后"]).toBe("#abcdef");
273
+ expect(after.body.colors["上色前"]).toBeUndefined();
274
+ });
275
+ });
@@ -0,0 +1,97 @@
1
+ // 标签树(父子标签)。标签本身仍然只是卡片 tags_json 里的字符串(见 requirement-tags.ts);
2
+ // 这里只多存两件事:
3
+ // ① 谁挂在谁下面 —— network_tag_nodes(network_id, name, parent_name);
4
+ // ② 创建过但还没有任何卡片用的标签 —— 否则 0 任务的子标签在 GET /tags 里读不到(它只会数卡片)。
5
+ // 语义:网络内标签名就是 id(没有 tag_id),parent_name 指向父标签名;改名时父引用跟着改。
6
+ // 深度上限 MAX_TAG_DEPTH(含根)。父不存在 = 报错,不静默挂到根上。
7
+ import { tagName } from "./requirement-tags.js";
8
+
9
+ /** 最多 4 层(顶层是第 1 层)。比子需求(5 层)浅一层:侧栏要一屏放得下父子孙。 */
10
+ export const MAX_TAG_DEPTH = 4;
11
+
12
+ /** 客户端用这个 capability 判断 Hub 有没有「创建标签 / 父标签」;没有就灰掉入口,别调用。 */
13
+ export const TAG_TREE_CAPABILITY = "tag_tree";
14
+
15
+ export type TagNodeRow = { name: string; parent_name: string | null };
16
+
17
+ export type TagNodePublic = {
18
+ name: string;
19
+ parent_id: string | null;
20
+ depth: number;
21
+ /** 这张标签是不是「登记过」的节点(create 建的、或被 create 当父补登记的)。false = 只在卡片上出现过。 */
22
+ registered: boolean;
23
+ };
24
+
25
+ export type CreateTagInput = { name: string; parent: string | null };
26
+
27
+ /**
28
+ * POST /api/requirements/tags/create 的 body:
29
+ * { "name": "子标签", "parent_id": null | "父标签名" }
30
+ * parent_id 缺省/null = 建在根上;给了字符串就必须是网络里已存在的标签 —— 不存在回 404,
31
+ * **绝不**悄悄建到根上(客户端要预选父标签,静默 root fallback 会让 UI 显示的和实际存的不一致)。
32
+ */
33
+ export function parseCreateTag(body: Record<string, unknown>): CreateTagInput | { error: string } {
34
+ const name = tagName(body.name);
35
+ if (!name) return { error: "invalid_tag" };
36
+ const rawParent = body.parent_id;
37
+ if (rawParent === undefined || rawParent === null) return { name, parent: null };
38
+ if (typeof rawParent !== "string") return { error: "invalid_tag_parent_id" };
39
+ const parent = tagName(rawParent);
40
+ if (!parent) return { error: "invalid_tag_parent_id" };
41
+ if (parent === name) return { error: "tag_parent_cycle" };
42
+ return { name, parent };
43
+ }
44
+
45
+ /** 从 name 往上数几层(顶层 = 1)。链上有环(历史脏数据)返回 Infinity,调用方据此拒绝。 */
46
+ export function tagDepth(name: string, parentOf: Map<string, string | null>): number {
47
+ let depth = 1;
48
+ let cur = parentOf.get(name) ?? null;
49
+ const seen = new Set<string>([name]);
50
+ while (cur) {
51
+ if (seen.has(cur)) return Number.POSITIVE_INFINITY;
52
+ seen.add(cur);
53
+ depth++;
54
+ cur = parentOf.get(cur) ?? null;
55
+ }
56
+ return depth;
57
+ }
58
+
59
+ /** child 是不是 ancestor 的子孙(用于改名时的防环判断)。 */
60
+ export function isDescendant(child: string, ancestor: string, parentOf: Map<string, string | null>): boolean {
61
+ let cur: string | null = parentOf.get(child) ?? null;
62
+ const seen = new Set<string>();
63
+ while (cur) {
64
+ if (cur === ancestor) return true;
65
+ if (seen.has(cur)) return false;
66
+ seen.add(cur);
67
+ cur = parentOf.get(cur) ?? null;
68
+ }
69
+ return false;
70
+ }
71
+
72
+ /**
73
+ * GET /api/requirements/tags 的 nodes:
74
+ * 登记过的节点 ∪ 调用者看得见的在用标签(后者 parent_id = null、registered = false,
75
+ * 老 Hub 上就是一堆平标签,升级后不会突然消失)。
76
+ */
77
+ export function buildTagNodes(registered: TagNodeRow[], inUse: string[]): TagNodePublic[] {
78
+ const parentOf = new Map<string, string | null>();
79
+ const isRegistered = new Set<string>();
80
+ for (const row of registered) {
81
+ parentOf.set(row.name, row.parent_name ?? null);
82
+ isRegistered.add(row.name);
83
+ }
84
+ for (const name of inUse) if (!parentOf.has(name)) parentOf.set(name, null);
85
+ const nodes: TagNodePublic[] = [];
86
+ for (const [name, parent] of parentOf) {
87
+ const depth = tagDepth(name, parentOf);
88
+ nodes.push({
89
+ name,
90
+ parent_id: parent,
91
+ // 环/脏数据不给客户端 NaN/Infinity:按顶层展示,写接口仍然会拒绝往里挂。
92
+ depth: Number.isFinite(depth) ? depth : 1,
93
+ registered: isRegistered.has(name),
94
+ });
95
+ }
96
+ return nodes.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
97
+ }
@@ -34,6 +34,14 @@ const ENTRIES: Record<string, Entry> = {
34
34
  not_found: { message: 'No such task, project or route.', hint: 'Check the id; requirements_list / projects_list return the ids you can use.' },
35
35
  project_not_found: { field: 'id', message: 'No project with that id in this network.', hint: 'Call projects_list to get project ids (proj_…).' },
36
36
  tag_not_found: { field: 'tag', message: 'No task in this network carries that tag.', hint: 'Tags are free text on tasks; list the ones in use from requirements_list (view=summary shows each task’s tags).' },
37
+ // 标签树(POST /api/requirements/tags/create):父标签是**标签名**不是 req_… id,所以用自己的错误码,
38
+ // 不借用任务父需求的 parent_not_found / invalid_parent_id(那两句文案会让调用方去查 req id)。
39
+ tag_exists: { field: 'name', message: 'This network already has a tag with that name (on a task, with a colour, or as a registered node).', hint: 'Pick another name, or rename / merge the existing tag with POST /api/requirements/tags/ops.' },
40
+ tag_parent_not_found: { field: 'parent_id', message: 'No tag named that in this network.', hint: 'Create the parent first (parent_id: null), or read GET /api/requirements/tags → nodes for the names that exist.' },
41
+ invalid_tag_parent_id: { field: 'parent_id', message: 'parent_id must be a tag name in this network, or null for the root.', hint: 'Tag names are 1–20 characters; read GET /api/requirements/tags → nodes for the names that exist.' },
42
+ tag_parent_cycle: { field: 'parent_id', message: 'A tag cannot be its own parent.', hint: 'Pass a different parent, or null for the root.' },
43
+ tag_depth_exceeded: { field: 'parent_id', message: 'Tags nest at most 4 levels (the root counts as level 1).', hint: 'Attach the tag to a higher-level tag.' },
44
+ invalid_tag: { field: 'name', message: 'Tag names are 1–20 characters after trimming, with no control characters.', hint: 'Shorten the name, or remove line breaks.' },
37
45
  checklist_item_not_found: { field: 'item_id', message: 'This task has no checklist item with that id.', hint: 'Item ids (ck_…) are in requirements_get → checklist[].id.' },
38
46
  // ── 权限 ──
39
47
  user_token_required: { message: 'This operation is for people only: a node token can read projects and read / create / update tasks, but cannot manage projects or delete tasks.', hint: 'Use an existing project (projects_list), or ask a person to create / change the project in the app (任务 → 管理项目).' },
@@ -373,7 +373,7 @@ describe("requirements stay on the hub", () => {
373
373
  expect((await api(ownerToken, path, { method: "PATCH", body: JSON.stringify({ due: "" }) })).body.requirement.due).toBe("");
374
374
  const listed = await api(ownerToken, "/api/requirements");
375
375
  expect(listed.body.capabilities).toContain("due_datetime");
376
- expect(listed.body.capabilities).toEqual(["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date", "requirement_seq", "search", "paging", "completed_at", "stats", "tag_status_snapshot", "tag_ops", "list_summary", "changes", "events", "last_event", "column_abandoned"]);
376
+ expect(listed.body.capabilities).toEqual(["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date", "requirement_seq", "search", "paging", "completed_at", "stats", "tag_status_snapshot", "tag_ops", "tag_tree", "list_summary", "changes", "events", "last_event", "column_abandoned"]);
377
377
  });
378
378
 
379
379
  test("sub-requirements: parent_id in the same network, no cycles, ≤ 5 levels, child counts, filters, delete detaches children", async () => {
@@ -59,6 +59,27 @@ export function ensureNetworkTags(database: DbAdapter): void {
59
59
  `);
60
60
  }
61
61
 
62
+ /**
63
+ * 标签树(父子标签)。标签名就是 id,parent_name 指向同网络的父标签名;没有 parent 行 = 顶层。
64
+ * 只存「登记过的节点」:卡片上出现但没登记过的标签(老数据)仍然是平标签,不改写任何 cards_json。
65
+ * 建标签、改名 / 合并 / 删除时维护这里(见 requirements.ts 的 handleTagCreate / handleTagOp)。
66
+ * IF NOT EXISTS:重复执行无副作用。
67
+ */
68
+ export function ensureNetworkTagNodes(database: DbAdapter): void {
69
+ database.exec(`
70
+ CREATE TABLE IF NOT EXISTS network_tag_nodes (
71
+ network_id TEXT NOT NULL,
72
+ name TEXT NOT NULL,
73
+ parent_name TEXT,
74
+ created_at TEXT NOT NULL,
75
+ updated_at TEXT NOT NULL,
76
+ created_by_json TEXT,
77
+ PRIMARY KEY (network_id, name)
78
+ );
79
+ CREATE INDEX IF NOT EXISTS idx_network_tag_nodes_parent ON network_tag_nodes(network_id, parent_name);
80
+ `);
81
+ }
82
+
62
83
  /**
63
84
  * 删掉的卡留一条墓碑,给 GET /api/requirements?changes=1 报「这些 id 没了」(按 updated_since 增量同步的客户端
64
85
  * 否则永远不知道一张卡被删了)。连同判断可见性要用的列一起存:受限成员只收到他本来看得见的卡的删除。
@@ -8,6 +8,7 @@ import { db, logAudit } from "./db.js";
8
8
  import { resolveNodeCaller } from "./create-node.js";
9
9
  import { markGzipReusable } from "./http-gzip.js";
10
10
  import { applyTagOp, normalizeTags, parseTagOp, storedTags, type TagOp } from "./requirement-tags.js";
11
+ import { buildTagNodes, MAX_TAG_DEPTH, parseCreateTag, TAG_TREE_CAPABILITY, tagDepth, type TagNodeRow } from "./requirement-tag-tree.js";
11
12
  import { aggregateStats, parseStatsQuery, type StatsRow } from "./requirements-stats.js";
12
13
  import { addHumanNetworkScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, resolveRestWriteNetworkId, type RestNetworkScope } from "./network-scope.js";
13
14
  import { isAgentRestricted, restrictedNetworkIds, visibleAgents } from "./agent-access.js";
@@ -20,7 +21,7 @@ import { notifyLeaderDeleted, notifyParticipantChange } from "./requirement-noti
20
21
  import { departmentCardsFor, departmentSubtree } from "./department-heads.js";
21
22
  import { dueReminderTimezone, dueWithinSql, ensureDueReminders, overdueSql } from "./requirement-due-reminders.js";
22
23
  import { diffRequirement, ensureRequirementEvents, eventPublic, recordRequirementEvents, type EventRow } from "./requirement-events.js";
23
- import { ensureNetworkTags, ensureRequirementCompletedAt, ensureRequirementIndexes, ensureRequirementProjects, ensureRequirementSeq, ensureRequirementTombstones, migrateRequirementAgentOwners, migrateRequirementColumnCheck, migrateRequirementPriorityCheck, nextRequirementSeq } from "./requirements-migrate.js";
24
+ import { ensureNetworkTagNodes, ensureNetworkTags, ensureRequirementCompletedAt, ensureRequirementIndexes, ensureRequirementProjects, ensureRequirementSeq, ensureRequirementTombstones, migrateRequirementAgentOwners, migrateRequirementColumnCheck, migrateRequirementPriorityCheck, nextRequirementSeq } from "./requirements-migrate.js";
24
25
 
25
26
  // 启动迁移:旧库里节点当负责人的卡,节点挪到 agent_owner(列由 db.ts 的加列循环加上)。
26
27
  // 放在这里而不是 db.ts:db.ts 每多一行,文档里钉着的行号就漂一次。
@@ -32,6 +33,7 @@ ensureRequirementIndexes(db);
32
33
  ensureRequirementSeq(db);
33
34
  ensureRequirementCompletedAt(db);
34
35
  ensureNetworkTags(db);
36
+ ensureNetworkTagNodes(db);
35
37
  ensureRequirementTombstones(db);
36
38
  ensureRequirementEvents(db);
37
39
  ensureDueReminders(db);
@@ -474,7 +476,9 @@ async function handleTagOp(ctx: RequirementsRequestContext): Promise<Response> {
474
476
  );
475
477
  const colorOf = (name: string) => db.get<{ color: string }>("SELECT color FROM network_tags WHERE network_id = ?1 AND name = ?2", networkId, name)?.color ?? null;
476
478
  const sources = op.op === "rename" ? [op.from] : op.op === "merge" ? op.from : [op.tag];
477
- const known = cards.some(card => storedTags(card.tags_json).some(tag => sources.includes(tag))) || sources.some(tag => colorOf(tag) !== null);
479
+ // 「存在」= 有卡片在用 / 有颜色行 / 是登记过的树节点。最后一种让 0 任务的标签也能改名、合并、删除、上色。
480
+ const nodeExists = (name: string) => !!db.get<{ x: number }>("SELECT 1 AS x FROM network_tag_nodes WHERE network_id = ?1 AND name = ?2", networkId, name);
481
+ const known = cards.some(card => storedTags(card.tags_json).some(tag => sources.includes(tag))) || sources.some(tag => colorOf(tag) !== null || nodeExists(tag));
478
482
  if (!known) return null;
479
483
  if (op.op === "color") {
480
484
  if (op.color === null) db.run("DELETE FROM network_tags WHERE network_id = ?1 AND name = ?2", [networkId, op.tag]);
@@ -490,11 +494,12 @@ async function handleTagOp(ctx: RequirementsRequestContext): Promise<Response> {
490
494
  affected++;
491
495
  }
492
496
  moveTagColors(networkId, op, colorOf, now);
493
- return { affected };
497
+ const reparented = maintainTagTree(networkId, op, now);
498
+ return { affected, reparented };
494
499
  });
495
500
  if (!result) return jsonError("tag_not_found", 404);
496
501
  if (ctx.auth) logAudit(ctx.auth.userId, ctx.auth.username || null, `requirement_tag_${op.op}`, "requirement_tag", op.op === "rename" || op.op === "merge" ? op.to : op.tag, JSON.stringify({ ...op, affected: result.affected }).slice(0, 1000), undefined, networkId);
497
- return Response.json({ ok: true, op: op.op, affected: result.affected });
502
+ return Response.json({ ok: true, op: op.op, affected: result.affected, reparented: result.reparented });
498
503
  }
499
504
 
500
505
  /** 改名 / 合并:目标没有颜色就继承第一个有颜色的来源;来源的颜色行删掉。删除:删颜色行。 */
@@ -508,6 +513,115 @@ function moveTagColors(networkId: string, op: TagOp, colorOf: (name: string) =>
508
513
  for (const tag of sources) db.run("DELETE FROM network_tags WHERE network_id = ?1 AND name = ?2", [networkId, tag]);
509
514
  }
510
515
 
516
+ /**
517
+ * 标签改名 / 合并 / 删除时让树跟着动(与卡片改写同一个事务):
518
+ * rename —— 节点改名,子节点的 parent_name 一起改;目标已有节点时按合并处理(并把「目标自己是来源的子节点」这种情况挂到来源的父上,避免自环)。
519
+ * merge —— 来源节点的子节点挂到目标,来源节点删掉。
520
+ * delete —— 被删节点的子节点挂到它的父(没有父就成根);不删子节点、不丢层级。
521
+ * 返回被改挂的节点数(客户端能报「N 个子标签已上移」)。
522
+ */
523
+ function maintainTagTree(networkId: string, op: TagOp, now: string): number {
524
+ if (op.op === "color") return 0;
525
+ const rows = db.all<TagNodeRow>("SELECT name, parent_name FROM network_tag_nodes WHERE network_id = ?1", networkId);
526
+ if (!rows.length) return 0;
527
+ const parentOf = new Map(rows.map(row => [row.name, row.parent_name ?? null]));
528
+ if (op.op === "rename") {
529
+ if (!parentOf.has(op.from)) return 0;
530
+ const fromParent = parentOf.get(op.from) ?? null;
531
+ if (!parentOf.has(op.to)) {
532
+ db.run("UPDATE network_tag_nodes SET name = ?1, updated_at = ?2 WHERE network_id = ?3 AND name = ?4", [op.to, now, networkId, op.from]);
533
+ const moved = db.run("UPDATE network_tag_nodes SET parent_name = ?1, updated_at = ?2 WHERE network_id = ?3 AND parent_name = ?4", [op.to, now, networkId, op.from]);
534
+ return moved.changes ?? 0;
535
+ }
536
+ // 目标已有节点 —— 合并。目标若正好是来源的子孙,直接挂过去会自环,改挂到来源的父。
537
+ let moved = 0;
538
+ for (const row of rows) {
539
+ if (row.parent_name !== op.from) continue;
540
+ const nextParent = row.name === op.to ? fromParent : op.to;
541
+ db.run("UPDATE network_tag_nodes SET parent_name = ?1, updated_at = ?2 WHERE network_id = ?3 AND name = ?4", [nextParent, now, networkId, row.name]);
542
+ moved++;
543
+ }
544
+ db.run("DELETE FROM network_tag_nodes WHERE network_id = ?1 AND name = ?2", [networkId, op.from]);
545
+ return moved;
546
+ }
547
+ if (op.op === "merge") {
548
+ let moved = 0;
549
+ for (const source of op.from) {
550
+ if (!parentOf.has(source)) continue;
551
+ const children = rows.filter(row => row.parent_name === source);
552
+ const res = db.run("UPDATE network_tag_nodes SET parent_name = ?1, updated_at = ?2 WHERE network_id = ?3 AND parent_name = ?4", [op.to, now, networkId, source]);
553
+ moved += res.changes ?? children.length;
554
+ db.run("DELETE FROM network_tag_nodes WHERE network_id = ?1 AND name = ?2", [networkId, source]);
555
+ }
556
+ return moved;
557
+ }
558
+ // delete
559
+ const row = parentOf.has(op.tag) ? rows.find(item => item.name === op.tag)! : null;
560
+ if (!row) return 0;
561
+ const res = db.run("UPDATE network_tag_nodes SET parent_name = ?1, updated_at = ?2 WHERE network_id = ?3 AND parent_name = ?4", [row.parent_name ?? null, now, networkId, op.tag]);
562
+ db.run("DELETE FROM network_tag_nodes WHERE network_id = ?1 AND name = ?2", [networkId, op.tag]);
563
+ return res.changes ?? 0;
564
+ }
565
+
566
+ /** 网络里所有卡片上出现过的标签(**不分可见范围**)。写操作按整个网络判重,否则同名标签会被拆成两半。 */
567
+ function networkTagNames(networkId: string): Set<string> {
568
+ const names = new Set<string>();
569
+ for (const row of db.all<{ tags_json: string | null }>(
570
+ "SELECT tags_json FROM requirements WHERE network_id = ?1 AND tags_json IS NOT NULL AND tags_json <> '[]'", networkId,
571
+ )) {
572
+ for (const tag of storedTags(row.tags_json)) names.add(tag);
573
+ }
574
+ return names;
575
+ }
576
+
577
+ type CreateTagOutcome =
578
+ | { error: "tag_exists" | "tag_parent_not_found" | "tag_depth_exceeded" }
579
+ | { name: string; parent: string | null; depth: number; parentRegistered: boolean };
580
+
581
+ /** POST /api/requirements/tags/create —— 建标签(可带父)。原子、整网络判重、父不存在就报错。 */
582
+ async function handleTagCreate(ctx: RequirementsRequestContext): Promise<Response> {
583
+ const networkId = resolveRestWriteNetworkId(ctx.scope, ctx.auth, ctx.isAdmin);
584
+ if (!networkId) return jsonError("network_id_required", 400);
585
+ // 与改名 / 合并 / 删除同一把锁:整网写,只给不受任务范围限制的人。
586
+ if (!canManageTags(ctx, networkId)) return jsonError("permission_denied", 403);
587
+ let body: Record<string, unknown>;
588
+ try { body = await bodyObject(ctx.req); } catch { return jsonError("invalid_json", 400); }
589
+ const input = parseCreateTag(body);
590
+ if ("error" in input) return jsonError(input.error, 400);
591
+ const now = new Date().toISOString();
592
+ const actor = JSON.stringify(actorOf(ctx));
593
+ const result = db.transaction<CreateTagOutcome>(() => {
594
+ const nodes = db.all<TagNodeRow>("SELECT name, parent_name FROM network_tag_nodes WHERE network_id = ?1", networkId);
595
+ const parentOf = new Map(nodes.map(row => [row.name, row.parent_name ?? null]));
596
+ const used = networkTagNames(networkId);
597
+ const hasColor = (name: string) => !!db.get<{ x: number }>("SELECT 1 AS x FROM network_tags WHERE network_id = ?1 AND name = ?2", networkId, name);
598
+ const exists = (name: string) => parentOf.has(name) || used.has(name) || hasColor(name);
599
+ // 重名 = 409:不「复用」已有标签(复用会让 UI 以为新建成功,实际改了别的分支的标签)。
600
+ if (exists(input.name)) return { error: "tag_exists" };
601
+ let parentRegistered = false;
602
+ if (input.parent) {
603
+ // 父不存在 = 404。**不**把子标签建到根上:客户端预选了父,存成根会让回读的层级和表单不一致。
604
+ if (!exists(input.parent)) return { error: "tag_parent_not_found" };
605
+ if (!parentOf.has(input.parent)) {
606
+ // 父此前只在卡片上出现过(老数据):登记成顶层节点。父本来就没有父,这不是把子标签降级到根。
607
+ db.run("INSERT INTO network_tag_nodes (network_id, name, parent_name, created_at, updated_at, created_by_json) VALUES (?1, ?2, NULL, ?3, ?3, ?4)", [networkId, input.parent, now, actor]);
608
+ parentOf.set(input.parent, null);
609
+ parentRegistered = true;
610
+ }
611
+ if (tagDepth(input.parent, parentOf) + 1 > MAX_TAG_DEPTH) return { error: "tag_depth_exceeded" };
612
+ }
613
+ db.run("INSERT INTO network_tag_nodes (network_id, name, parent_name, created_at, updated_at, created_by_json) VALUES (?1, ?2, ?3, ?4, ?4, ?5)", [networkId, input.name, input.parent, now, actor]);
614
+ parentOf.set(input.name, input.parent);
615
+ return { name: input.name, parent: input.parent, depth: tagDepth(input.name, parentOf), parentRegistered };
616
+ });
617
+ if ("error" in result) {
618
+ const status = result.error === "tag_exists" ? 409 : result.error === "tag_parent_not_found" ? 404 : 400;
619
+ return jsonError(result.error, status);
620
+ }
621
+ if (ctx.auth) logAudit(ctx.auth.userId, ctx.auth.username || null, "requirement_tag_create", "requirement_tag", result.name, JSON.stringify({ parent_id: result.parent, depth: result.depth }).slice(0, 1000), undefined, networkId);
622
+ return Response.json({ ok: true, tag: { name: result.name, parent_id: result.parent, depth: result.depth }, parent_registered: result.parentRegistered }, { status: 201 });
623
+ }
624
+
511
625
  function toPublic(row: Row) {
512
626
  return {
513
627
  owner: row.owner_json ? JSON.parse(row.owner_json) : null,
@@ -726,7 +840,7 @@ export const COMMENT_MAX_CHARS = 4000;
726
840
  const NODE_TOKEN_OPERATIONS: ReadonlySet<string> = new Set<string>(["read", "create", "patch", "checklist_item", "comment", "upsert", "projects_read"]);
727
841
  function operationOf(req: Request, url: URL): string {
728
842
  if (/^\/api\/requirements\/projects(\/|$)/.test(url.pathname)) return req.method === "GET" ? "projects_read" : "projects_write";
729
- if (url.pathname === "/api/requirements/tags/ops") return "tags_write";
843
+ if (url.pathname === "/api/requirements/tags/ops" || url.pathname === "/api/requirements/tags/create") return "tags_write";
730
844
  if (req.method === "GET") return "read";
731
845
  if (/^\/api\/requirements\/[^/]+\/checklist\/[^/]+$/.test(url.pathname)) return "checklist_item";
732
846
  if (/^\/api\/requirements\/[^/]+\/comments$/.test(url.pathname)) return "comment";
@@ -770,7 +884,7 @@ function ifNoneMatchHits(header: string | null, etag: string): boolean {
770
884
  // list_summary:GET 认 view=summary(不带描述正文与子任务条目,见 toSummary);changes:GET 认 changes=1 + updated_since
771
885
  // (改过的卡含归档的,加上 deleted 墓碑与 server_time,见 listChanges)。
772
886
  // events:GET /api/requirements/events —— 字段级的改动流水(谁、何时、旧值 → 新值,requirement-events.ts)。
773
- export const REQUIREMENT_CAPABILITIES = ["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date", "requirement_seq", "search", "paging", "completed_at", "stats", "tag_status_snapshot", "tag_ops", "list_summary", "changes", "events", "last_event", "column_abandoned"] as const;
887
+ export const REQUIREMENT_CAPABILITIES = ["agent_owner", "description", "checklist", "projects", "due_datetime", "external_ref", "archived", "agent_api", "sub_requirements", "tags", "priority_lowest", "start_date", "requirement_seq", "search", "paging", "completed_at", "stats", "tag_status_snapshot", "tag_ops", TAG_TREE_CAPABILITY, "list_summary", "changes", "events", "last_event", "column_abandoned"] as const;
774
888
 
775
889
  // ── 子需求 ──
776
890
  // parent_id:同一网络里的另一张卡;不能成环;最多 5 层(顶层是第 1 层)。删父卡 = 子卡保留、parent_id 置空。
@@ -1559,15 +1673,22 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
1559
1673
  const tagSql = addTaskVisibilityScope("SELECT tags_json FROM requirements WHERE network_id=?1", tagParams, taskCaller(ctx));
1560
1674
  const rows = db.all<{ tags_json: string | null }>(tagSql, ...tagParams);
1561
1675
  // counts:调用者看得见的卡里(含归档)每个标签用了几张;colors:只回这些标签的颜色(看不见的卡上的标签不外泄)。
1562
- // can_manage:这个调用者能不能用 /tags/ops。旧 App 只读 tags,多出的字段忽略。
1676
+ // can_manage:这个调用者能不能用 /tags/ops /tags/create。旧 App 只读 tags,多出的字段忽略。
1563
1677
  const counts: Record<string, number> = {};
1564
1678
  for (const row of rows) for (const tag of storedTags(row.tags_json)) counts[tag] = (counts[tag] ?? 0) + 1;
1565
1679
  const tags = Object.keys(counts).sort();
1680
+ const manageable = canManageTags(ctx, networkId);
1681
+ // nodes:标签树。登记过的节点(create 建的,含 0 任务)∪ 调用者看得见的在用标签。
1682
+ // 受限成员只拿得到「看得见的名字」:登记过但一张可见卡都没有的标签不外泄(与 counts / colors 同一口径)。
1683
+ const registeredRows = db.all<TagNodeRow>("SELECT name, parent_name FROM network_tag_nodes WHERE network_id = ?1", networkId);
1684
+ const visibleRegistered = manageable ? registeredRows : registeredRows.filter(row => counts[row.name] > 0);
1685
+ const nodeNames = new Set([...registeredRows.map(row => row.name), ...tags]);
1566
1686
  const colors: Record<string, string> = {};
1567
1687
  for (const row of db.all<{ name: string; color: string }>("SELECT name, color FROM network_tags WHERE network_id = ?1", networkId)) {
1568
- if (counts[row.name]) colors[row.name] = row.color;
1688
+ if (counts[row.name] || (manageable && nodeNames.has(row.name))) colors[row.name] = row.color;
1569
1689
  }
1570
- return Response.json({ ok: true, networkId, tags, counts, colors, can_manage: canManageTags(ctx, networkId) });
1690
+ const nodes = buildTagNodes(visibleRegistered, tags).map(node => ({ ...node, count: counts[node.name] ?? 0, color: colors[node.name] ?? null }));
1691
+ return Response.json({ ok: true, networkId, tags, counts, colors, nodes, can_manage: manageable });
1571
1692
  }
1572
1693
 
1573
1694
  if (url.pathname === "/api/requirements/tags/ops") {
@@ -1575,6 +1696,12 @@ async function handleRequirementsRequestInner(ctx: RequirementsRequestContext):
1575
1696
  return handleTagOp(ctx);
1576
1697
  }
1577
1698
 
1699
+ // 建标签(可带 parent_id)。原子:节点 + 审计在一个事务里;父不存在 404,绝不安静建到根上。
1700
+ if (url.pathname === "/api/requirements/tags/create") {
1701
+ if (req.method !== "POST") return jsonError("not_found", 404);
1702
+ return handleTagCreate(ctx);
1703
+ }
1704
+
1578
1705
  // 仪表盘聚合(requirements-stats.ts):一个网络、调用者看得见的卡(与列表同一个可见范围),含归档的卡 ——
1579
1706
  // 完成的卡常被归档,不算它们「今天完成了多少」就会偏少。
1580
1707
  if (url.pathname === "/api/requirements/stats" && req.method === "GET") {
@@ -312,13 +312,23 @@ describe("old clients unaffected", () => {
312
312
  });
313
313
 
314
314
  describe("scheduler re-checks the creating node as it is now", () => {
315
+ // #2115 —— `at` 原来是 `now - 5s - random(0..1000ms)`:两次调用只要「晚的毫秒差」正好等于两个
316
+ // 随机值之差,算出的 `at` 就完全相同,于是第二次按 `scheduled_for = at` 读到的是**上一轮那行**
317
+ // (于是 test638 L2 并发聚合偶发「期望 creator_node_gone、得到 creator_node_readonly」)。
318
+ // 注释里说「随机」的人以为碰撞概率是 1/1000,其实是「随机差 == 时间差」,慢机器上时间差几十毫秒
319
+ // 就落在这个窗口里。改成单调递推:每次减 1s,任何两次调用都不可能相等。
320
+ let dueSeq = 0;
315
321
  const dueAndRun = async (id: string) => {
316
- const at = new Date(Date.now() - 5_000 - Math.floor(Math.random() * 1000)).toISOString();
322
+ dueSeq += 1;
323
+ const at = new Date(Date.now() - 5_000 - dueSeq * 1_000).toISOString();
317
324
  db.run("UPDATE scheduled_tasks SET next_run_at = ?1 WHERE schedule_id = ?2", [at, id]);
318
325
  const { runDueScheduledTasks } = await import("./scheduled-tasks.js");
319
326
  runDueScheduledTasks();
320
- return db.get<{ status: string; error_code: string | null; task_id: string | null }>(
321
- "SELECT status, error_code, task_id FROM scheduled_task_runs WHERE schedule_id = ?1 AND scheduled_for = ?2", id, at)!;
327
+ const row = db.get<{ status: string; error_code: string | null; task_id: string | null }>(
328
+ "SELECT status, error_code, task_id FROM scheduled_task_runs WHERE schedule_id = ?1 AND scheduled_for = ?2", id, at);
329
+ // 把「这一轮根本没跑出 run 行」和「跑出了但结论不对」分开报 —— 前者是夹具问题,后者才是行为问题。
330
+ expect(row).toBeTruthy();
331
+ return row!;
322
332
  };
323
333
  test("owner changes to a user not granted the target: send_task denied, and so is every scheduled run", async () => {
324
334
  const id = (await create("a", { target_node_id: node.b.id })).schedule.schedule_id;
package/src/server.ts CHANGED
@@ -99,7 +99,7 @@ import { handleSideThreadHttpRequest } from "./side-thread-http.js";
99
99
  import { createProductionSideThreadTransport } from "./side-thread-production.js";
100
100
  import { parseHubTimestamp } from "./hub-timestamp";
101
101
  import { logTaskRejection } from "./task-rejection-log.js";
102
- import { AVATAR_FIELD, avatarUrl, inspectAvatarImage, isMultipart, isValidAvatarVersion, MAX_AVATAR_BYTES } from "./user-avatar-image.js";
102
+ import { AVATAR_FIELD, avatarUrl, inspectAvatarImage, isMultipart, isValidAvatarVersion, MAX_AVATAR_BYTES, MAX_AVATAR_STORAGE_BYTES } from "./user-avatar-image.js";
103
103
 
104
104
  const PORT = resolvePort(process.env.PORT);
105
105
  const HOST = process.env.HOST || "127.0.0.1";
@@ -1553,6 +1553,14 @@ return Bun.serve({
1553
1553
  return withCors(req, Response.json({ ok: false, error: "invalid_avatar_body", reason: "expected exactly one `file` part" }, { status: 400 }));
1554
1554
  }
1555
1555
  if (file.size > MAX_AVATAR_BYTES) return withCors(req, Response.json({ ok: false, error: "too_large" }, { status: 413 }));
1556
+ // #2054 —— Hub-wide 总容量上界:公开注册可无限建账号,per-user 单条上限挡不住
1557
+ // 「轮换账号把主库撑大」。这里在**写入前**用当前总量 + 本次大小判一次;
1558
+ // 已存在的其他用户头像绝不删除(不回收别人的数据),只拒绝本次上传。
1559
+ const storedBytes = db.get<{ total: number }>("SELECT COALESCE(SUM(bytes), 0) AS total FROM user_avatar_uploads")?.total ?? 0;
1560
+ const ownBytes = db.get<{ bytes: number }>("SELECT bytes FROM user_avatar_uploads WHERE user_id = ?1", [resolved.user.user_id])?.bytes ?? 0;
1561
+ if (storedBytes - ownBytes + file.size > MAX_AVATAR_STORAGE_BYTES) {
1562
+ return withCors(req, Response.json({ ok: false, error: "avatar_storage_full" }, { status: 503 }));
1563
+ }
1556
1564
  const bytes = new Uint8Array(await file.arrayBuffer());
1557
1565
  const check = inspectAvatarImage(bytes);
1558
1566
  if (!check.ok) return withCors(req, Response.json({ ok: false, error: "invalid_image", reason: check.reason }, { status: 400 }));
@@ -10,6 +10,13 @@ import { inflateSync } from "node:zlib";
10
10
  // 校验强度:结构(签名/chunk/CRC)+ 真实解码(IDAT 解压且扫描行长度与 IHDR 相符)。
11
11
 
12
12
  export const MAX_AVATAR_BYTES = 8 * 1024 * 1024;
13
+
14
+ /**
15
+ * #2054 —— 单个 Hub 的头像总存储上界。公开注册可以持续创建账号,per-user 单条
16
+ * 上限(每人 1 条 × 8 MiB)挡不住轮换账号把主库撑大,所以再加一道总量边界。
17
+ * 超限时上传返回 503 `avatar_storage_full`;不回收/删除任何既有用户的头像。
18
+ */
19
+ export const MAX_AVATAR_STORAGE_BYTES = 1024 * 1024 * 1024;
13
20
  /** 客户端会把图重编码到最长边 512;服务端独立复核这条上限。 */
14
21
  export const MAX_AVATAR_EDGE = 512;
15
22
  /** version 只允许 URL 安全字符,长度有界(避免拼路径时引入 `.`、`/`)。 */
@@ -292,3 +292,57 @@ test("image inspector: signature, shape and size limits", () => {
292
292
  expect(inspectAvatarImage(jpeg)).toEqual({ ok: false, reason: "not_png" });
293
293
  expect(inspectAvatarImage(Buffer.alloc(MAX_AVATAR_BYTES + 1))).toEqual({ ok: false, reason: "too_large" });
294
294
  });
295
+
296
+ // #2054 —— Hub-wide 头像总容量上界:公开注册可无限建账号,per-user 单条上限挡不住
297
+ // 轮换账号把主库撑大。这里在库里塞一条接近上界的行,断言上传被 503 拒绝,
298
+ // 且**不删除**任何既有用户的头像(别人的数据不动),随后清掉该行恢复。
299
+ test("account avatar upload: a hub-wide storage cap rejects the write and keeps existing rows", async () => {
300
+ const { MAX_AVATAR_STORAGE_BYTES } = await import("./user-avatar-image.js");
301
+ const victim = register("avcap_alice", "AvatarCap1!");
302
+ const uploader = register("avcap_bob", "AvatarCap1!");
303
+ expect(victim.ok && uploader.ok).toBe(true);
304
+
305
+ const { bootServer } = await import("./server.js");
306
+ const hub = bootServer({ port: 0, hostname: "127.0.0.1" });
307
+ const base = `http://127.0.0.1:${hub.port}`;
308
+ const upload = async (token: string, bytes: Buffer) => {
309
+ const form = new FormData();
310
+ form.append("file", new Blob([bytes], { type: "image/png" }), "a.png");
311
+ const res = await fetch(`${base}/api/auth/me/avatar/upload`, {
312
+ method: "POST", headers: { Authorization: `Bearer ${token}` }, body: form,
313
+ });
314
+ return { status: res.status, body: (await res.json().catch(() => null)) as any };
315
+ };
316
+
317
+ const filler = Buffer.from("filler-avatar-bytes");
318
+ try {
319
+ // 先让 bob 正常上传一次,证明上界不是「谁都不能传」。
320
+ const ok = await upload(uploader.token!, PNG_1X1);
321
+ expect(ok.status).toBe(200);
322
+
323
+ // 塞一条「别人已经占满」的行:总量逼到上界之上。
324
+ db.run(
325
+ "INSERT INTO user_avatar_uploads (user_id, version, png, bytes, width, height, updated_at) VALUES (?1, ?2, ?3, ?4, 1, 1, datetime('now'))",
326
+ [victim.user!.user_id, "capver", filler, MAX_AVATAR_STORAGE_BYTES],
327
+ );
328
+ const before = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM user_avatar_uploads")!.n;
329
+
330
+ const blocked = await upload(uploader.token!, PNG_1X1);
331
+ expect(blocked.status).toBe(503);
332
+ expect(blocked.body.error).toBe("avatar_storage_full");
333
+
334
+ // 不回收别人的数据:行数与那条 filler 都还在。
335
+ const after = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM user_avatar_uploads")!.n;
336
+ expect(after).toBe(before);
337
+ expect(db.get<any>("SELECT bytes FROM user_avatar_uploads WHERE user_id = ?1", [victim.user!.user_id])?.bytes)
338
+ .toBe(MAX_AVATAR_STORAGE_BYTES);
339
+ // 自己的额度要扣掉:上限全算在自己头上时,替换**自己**的头像不应被拒。
340
+ db.run("DELETE FROM user_avatar_uploads WHERE user_id = ?1", [victim.user!.user_id]);
341
+ db.run("UPDATE user_avatar_uploads SET bytes = ?1 WHERE user_id = ?2", [MAX_AVATAR_STORAGE_BYTES, uploader.user!.user_id]);
342
+ const replaced = await upload(uploader.token!, PNG_1X1);
343
+ expect(replaced.status).toBe(200);
344
+ } finally {
345
+ db.run("DELETE FROM user_avatar_uploads WHERE user_id = ?1", [victim.user!.user_id]);
346
+ hub.stop?.();
347
+ }
348
+ });