@sleep2agi/commhub-server 0.9.0-preview.147 → 0.9.0-preview.149

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.147",
3
+ "version": "0.9.0-preview.149",
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",
@@ -19,6 +19,11 @@ let server: ReturnType<typeof Bun.serve>;
19
19
  let base = "";
20
20
  const PLAIN = "n_gate_plain";
21
21
  const TLS = "n_gate_tls";
22
+ // #1510 acceptance names three transports: loopback passes, https passes, plain
23
+ // public is refused. log/enforce only ever *special-case* `plain`, so loopback
24
+ // could rot without anyone noticing it is still accepted — seed it as its own
25
+ // node rather than folding it in with TLS, so a regression reads by name.
26
+ const LOOPBACK = "n_gate_loopback";
22
27
 
23
28
  const post = async (path: string, token: string, body: unknown) => {
24
29
  const r = await fetch(`${base}${path}`, {
@@ -50,6 +55,7 @@ beforeAll(async () => {
50
55
  );
51
56
  seed(PLAIN, "plain");
52
57
  seed(TLS, "https");
58
+ seed(LOOPBACK, "loopback");
53
59
  // The transport is reported on the session row (that is where report_status
54
60
  // writes it).
55
61
  const session = (nodeId: string, transport: string) => db.run(
@@ -59,6 +65,7 @@ beforeAll(async () => {
59
65
  );
60
66
  session(PLAIN, "plain");
61
67
  session(TLS, "https");
68
+ session(LOOPBACK, "loopback");
62
69
 
63
70
  const mod = await import("./server.js");
64
71
  server = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
@@ -74,7 +81,7 @@ beforeEach(async () => {
74
81
  // The lane allows one pending request per node; drain whatever the previous
75
82
  // case left so the next one starts clean.
76
83
  const { pullNodeSecrets, ackNodeSecrets } = await import("./scoped-secrets.js");
77
- for (const nodeId of [PLAIN, TLS]) {
84
+ for (const nodeId of [PLAIN, TLS, LOOPBACK]) {
78
85
  const daemon = { ok: true as const, networkId: net, daemonNodeId: nodeId, daemonAlias: nodeId };
79
86
  const pulled = pullNodeSecrets(daemon) as any;
80
87
  for (const r of (pulled?.requests ?? [])) ackNodeSecrets(daemon, r.request_id, null, true);
@@ -102,6 +109,19 @@ describe("log mode (default)", () => {
102
109
  expect(after).toBe(before);
103
110
  });
104
111
 
112
+ // #1510 acceptance leg 1: loopback is an allowed transport, so it must neither
113
+ // be refused nor pollute the would-refuse log (the log is the decision input
114
+ // for turning enforce on — a loopback hit in it would read as "something to
115
+ // block" when nothing is).
116
+ test("a put to a loopback node succeeds AND leaves no hit", async () => {
117
+ const before = (await get(`/api/networks/${net}/env-transport-log`, ownerToken)).body.total;
118
+ const r = await post(`/api/nodes/${LOOPBACK}/secrets?network_id=${net}`, ownerToken, { action: "put", key: "LB_KEY", value: "v", revision: 1 });
119
+ expect(r.status).toBe(200);
120
+ expect(r.body.ok).toBe(true);
121
+ const after = (await get(`/api/networks/${net}/env-transport-log`, ownerToken)).body.total;
122
+ expect(after).toBe(before);
123
+ });
124
+
105
125
  test("list is not observed (it carries no secret)", async () => {
106
126
  const before = (await get(`/api/networks/${net}/env-transport-log`, ownerToken)).body.total;
107
127
  await post(`/api/nodes/${PLAIN}/secrets?network_id=${net}`, ownerToken, { action: "list" });
@@ -123,6 +143,17 @@ describe("enforce mode", () => {
123
143
  const r = await post(`/api/nodes/${TLS}/secrets?network_id=${net}`, ownerToken, { action: "put", key: "D_KEY", value: "v", revision: 1 });
124
144
  expect(r.status).toBe(200);
125
145
  });
146
+
147
+ // #1510 acceptance: "loopback 通过 / https 通过 / 明文公网被拒". enforce is
148
+ // defined as *refuse plain* — loopback is a local link and must keep working
149
+ // under enforce, otherwise turning the gate on would break the one transport
150
+ // it exists to protect.
151
+ test("a loopback node passes enforce too", async () => {
152
+ process.env.ANET_SECRET_TRANSPORT_GATE = "enforce";
153
+ const r = await post(`/api/nodes/${LOOPBACK}/secrets?network_id=${net}`, ownerToken, { action: "put", key: "LB2_KEY", value: "v", revision: 1 });
154
+ expect(r.status).toBe(200);
155
+ expect(r.body.ok).toBe(true);
156
+ });
126
157
  });
127
158
 
128
159
  describe("off mode", () => {
@@ -297,7 +297,9 @@ describe("#2017 human-to-exact-Bridge HTTP relay", () => {
297
297
  test("lease expiry refuses new work and Hub restart loses IDs without replay", async () => {
298
298
  const made = await enqueue();
299
299
  setSystemTime(new Date(Date.now() + 76_000));
300
- expect((await enqueue()).body.error).toBe("session_control_unavailable");
300
+ // #2014: no live bridge is its own code, so a client can tell "this node has no
301
+ // bridge (upgrade / start it)" from a permission refusal.
302
+ expect((await enqueue()).body.error).toBe("session_control_bridge_missing");
301
303
  setSystemTime();
302
304
  server.stop(true);
303
305
  const { bootServer } = await import("./server.js");
@@ -198,7 +198,11 @@ export function createNodeSessionControlRelay() {
198
198
  const n = node(v.node_id);
199
199
  if (!n || n.network_id !== v.network_id || !human(tokenId, n)) return fail("session_control_unavailable", 403);
200
200
  const b = bridges.get(key(n));
201
- if (!b || b.expires <= now || !bound(b.tokenId, n)) return fail("session_control_unavailable");
201
+ // A distinct code for "this node has no live bridge" (never registered, or
202
+ // its 75 s lease expired). #2014 needs it: the client can then say "upgrade
203
+ // the node / start it" instead of the generic "refresh later" it shows for
204
+ // `session_control_unavailable` (which also covers permission refusals).
205
+ if (!b || b.expires <= now || !bound(b.tokenId, n)) return fail("session_control_bridge_missing");
202
206
  if (v.expected && !same(v.expected as Identity, b)) return fail("session_control_changed");
203
207
  if (entries.size >= CAP || [...entries.values()].some(e => e.nodeId === n.node_id
204
208
  && e.networkId === n.network_id && (e.status === "pending" || e.status === "in_progress"))) {
@@ -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") {