@sleep2agi/commhub-server 0.9.0-preview.112 → 0.9.0-preview.113

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.112",
3
+ "version": "0.9.0-preview.113",
4
4
  "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,294 @@
1
+ // #711 改密码只让浏览器 / app 的登录会话下线,命令行和脚本令牌默认保留。
2
+ //
3
+ // main 上 POST /api/auth/password 跑的是 `DELETE FROM api_tokens WHERE user_id = ? AND network_id IS NULL
4
+ // AND token_id != ?` —— 每台机器上 `anet login` 拿到的令牌、POST /api/auth/tokens 建的具名脚本令牌,
5
+ // 跟浏览器会话一起被删光。安全复审定的形状:默认不变,保留是显式选项。这里每条都钉住:
6
+ // · 默认(旧 app、dashboard 代理不带字段):撤销全部不绑网络的令牌 —— 客户端自报的 cli 也活不下来;
7
+ // · keep_cli_tokens=true(anet passwd 默认带):只撤其他 kind='login' 会话,保留的 cli 令牌列在响应里、不含令牌值;
8
+ // · 当前会话(换发后的新令牌)永远可用,而且沿用当前令牌的种类;
9
+ // · 节点 / 网络令牌永远不动;
10
+ // · 启动迁移按 client_label / scope 回填存量令牌的 kind(夹具库,跑两遍幂等)。
11
+ // SQLite 由 test798 自动收进;PostgreSQL 由 tests/test2123-hub-postgres-ladder 的 run_pg_tests_rc 跑同一个文件。
12
+
13
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
14
+ import { mkdtempSync, rmSync } from "fs";
15
+ import { join } from "path";
16
+ import { tmpdir } from "os";
17
+ import { spawnSync } from "child_process";
18
+ import { register, createNetworkTokenForNode, createToken, changePassword } from "./auth.js";
19
+ import { db, hashToken } from "./db.js";
20
+
21
+ const DIR = mkdtempSync(join(tmpdir(), "anet-pwchange-kind-"));
22
+ let BASE = "";
23
+ let hub: any = null;
24
+ const PW = "PwChangeKindPassw0rd!";
25
+ const PW2 = "PwChangeKindPassw0rd!2";
26
+ const CLI_LABEL = "anet 2.5.0 · test-box · login";
27
+
28
+ async function call(token: string, method: string, path: string, body?: unknown) {
29
+ const res = await fetch(`${BASE}${path}`, {
30
+ method,
31
+ headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body === undefined ? {} : { "Content-Type": "application/json" }) },
32
+ body: body === undefined ? undefined : JSON.stringify(body),
33
+ });
34
+ return { status: res.status, body: await res.json().catch(() => null) as any };
35
+ }
36
+ const works = async (token: string) => (await call(token, "GET", "/api/auth/me")).status === 200;
37
+ const kindOf = (token: string) => db.get<any>("SELECT kind FROM api_tokens WHERE token_hash = ?1", hashToken(token))?.kind ?? null;
38
+
39
+ type Fleet = {
40
+ user: string; userId: string; net: string;
41
+ app: string; appB: string; regToken: string;
42
+ cliByLabel: string; cliByKind: string; apiToken: string;
43
+ nodeToken: string; netToken: string;
44
+ };
45
+
46
+ let seq = 0;
47
+ async function fleet(): Promise<Fleet> {
48
+ const user = `pwk_${Date.now()}_${++seq}`;
49
+ const reg = register(user, PW);
50
+ expect(reg.ok).toBe(true);
51
+ const login = async (extra: Record<string, unknown>) => {
52
+ const r = await call("", "POST", "/api/auth/login", { username: user, password: PW, ...extra });
53
+ expect(r.status).toBe(200);
54
+ return r.body.token as string;
55
+ };
56
+ const node = createNetworkTokenForNode(reg.user!.user_id, reg.network_id!, "pwk-node", `node_pwk_${seq}`);
57
+ expect(node.ok).toBe(true);
58
+ const api = createToken(reg.user!.user_id, "ci-script");
59
+ expect(api.ok).toBe(true);
60
+ const netTok = createToken(reg.user!.user_id, "net-script", reg.network_id!);
61
+ expect(netTok.ok).toBe(true);
62
+ return {
63
+ user, userId: reg.user!.user_id, net: reg.network_id!,
64
+ regToken: reg.token!,
65
+ app: await login({ client_label: "Desktop app" }),
66
+ appB: await login({}),
67
+ cliByLabel: await login({ client_label: CLI_LABEL }),
68
+ cliByKind: await login({ client_kind: "cli" }),
69
+ apiToken: api.token!,
70
+ nodeToken: node.token!,
71
+ netToken: netTok.token!,
72
+ };
73
+ }
74
+
75
+ beforeAll(async () => {
76
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
77
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
78
+ process.env.HOST = "127.0.0.1";
79
+ process.env.COMMHUB_LOGIN_IP_MAX = "1000";
80
+ const mod: any = await import("./server.js");
81
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
82
+ BASE = `http://127.0.0.1:${hub.port}`;
83
+ }, 30_000);
84
+
85
+ afterAll(() => {
86
+ try { hub?.stop?.(true); } catch {}
87
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
88
+ });
89
+
90
+ describe("new tokens are classified at issue time", () => {
91
+ test("browser/app logins are 'login'; anet-labelled, client_kind=cli and named API tokens are 'cli'; node tokens unclassified", async () => {
92
+ const f = await fleet();
93
+ expect(kindOf(f.regToken)).toBe("login");
94
+ expect(kindOf(f.app)).toBe("login");
95
+ expect(kindOf(f.appB)).toBe("login");
96
+ expect(kindOf(f.cliByLabel)).toBe("cli");
97
+ expect(kindOf(f.cliByKind)).toBe("cli");
98
+ expect(kindOf(f.apiToken)).toBe("cli");
99
+ expect(kindOf(f.nodeToken)).toBe(null);
100
+ expect(kindOf(f.netToken)).toBe(null);
101
+ });
102
+ });
103
+
104
+ // 响应里出现任何令牌值都算泄漏:换发给调用方的那一条(body.token)除外,它本来就要交给调用方。
105
+ function assertNoTokenValues(body: any, f: Fleet) {
106
+ const { token: _rotated, ...rest } = body;
107
+ const text = JSON.stringify(rest);
108
+ for (const t of [f.regToken, f.app, f.appB, f.cliByLabel, f.cliByKind, f.apiToken, f.nodeToken, f.netToken]) {
109
+ expect(text.includes(t)).toBe(false);
110
+ }
111
+ expect(/\b(utok|atok|ntok)_[A-Za-z0-9]{8,}/.test(text)).toBe(false);
112
+ expect(text.includes("token_hash")).toBe(false);
113
+ }
114
+
115
+ describe("POST /api/auth/password (default: old apps, dashboard proxy — no field)", () => {
116
+ test("every other non-network token is revoked (pre-#711 behaviour); node tokens and the rotated current session survive", async () => {
117
+ const f = await fleet();
118
+ const r = await call(f.app, "POST", "/api/auth/password", { old_password: PW, new_password: PW2 });
119
+ expect(r.status).toBe(200);
120
+ expect(r.body.ok).toBe(true);
121
+ expect(r.body.revoked_login).toBe(2); // reg + appB
122
+ expect(r.body.revoked_cli).toBe(3); // cliByLabel + cliByKind + apiToken
123
+ expect(r.body.revoked).toBe(5);
124
+ expect(r.body.kept_cli_tokens).toBeUndefined();
125
+ for (const t of [f.regToken, f.appB, f.cliByLabel, f.cliByKind, f.apiToken]) expect(await works(t)).toBe(false);
126
+ expect(await works(f.nodeToken)).toBe(true);
127
+ expect(await works(f.netToken)).toBe(true);
128
+ expect(await works(r.body.token)).toBe(true);
129
+ expect(kindOf(r.body.token)).toBe("login");
130
+ expect(await works(f.app)).toBe(false);
131
+ });
132
+
133
+ test("a client-declared cli token (client_kind / anet label) does NOT survive a default password change", async () => {
134
+ const f = await fleet();
135
+ expect(kindOf(f.cliByKind)).toBe("cli");
136
+ const r = await call(f.app, "POST", "/api/auth/password", { old_password: PW, new_password: PW2 });
137
+ expect(r.status).toBe(200);
138
+ expect(await works(f.cliByKind)).toBe(false);
139
+ expect(await works(f.cliByLabel)).toBe(false);
140
+ });
141
+
142
+ test("a non-boolean keep_cli_tokens is not an opt-in; the removed revoke_cli_tokens field does nothing", async () => {
143
+ const f = await fleet();
144
+ const r = await call(f.app, "POST", "/api/auth/password", { old_password: PW, new_password: PW2, keep_cli_tokens: "true", revoke_cli_tokens: false });
145
+ expect(r.status).toBe(200);
146
+ expect(r.body.revoked_cli).toBe(3);
147
+ expect(await works(f.cliByLabel)).toBe(false);
148
+ expect(await works(f.apiToken)).toBe(false);
149
+ });
150
+ });
151
+
152
+ describe("POST /api/auth/password with keep_cli_tokens=true (anet passwd)", () => {
153
+ test("only other login sessions are revoked; CLI/script tokens are kept and listed without token values", async () => {
154
+ const f = await fleet();
155
+ const r = await call(f.cliByLabel, "POST", "/api/auth/password", { old_password: PW, new_password: PW2, keep_cli_tokens: true });
156
+ expect(r.status).toBe(200);
157
+ expect(r.body.revoked_login).toBe(3); // reg + app + appB
158
+ expect(r.body.revoked_cli).toBe(0);
159
+ expect(r.body.revoked).toBe(3);
160
+ for (const t of [f.regToken, f.app, f.appB]) expect(await works(t)).toBe(false);
161
+ expect(await works(f.cliByKind)).toBe(true);
162
+ expect(await works(f.apiToken)).toBe(true);
163
+ expect(await works(f.nodeToken)).toBe(true);
164
+ expect(await works(f.netToken)).toBe(true);
165
+ // 当前会话:换发的新令牌可用,沿用 cli
166
+ expect(await works(r.body.token)).toBe(true);
167
+ expect(kindOf(r.body.token)).toBe("cli");
168
+ // 保留清单:不含当前这条(它已被换掉),只含另外两条 cli;字段齐全、无令牌值
169
+ const kept = r.body.kept_cli_tokens as any[];
170
+ const idOf = (t: string) => db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash = ?1", hashToken(t)).token_id;
171
+ expect(kept.map((k) => k.token_id).sort()).toEqual([idOf(f.cliByKind), idOf(f.apiToken)].sort());
172
+ for (const k of kept) {
173
+ expect(Object.keys(k).sort()).toEqual(["client_label", "created_at", "last_used_at", "name", "token_id"]);
174
+ expect(k.token_id).toMatch(/^tok_[0-9a-f]{12}$/);
175
+ }
176
+ expect(kept.find((k) => k.token_id === idOf(f.apiToken)).name).toBe("ci-script");
177
+ assertNoTokenValues(r.body, f);
178
+ // 可以逐条撤销
179
+ expect((await call(r.body.token, "DELETE", `/api/auth/tokens/${idOf(f.apiToken)}`)).status).toBe(200);
180
+ expect(await works(f.apiToken)).toBe(false);
181
+ });
182
+
183
+ test("default response carries no token values either", async () => {
184
+ const f = await fleet();
185
+ const r = await call(f.app, "POST", "/api/auth/password", { old_password: PW, new_password: PW2 });
186
+ expect(r.status).toBe(200);
187
+ assertNoTokenValues(r.body, f);
188
+ });
189
+ });
190
+
191
+ describe("changePassword() keeps the current token row in both modes", () => {
192
+ test("no opts at all = default: CLI tokens revoked, current kept", async () => {
193
+ const f = await fleet();
194
+ const cur = db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash = ?1", hashToken(f.app)).token_id;
195
+ const r = changePassword(f.userId, PW, PW2, cur);
196
+ expect(r.ok).toBe(true);
197
+ expect(r.revoked_cli).toBe(3);
198
+ expect(r.kept_cli_tokens).toBeUndefined();
199
+ expect(await works(f.app)).toBe(true);
200
+ expect(await works(f.cliByKind)).toBe(false);
201
+ expect(await works(f.apiToken)).toBe(false);
202
+ });
203
+
204
+ for (const keepCliTokens of [false, true]) {
205
+ test(`keepCliTokens=${keepCliTokens}: current login token kept`, async () => {
206
+ const f = await fleet();
207
+ const cur = db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash = ?1", hashToken(f.app)).token_id;
208
+ const r = changePassword(f.userId, PW, PW2, cur, { keepCliTokens });
209
+ expect(r.ok).toBe(true);
210
+ expect(await works(f.app)).toBe(true);
211
+ expect(await works(f.appB)).toBe(false);
212
+ expect(await works(f.cliByLabel)).toBe(keepCliTokens);
213
+ expect(await works(f.nodeToken)).toBe(true);
214
+ });
215
+ test(`keepCliTokens=${keepCliTokens}: current CLI token kept`, async () => {
216
+ const f = await fleet();
217
+ const cur = db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash = ?1", hashToken(f.cliByKind)).token_id;
218
+ const r = changePassword(f.userId, PW, PW2, cur, { keepCliTokens });
219
+ expect(r.ok).toBe(true);
220
+ expect(await works(f.cliByKind)).toBe(true);
221
+ expect(await works(f.app)).toBe(false);
222
+ expect(await works(f.apiToken)).toBe(keepCliTokens);
223
+ });
224
+ }
225
+ });
226
+
227
+ describe("startup migration backfills api_tokens.kind on a fixture DB", () => {
228
+ // 把 kind 列拿掉 = 回到 #711 之前的表结构;塞存量行;再起一个进程加载 db.ts(= Hub 启动迁移)。
229
+ function bootMigration() {
230
+ const r = spawnSync(process.execPath, ["-e", `await import(${JSON.stringify(join(import.meta.dir, "db.ts"))}); process.exit(0);`], {
231
+ cwd: join(import.meta.dir, ".."),
232
+ env: { ...process.env, NODE_ENV: "test" },
233
+ encoding: "utf8",
234
+ timeout: 60_000,
235
+ });
236
+ if (r.status !== 0) console.log(String(r.stderr || "").slice(-2000));
237
+ return r.status;
238
+ }
239
+
240
+ test("anet-labelled and named API tokens → cli, everything else → login, node tokens untouched; idempotent", async () => {
241
+ const f = await fleet();
242
+ db.exec("ALTER TABLE api_tokens DROP COLUMN kind");
243
+ const ins = (id: string, net: string | null, scope: string, label: string | null) =>
244
+ db.run(
245
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, client_label, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, 'fixture', ?5, ?6, 1)",
246
+ [id, hashToken(`fixture-${id}`), f.userId, net, scope, label],
247
+ );
248
+ const id = (s: string) => `tok_fx_${seq}_${s}`;
249
+ ins(id("cli_label"), null, "user", "anet 2.4.0 · old-box · login");
250
+ ins(id("cli_demo"), null, "user", "anet dev · unknown-host · demo sci-team");
251
+ ins(id("browser"), null, "user", "Chrome on macOS");
252
+ ins(id("nolabel"), null, "user", null);
253
+ ins(id("lookalike"), null, "user", "planet-anet dashboard");
254
+ ins(id("upper"), null, "user", "ANET 2.4.0 · box · login");
255
+ ins(id("padded"), null, "user", " anet 2.4.0 · box · login");
256
+ ins(id("named"), null, "full", null);
257
+ ins(id("node"), f.net, "network", null);
258
+ ins(id("netfull"), f.net, "full", null);
259
+
260
+ expect(bootMigration()).toBe(0);
261
+ const k = (s: string) => db.get<any>("SELECT kind FROM api_tokens WHERE token_id = ?1", id(s))?.kind ?? null;
262
+ expect(k("cli_label")).toBe("cli");
263
+ expect(k("cli_demo")).toBe("cli");
264
+ expect(k("browser")).toBe("login");
265
+ expect(k("nolabel")).toBe("login");
266
+ expect(k("lookalike")).toBe("login");
267
+ expect(k("upper")).toBe("login"); // 区分大小写:SQLite 的 LIKE 会把它当 cli,PG 不会 —— 两边必须一致
268
+ expect(k("padded")).toBe("cli"); // 与运行时一样先 trim
269
+ expect(k("named")).toBe("cli");
270
+ expect(k("node")).toBe(null);
271
+ expect(k("netfull")).toBe(null);
272
+ // fleet() 的 live 令牌也被回填(列重建后原值丢了,按同一规则算回来)
273
+ expect(kindOf(f.cliByLabel)).toBe("cli");
274
+ expect(kindOf(f.apiToken)).toBe("cli");
275
+ expect(kindOf(f.app)).toBe("login");
276
+
277
+ // 幂等:已有值不被改写(手动改成 login 的 anet 行保持 login),再跑一次结果不变
278
+ db.run("UPDATE api_tokens SET kind = 'login' WHERE token_id = ?1", [id("cli_label")]);
279
+ expect(bootMigration()).toBe(0);
280
+ expect(k("cli_label")).toBe("login");
281
+ expect(k("named")).toBe("cli");
282
+ expect(k("browser")).toBe("login");
283
+
284
+ // 迁移后的库上,keep_cli_tokens 保留回填成 cli 的存量令牌
285
+ const cur = db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash = ?1", hashToken(f.app)).token_id;
286
+ const r = changePassword(f.userId, PW, PW2, cur, { keepCliTokens: true });
287
+ expect(r.ok).toBe(true);
288
+ expect(k("named")).toBe("cli");
289
+ expect(k("cli_demo")).toBe("cli");
290
+ expect(k("browser")).toBe(null); // 被撤销(行已删除)
291
+ expect(k("node")).toBe(null);
292
+ expect(Number(db.get<any>("SELECT COUNT(*) AS n FROM api_tokens WHERE token_id = ?1", id("node")).n)).toBe(1);
293
+ }, 120_000);
294
+ });
package/src/auth.ts CHANGED
@@ -137,8 +137,8 @@ export function register(username: string, password: string, email?: string, dis
137
137
  const userToken = generateUserToken();
138
138
  const userTokenId = generateId("tok");
139
139
  db.run(
140
- "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, client_label, user_agent, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1)",
141
- [userTokenId, hashToken(userToken), userId, null, "user-login", "user", cleanClientField(opts.client?.label, 64), cleanClientField(opts.client?.userAgent, 256)]
140
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, client_label, user_agent, kind, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 1)",
141
+ [userTokenId, hashToken(userToken), userId, null, "user-login", "user", cleanClientField(opts.client?.label, 64), cleanClientField(opts.client?.userAgent, 256), userTokenKindFor(opts.client)]
142
142
  );
143
143
 
144
144
  // Network token (ntok_) — bound to default network, for agent-node
@@ -217,8 +217,8 @@ export function login(username: string, password: string, client: SessionClientI
217
217
  const userToken = generateUserToken();
218
218
  const tokenId = generateId("tok");
219
219
  db.run(
220
- "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, client_label, user_agent, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 1)",
221
- [tokenId, hashToken(userToken), user.user_id, null, "user-login", "user", cleanClientField(client.label, 64), cleanClientField(client.userAgent, 256)]
220
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, client_label, user_agent, kind, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, 1)",
221
+ [tokenId, hashToken(userToken), user.user_id, null, "user-login", "user", cleanClientField(client.label, 64), cleanClientField(client.userAgent, 256), userTokenKindFor(client)]
222
222
  );
223
223
 
224
224
  // Find default network
@@ -321,7 +321,28 @@ const SESSION_TOKEN_SQL = "scope = 'user' AND network_id IS NULL";
321
321
  // 以小时为粒度对一个以天计的闲置窗口没有影响。
322
322
  const LAST_USED_WRITE_INTERVAL_SECONDS = 3600;
323
323
 
324
- export type SessionClientInfo = { label?: unknown; userAgent?: unknown };
324
+ export type SessionClientInfo = { label?: unknown; userAgent?: unknown; kind?: unknown };
325
+
326
+ // #711 不绑网络的用户令牌的种类(api_tokens.kind):
327
+ // 'login' — 浏览器 / app 的登录会话;
328
+ // 'cli' — anet 命令行登录、POST /api/auth/tokens 建的具名脚本令牌。
329
+ // 种类由客户端自报(client_kind / client_label),**只用于展示和「显式保留」那条路**:
330
+ // 改密码默认仍撤销全部不绑网络的令牌(当前这条除外),调用方显式 keep_cli_tokens=true 才保留 'cli'。
331
+ // 所以一个自称 cli 的令牌永远不能靠自报在默认改密码里活下来。
332
+ // 节点 / 网络令牌(network_id 非空)不分类,改密码永远不碰。
333
+ export type UserTokenKind = "login" | "cli";
334
+
335
+ /** 登录 / 注册请求签发的令牌算哪一类:客户端显式报 kind='cli',或 client_label 是 anet 自报的「anet <版本> · …」。 */
336
+ /** anet 自报的 client_label:去掉首尾空白后,区分大小写地以「anet 」开头(启动回填用同一条规则,见 db.ts)。 */
337
+ export function isAnetClientLabel(label: string): boolean {
338
+ return label.trim().startsWith("anet ");
339
+ }
340
+
341
+ export function userTokenKindFor(client: SessionClientInfo = {}): UserTokenKind {
342
+ if (client.kind === "cli") return "cli";
343
+ if (typeof client.label === "string" && isAnetClientLabel(client.label)) return "cli";
344
+ return "login";
345
+ }
325
346
 
326
347
  function cleanClientField(value: unknown, max: number): string | null {
327
348
  if (typeof value !== "string") return null;
@@ -508,9 +529,10 @@ export function createToken(userId: string, name: string, networkId?: string): {
508
529
  }
509
530
  const token = generateToken();
510
531
  const tokenId = generateId("tok");
532
+ // #711 不绑网络的具名令牌是给脚本 / 命令行长期用的:kind='cli'(改密码时可显式 keep_cli_tokens 保留)。
511
533
  db.run(
512
- "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 1)",
513
- [tokenId, hashToken(token), userId, networkId || null, name, "full"]
534
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, kind, node_identity_epoch) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, 1)",
535
+ [tokenId, hashToken(token), userId, networkId || null, name, "full", networkId ? null : "cli"]
514
536
  );
515
537
  return { ok: true, token, token_id: tokenId };
516
538
  }
@@ -520,16 +542,55 @@ export function revokeToken(userId: string, tokenId: string): { ok: boolean; err
520
542
  return result.changes > 0 ? { ok: true } : { ok: false, error: "token not found" };
521
543
  }
522
544
 
523
- export function issueUserToken(userId: string, name = "user-login"): { token: string; token_id: string } {
545
+ export function issueUserToken(
546
+ userId: string,
547
+ name = "user-login",
548
+ opts: { kind?: UserTokenKind; clientLabel?: string | null; userAgent?: string | null } = {},
549
+ ): { token: string; token_id: string } {
524
550
  const token = generateUserToken();
525
551
  const tokenId = generateId("tok");
526
552
  db.run(
527
- "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, node_identity_epoch) VALUES (?1, ?2, ?3, NULL, ?4, 'user', 1)",
528
- [tokenId, hashToken(token), userId, name]
553
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope, client_label, user_agent, kind, node_identity_epoch) VALUES (?1, ?2, ?3, NULL, ?4, 'user', ?5, ?6, ?7, 1)",
554
+ [tokenId, hashToken(token), userId, name, opts.clientLabel ?? null, opts.userAgent ?? null, opts.kind === "cli" ? "cli" : "login"]
529
555
  );
530
556
  return { token, token_id: tokenId };
531
557
  }
532
558
 
559
+ /** 一条令牌的种类和设备标识(改密码换发新令牌时沿用,命令行的令牌换完还是命令行的)。 */
560
+ export function getUserTokenMeta(userId: string, tokenId: string): { kind: UserTokenKind; client_label: string | null; user_agent: string | null } | null {
561
+ const row = db.get<any>("SELECT kind, client_label, user_agent FROM api_tokens WHERE token_id = ?1 AND user_id = ?2 AND network_id IS NULL", tokenId, userId);
562
+ if (!row) return null;
563
+ return { kind: row.kind === "cli" ? "cli" : "login", client_label: row.client_label ?? null, user_agent: row.user_agent ?? null };
564
+ }
565
+
566
+ // #711 改密码撤销哪些令牌。kind 为 NULL 的(启动回填之后由旧版 Hub 签出的)按 'login' 算。
567
+ const CLI_KIND_SQL = "kind = 'cli'";
568
+ const NOT_CLI_KIND_SQL = "COALESCE(kind, 'login') <> 'cli'";
569
+
570
+ /** 改密码后保留下来的命令行 / 脚本令牌(给 UI 列出来、让用户逐条撤销)。**永远不含令牌值。** */
571
+ export type KeptCliToken = { token_id: string; name: string; client_label: string | null; created_at: string; last_used_at: string | null };
572
+
573
+ /**
574
+ * 改密码后的令牌撤销。默认(keepCli=false)= 改密码前的行为:撤销全部不绑网络的令牌;
575
+ * keepCli=true 时保留 kind='cli' 的,并把它们列出来。当前这条(exceptTokenId)和节点 / 网络令牌永远不动。
576
+ */
577
+ export function revokeTokensAfterPasswordChange(userId: string, exceptTokenId: string | null | undefined, keepCli: boolean): { login: number; cli: number; kept: KeptCliToken[] } {
578
+ const except = exceptTokenId ? " AND token_id != ?2" : "";
579
+ const params = exceptTokenId ? [userId, exceptTokenId] : [userId];
580
+ const login = db.run(`DELETE FROM api_tokens WHERE user_id = ?1 AND network_id IS NULL AND ${NOT_CLI_KIND_SQL}${except}`, params).changes;
581
+ if (!keepCli) {
582
+ const cli = db.run(`DELETE FROM api_tokens WHERE user_id = ?1 AND network_id IS NULL${except}`, params).changes;
583
+ return { login, cli, kept: [] };
584
+ }
585
+ const kept = db.all<any>(
586
+ `SELECT token_id, name, client_label, created_at, last_used_at FROM api_tokens
587
+ WHERE user_id = ?1 AND network_id IS NULL AND ${CLI_KIND_SQL}${except}
588
+ ORDER BY created_at DESC`,
589
+ ...params,
590
+ ).map((r) => ({ token_id: r.token_id, name: r.name, client_label: r.client_label ?? null, created_at: r.created_at, last_used_at: r.last_used_at ?? null }));
591
+ return { login, cli: 0, kept };
592
+ }
593
+
533
594
  export function revokeOtherUserTokens(userId: string, exceptTokenId?: string | null): number {
534
595
  const result = exceptTokenId
535
596
  ? db.run("DELETE FROM api_tokens WHERE user_id = ?1 AND network_id IS NULL AND token_id != ?2", [userId, exceptTokenId])
@@ -544,6 +605,7 @@ export type LoginSession = {
544
605
  last_used_at: string | null;
545
606
  client_label: string | null;
546
607
  user_agent: string | null;
608
+ kind: UserTokenKind;
547
609
  is_current: boolean;
548
610
  };
549
611
 
@@ -551,7 +613,7 @@ export type LoginSession = {
551
613
  export function listSessions(userId: string, currentTokenId: string | null): LoginSession[] {
552
614
  const cutoff = idleCutoffOffset();
553
615
  const rows = db.all<any>(
554
- `SELECT token_id, name, created_at, last_used_at, client_label, user_agent FROM api_tokens
616
+ `SELECT token_id, name, created_at, last_used_at, client_label, user_agent, kind FROM api_tokens
555
617
  WHERE user_id = ?1 AND ${SESSION_TOKEN_SQL} AND revoked_at IS NULL${cutoff ? `
556
618
  AND COALESCE(last_used_at, created_at) >= datetime('now', ?2)` : ""}
557
619
  ORDER BY COALESCE(last_used_at, created_at) DESC, created_at DESC`,
@@ -563,6 +625,7 @@ export function listSessions(userId: string, currentTokenId: string | null): Log
563
625
  last_used_at: r.last_used_at ?? null,
564
626
  client_label: r.client_label ?? null,
565
627
  user_agent: r.user_agent ?? null,
628
+ kind: r.kind === "cli" ? "cli" : "login",
566
629
  is_current: !!currentTokenId && r.token_id === currentTokenId,
567
630
  }));
568
631
  }
@@ -578,7 +641,14 @@ export function revokeOtherSessions(userId: string, currentTokenId: string): num
578
641
  return db.run(`DELETE FROM api_tokens WHERE user_id = ?1 AND ${SESSION_TOKEN_SQL} AND token_id != ?2`, [userId, currentTokenId]).changes;
579
642
  }
580
643
 
581
- export function changePassword(userId: string, oldPassword: string, newPassword: string, currentTokenId?: string | null): { ok: boolean; error?: string; revoked?: number } {
644
+ export type ChangePasswordResult = { ok: boolean; error?: string; revoked?: number; revoked_login?: number; revoked_cli?: number; kept_cli_tokens?: KeptCliToken[] };
645
+
646
+ /**
647
+ * 改密码。默认撤销该用户全部不绑网络的令牌(改密码前的行为,旧 app 依赖它)。#711:opts.keepCliTokens=true
648
+ * 时保留 kind='cli' 的(anet 命令行登录、具名脚本令牌),并在 kept_cli_tokens 里列出。当前会话和节点令牌永远保留。
649
+ * revoked = 两类合计(旧字段,兼容旧客户端)。
650
+ */
651
+ export function changePassword(userId: string, oldPassword: string, newPassword: string, currentTokenId?: string | null, opts: { keepCliTokens?: boolean } = {}): ChangePasswordResult {
582
652
  const passwordError = validatePasswordStrength(newPassword, "new password");
583
653
  if (passwordError) return { ok: false, error: passwordError };
584
654
  const user = db.get<any>("SELECT password_hash FROM users WHERE user_id = ?1", userId);
@@ -593,8 +663,9 @@ export function changePassword(userId: string, oldPassword: string, newPassword:
593
663
  // nudge goes away on next login. SET to 0 explicitly (rather than skip)
594
664
  // so a future flag flip can't drift the state.
595
665
  db.run("UPDATE users SET password_hash = ?1, must_change_password = 0, updated_at = datetime('now') WHERE user_id = ?2", [hashPassword(newPassword), userId]);
596
- const revoked = revokeOtherUserTokens(userId, currentTokenId);
597
- return { ok: true, revoked };
666
+ const keep = opts.keepCliTokens === true;
667
+ const r = revokeTokensAfterPasswordChange(userId, currentTokenId, keep);
668
+ return { ok: true, revoked: r.login + r.cli, revoked_login: r.login, revoked_cli: r.cli, ...(keep ? { kept_cli_tokens: r.kept } : {}) };
598
669
  }
599
670
 
600
671
  /**
package/src/db.ts CHANGED
@@ -646,12 +646,26 @@ for (const ddl of [
646
646
  // 登录会话的设备标识(GET /api/auth/sessions 显示用):客户端自报的 client_label + 登录请求的 User-Agent。
647
647
  "ALTER TABLE api_tokens ADD COLUMN client_label TEXT",
648
648
  "ALTER TABLE api_tokens ADD COLUMN user_agent TEXT",
649
+ // #711 不绑网络的用户令牌分两类:'login' = 浏览器 / app 的登录会话;'cli' = anet 命令行登录、
650
+ // POST /api/auth/tokens 建的具名脚本令牌。只用于展示和 keep_cli_tokens 那条显式保留路径;节点 / 网络令牌(network_id 非空)不填。
651
+ "ALTER TABLE api_tokens ADD COLUMN kind TEXT",
649
652
  ]) {
650
653
  try { db.exec(ddl); }
651
654
  catch (e: any) {
652
655
  if (!/duplicate column|already exists/i.test(e?.message || "")) throw e;
653
656
  }
654
657
  }
658
+ // #711 回填 kind:只碰 kind 还是 NULL 的非网络令牌(幂等;回滚到旧 Hub 期间新签的行,下次启动照样补上)。
659
+ // 能明确认出是 anet 命令行的才算 'cli':scope='full'(POST /api/auth/tokens 建的具名令牌),
660
+ // 或 client_label 去掉首尾空格后以「anet 」开头(anet 自报的「anet <版本> · <主机> · <子命令>」)。其余一律 'login'。
661
+ // 用 SUBSTR + = 而不是 LIKE:SQLite 的 LIKE 不分大小写、PG 分,两边结果会不一样;= 两边都区分大小写,
662
+ // 与运行时 isAnetClientLabel(auth.ts)同一条规则。
663
+ db.run(
664
+ `UPDATE api_tokens SET kind = CASE
665
+ WHEN scope = 'full' OR SUBSTR(TRIM(client_label), 1, 5) = 'anet ' THEN 'cli'
666
+ ELSE 'login' END
667
+ WHERE network_id IS NULL AND kind IS NULL`,
668
+ );
655
669
  try { db.exec(`CREATE INDEX IF NOT EXISTS idx_tokens_request ON api_tokens(request_id)`); }
656
670
  catch (e: any) { /* index may already exist */ void e; }
657
671
  try { db.exec(`CREATE INDEX IF NOT EXISTS idx_tokens_bound_node ON api_tokens(bound_node_id)`); }
@@ -969,7 +983,7 @@ db.exec(`
969
983
  requirement_id TEXT PRIMARY KEY,
970
984
  network_id TEXT NOT NULL,
971
985
  title TEXT NOT NULL,
972
- column_name TEXT NOT NULL DEFAULT 'pool' CHECK(column_name IN ('pool', 'doing', 'done')),
986
+ column_name TEXT NOT NULL DEFAULT 'pool' CHECK(column_name IN ('pool', 'doing', 'done', 'abandoned')),
973
987
  priority TEXT NOT NULL DEFAULT 'normal' CHECK(priority IN ('high', 'normal', 'low', 'lowest')),
974
988
  due_on TEXT,
975
989
  assignee TEXT,
@@ -227,7 +227,7 @@ export function dueReminderFor(due: string, nowMs: number, tz: string, maxOverdu
227
227
  export function overdueSql(params: unknown[], nowMs: number, tz: string): string {
228
228
  const today = localDate(nowMs, tz);
229
229
  const nowIso = new Date(nowMs).toISOString().replace(/\.\d{3}Z$/, "Z");
230
- return `(due_on IS NOT NULL AND column_name <> 'done' AND ((length(due_on) = 10 AND due_on < ?${params.push(today)}) OR (length(due_on) > 10 AND due_on <= ?${params.push(nowIso)})))`;
230
+ return `(due_on IS NOT NULL AND column_name NOT IN ('done', 'abandoned') AND ((length(due_on) = 10 AND due_on < ?${params.push(today)}) OR (length(due_on) > 10 AND due_on <= ?${params.push(nowIso)})))`;
231
231
  }
232
232
  /** 没逾期、且在 tz 的「今天 + days」那天结束之前到期(0 = 今天内)。 */
233
233
  export function dueWithinSql(params: unknown[], days: number, nowMs: number, tz: string): string {
@@ -235,7 +235,7 @@ export function dueWithinSql(params: unknown[], days: number, nowMs: number, tz:
235
235
  const lastDay = addDays(today, days);
236
236
  const endIso = new Date(localMidnightMs(addDays(lastDay, 1), tz)).toISOString().replace(/\.\d{3}Z$/, "Z");
237
237
  const nowIso = new Date(nowMs).toISOString().replace(/\.\d{3}Z$/, "Z");
238
- return `(due_on IS NOT NULL AND column_name <> 'done' AND ((length(due_on) = 10 AND due_on >= ?${params.push(today)} AND due_on <= ?${params.push(lastDay)}) OR (length(due_on) > 10 AND due_on > ?${params.push(nowIso)} AND due_on < ?${params.push(endIso)})))`;
238
+ return `(due_on IS NOT NULL AND column_name NOT IN ('done', 'abandoned') AND ((length(due_on) = 10 AND due_on >= ?${params.push(today)} AND due_on <= ?${params.push(lastDay)}) OR (length(due_on) > 10 AND due_on > ?${params.push(nowIso)} AND due_on < ?${params.push(endIso)})))`;
239
239
  }
240
240
 
241
241
  // ── 扫描 ───────────────────────────────────────────────────
@@ -332,7 +332,7 @@ export function runDueReminders(opts: { now?: number } = {}): DueReminderSent[]
332
332
  const rows = db.all<DueRow>(
333
333
  `SELECT requirement_id, network_id, seq, title, due_on, owner_json, participants_json, agent_owner_json
334
334
  FROM requirements
335
- WHERE due_on IS NOT NULL AND due_on >= ?1 AND due_on < ?2 AND column_name <> 'done' AND COALESCE(archived, 0) = 0${netFilter}${ownerFilter}`,
335
+ WHERE due_on IS NOT NULL AND due_on >= ?1 AND due_on < ?2 AND column_name NOT IN ('done', 'abandoned') AND COALESCE(archived, 0) = 0${netFilter}${ownerFilter}`,
336
336
  ...params,
337
337
  );
338
338
  const out: DueReminderSent[] = [];
@@ -12,7 +12,7 @@ import { isParticipant } from "./task-access.js";
12
12
  import { rewriteUnreadNoticeDm, sendNoticeDm } from "./human-dm.js";
13
13
 
14
14
  export const COALESCE_WINDOW_MS = 60_000;
15
- const COLUMN_LABEL: Record<string, string> = { pool: "需求池", doing: "进行中", done: "完成" };
15
+ const COLUMN_LABEL: Record<string, string> = { pool: "需求池", doing: "进行中", done: "完成", abandoned: "废弃" };
16
16
  const MAX_LINES = 20;
17
17
  const MAX_ITEM_CHARS = 40;
18
18
 
@@ -0,0 +1,236 @@
1
+ // 任务状态「废弃」(abandoned)—— HTTP 集成测试(真实 Bun.serve,私有端口,临时库)。
2
+ // test2123 在真实 PostgreSQL 上原样再跑一遍(COMMHUB_TEST_PG_URL)。
3
+ //
4
+ // 废弃是关闭态(同 done):不算开着、不逾期、不发到期提醒、不记完成时间;改进 / 改出都记动态;能改回 pool / doing。
5
+ // 旧 App(desktop ≤ 0.2.220)把不认识的状态当 pool —— 所以没声明 `X-Anet-Accept-Columns: abandoned`
6
+ // (或 `accept_columns=abandoned`)的调用者看到的是 done;MCP 总是声明,看到真值。
7
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8
+ import { mkdtempSync, rmSync } from "node:fs";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+
12
+ const DIR = mkdtempSync(join(tmpdir(), "anet-req-abandoned-"));
13
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
14
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
15
+ process.env.HOST = "127.0.0.1";
16
+ for (const k of ["COMMHUB_DUE_REMINDER_TZ", "COMMHUB_DUE_OVERDUE_MAX_DAYS", "COMMHUB_DUE_REMINDER_NETWORKS", "COMMHUB_DUE_REMINDERS", "COMMHUB_DUE_REMINDERS_NETWORKS", "COMMHUB_DUE_REMINDERS_EXCLUDE_NETWORKS", "COMMHUB_DUE_REMINDERS_OWNERS"]) delete process.env[k];
17
+ const PW = "AbandonedPassw0rd!x";
18
+ const T0 = Date.UTC(2026, 9, 10, 2, 0, 0);
19
+ const DAY = 86_400_000;
20
+ const AWARE = { "X-Anet-Accept-Columns": "abandoned" };
21
+
22
+ let BASE = "";
23
+ let hub: any = null;
24
+ let NET = "";
25
+ let TOKEN = "";
26
+ let OWNER = "";
27
+ let db: any;
28
+ let due: typeof import("./requirement-due-reminders.js");
29
+
30
+ type R = { status: number; body: any };
31
+ async function send(method: string, path: string, payload?: unknown, headers: Record<string, string> = {}): Promise<R> {
32
+ const res = await fetch(`${BASE}${path}`, {
33
+ method,
34
+ headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", ...headers },
35
+ body: payload === undefined ? undefined : JSON.stringify(payload),
36
+ }, 60_000);
37
+ const text = await res.text();
38
+ let body: any = null;
39
+ try { body = JSON.parse(text); } catch {}
40
+ return { status: res.status, body };
41
+ }
42
+ async function mcp(name: string, args: Record<string, unknown>): Promise<any> {
43
+ const res = await fetch(`${BASE}/mcp`, {
44
+ method: "POST",
45
+ headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", Accept: "application/json, text/event-stream", "MCP-Protocol-Version": "2025-03-26" },
46
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name, arguments: args } }),
47
+ }, 60_000);
48
+ const raw = await res.text();
49
+ const lines = raw.split("\n").filter(x => x.startsWith("data:"));
50
+ const payload = lines.length ? JSON.parse(lines.at(-1)!.slice(5).trim()) : JSON.parse(raw);
51
+ return JSON.parse(payload.result.content[0].text);
52
+ }
53
+ async function card(name: string, extra: Record<string, unknown> = {}): Promise<string> {
54
+ const r = await send("POST", "/api/requirements", { network_id: NET, name, owner: { kind: "user", id: OWNER }, ...extra });
55
+ expect(r.status).toBe(201);
56
+ return r.body.requirement.id as string;
57
+ }
58
+ const get = (id: string, headers: Record<string, string> = {}) => send("GET", `/api/requirements/${id}?network_id=${NET}`, undefined, headers);
59
+ const patch = (id: string, body: Record<string, unknown>, headers: Record<string, string> = {}) => send("PATCH", `/api/requirements/${id}?network_id=${NET}`, body, headers);
60
+ const list = (qs = "", headers: Record<string, string> = {}) => send("GET", `/api/requirements?network_id=${NET}${qs}`, undefined, headers);
61
+ const ids = (r: R) => (r.body.requirements as any[]).map(x => x.id);
62
+ const columnIn = (r: R, id: string) => (r.body.requirements as any[]).find(x => x.id === id)?.column;
63
+ const sentFor = (out: Array<{ requirement_id: string; kind: string }>, id: string) => out.filter(x => x.requirement_id === id).map(x => x.kind);
64
+ const columnEvents = async (id: string) => ((await send("GET", `/api/requirements/events?network_id=${NET}&requirement_id=${id}`, undefined, AWARE)).body.events as any[])
65
+ .filter(e => e.kind === "changed" && e.field === "column").map(e => [e.old, e.new]).reverse();
66
+
67
+ beforeAll(async () => {
68
+ ({ db } = await import("./db.js"));
69
+ const { register } = await import("./auth.js");
70
+ const r = register(`ab_owner_${Date.now()}`, PW);
71
+ expect(r.ok).toBe(true);
72
+ TOKEN = r.token!;
73
+ OWNER = r.user!.user_id;
74
+ NET = r.network_id!;
75
+ due = await import("./requirement-due-reminders.js");
76
+ const mod: any = await import("./server.js");
77
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
78
+ BASE = `http://127.0.0.1:${hub.port}`;
79
+ due.runDueReminders({ now: T0 - 30 * DAY }); // 到期提醒基线(同 requirement-due-reminders-http.test.ts)
80
+ }, 30_000);
81
+
82
+ afterAll(() => {
83
+ try { hub?.stop?.(true); } catch {}
84
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
85
+ });
86
+
87
+ describe("abandoned column", () => {
88
+ test("schema accepts abandoned; the legacy CHECK migration is idempotent (SQLite rebuild / PG constraint swap)", async () => {
89
+ const { migrateRequirementColumnCheck } = await import("./requirements-migrate.js");
90
+ if (db.dialect === "postgres") {
91
+ // 模拟旧 PG 库:换回旧约束,迁移后 abandoned 能写,再跑一次照样不报错。
92
+ db.exec("ALTER TABLE requirements DROP CONSTRAINT IF EXISTS requirements_column_name_check");
93
+ db.exec("ALTER TABLE requirements ADD CONSTRAINT requirements_column_name_check CHECK(column_name IN ('pool', 'doing', 'done'))");
94
+ expect(migrateRequirementColumnCheck(db).rebuilt).toBe(true);
95
+ // 已经认识 abandoned:不再动表(不是每次启动都删了重加)
96
+ expect(migrateRequirementColumnCheck(db).rebuilt).toBe(false);
97
+ // 加约束失败要响亮地失败,而且是一条语句:旧约束原样留着,不会落到「没约束」
98
+ db.exec("ALTER TABLE requirements DROP CONSTRAINT IF EXISTS requirements_column_name_check");
99
+ db.exec("ALTER TABLE requirements ADD CONSTRAINT requirements_column_name_check CHECK(column_name IN ('pool', 'doing', 'done', 'bogus'))");
100
+ db.run("INSERT INTO requirements (requirement_id, network_id, title, column_name) VALUES ('req_ab_bogus', ?1, 'bogus', 'bogus')", [NET]);
101
+ expect(() => migrateRequirementColumnCheck(db)).toThrow();
102
+ const def = (db.get("SELECT pg_get_constraintdef(c.oid) AS def FROM pg_constraint c JOIN pg_class t ON t.oid = c.conrelid WHERE t.relname = 'requirements' AND c.conname = 'requirements_column_name_check'") as any)?.def ?? "";
103
+ expect(def).toContain("'bogus'");
104
+ db.run("DELETE FROM requirements WHERE requirement_id = 'req_ab_bogus'");
105
+ expect(migrateRequirementColumnCheck(db).rebuilt).toBe(true);
106
+ } else {
107
+ expect(migrateRequirementColumnCheck(db).rebuilt).toBe(false);
108
+ }
109
+ const id = await card("ab-schema");
110
+ expect((await patch(id, { column: "abandoned" }, AWARE)).status).toBe(200);
111
+ expect((await patch(id, { column: "cancelled" }, AWARE)).body.error).toBe("invalid_column");
112
+ expect((await list()).body.capabilities).toContain("column_abandoned");
113
+ }, 60_000);
114
+
115
+ test("set and restore: events recorded each way, no completedAt, restorable to pool and doing", async () => {
116
+ const id = await card("ab-set");
117
+ const a = await patch(id, { column: "abandoned" }, AWARE);
118
+ expect(a.status).toBe(200);
119
+ expect(a.body.requirement.column).toBe("abandoned");
120
+ expect(a.body.requirement.completedAt).toBeNull();
121
+ expect((await patch(id, { column: "doing" }, AWARE)).body.requirement.column).toBe("doing");
122
+ expect((await patch(id, { column: "abandoned" }, AWARE)).body.requirement.column).toBe("abandoned");
123
+ expect((await patch(id, { column: "pool" }, AWARE)).body.requirement.column).toBe("pool");
124
+ expect(await columnEvents(id)).toEqual([["pool", "abandoned"], ["abandoned", "doing"], ["doing", "abandoned"], ["abandoned", "pool"]]);
125
+ // 直接建在废弃列(带声明)也行
126
+ const created = await send("POST", "/api/requirements", { network_id: NET, name: "ab-born", column: "abandoned" }, AWARE);
127
+ expect(created.body.requirement.column).toBe("abandoned");
128
+ }, 60_000);
129
+
130
+ test("closed like done: not overdue, no due reminder, not counted as open in stats; restoring reopens it", async () => {
131
+ // id:到期提醒用假时钟 T0(10-10),到期 10-07 = 逾期 3 天;old:列表的 overdue 筛选用真时钟,到期给一个早就过了的日子。
132
+ const id = await card("ab-overdue", { due: "2026-10-07", column: "doing" });
133
+ const old = await card("ab-overdue-list", { due: "2020-01-01", column: "doing" });
134
+ expect(ids(await list("&overdue=1"))).toContain(old);
135
+ await patch(id, { column: "abandoned" }, AWARE);
136
+ await patch(old, { column: "abandoned" }, AWARE);
137
+ expect(ids(await list("&overdue=1"))).not.toContain(old);
138
+ expect(ids(await list("&overdue=0"))).toContain(old);
139
+ expect(sentFor(due.runDueReminders({ now: T0 }), id)).toEqual([]);
140
+ const stats = (await send("GET", `/api/requirements/stats?network_id=${NET}`)).body.totals;
141
+ const raw = db.all("SELECT column_name FROM requirements WHERE network_id = ?1 AND COALESCE(archived, 0) = 0", NET) as any[];
142
+ const n = (c: string) => raw.filter(r => r.column_name === c).length;
143
+ expect(stats.doing).toBe(n("doing"));
144
+ expect(stats.pool).toBe(n("pool"));
145
+ expect(stats.abandoned).toBe(n("abandoned"));
146
+ expect(n("abandoned")).toBeGreaterThan(0);
147
+ // 子任务进度不把废弃的子任务算进去
148
+ const parent = await card("ab-parent");
149
+ const kid = await card("ab-kid", { parent_id: parent });
150
+ await card("ab-kid2", { parent_id: parent });
151
+ await patch(kid, { column: "abandoned" }, AWARE);
152
+ // 声明了:废弃的子需求不算进度;没声明(旧 App 把它当 done 列出来):算进总数、算作完成,和它看到的列表对得上
153
+ expect((await get(parent, AWARE)).body.requirement.children).toEqual({ total: 1, done: 0 });
154
+ expect((await get(parent)).body.requirement.children).toEqual({ total: 2, done: 1 });
155
+ expect(columnIn(await list("&parent_id=" + parent), kid)).toBe("done");
156
+ // 改回 doing:又逾期、又提醒
157
+ await patch(id, { column: "doing" }, AWARE);
158
+ await patch(old, { column: "doing" }, AWARE);
159
+ expect(ids(await list("&overdue=1"))).toContain(old);
160
+ expect(sentFor(due.runDueReminders({ now: T0 }), id)).toEqual(["overdue"]);
161
+ }, 60_000);
162
+
163
+ test("old clients (no declaration) see abandoned as done; their writes cannot un-abandon it by echo", async () => {
164
+ const id = await card("ab-legacy");
165
+ await patch(id, { column: "abandoned" }, AWARE);
166
+ expect((await get(id)).body.requirement.column).toBe("done");
167
+ expect((await get(id, AWARE)).body.requirement.column).toBe("abandoned");
168
+ expect((await send("GET", `/api/requirements/${id}?network_id=${NET}&accept_columns=abandoned`)).body.requirement.column).toBe("abandoned");
169
+ expect(columnIn(await list(), id)).toBe("done");
170
+ expect(columnIn(await list("&view=summary"), id)).toBe("done");
171
+ expect(columnIn(await list("", AWARE), id)).toBe("abandoned");
172
+ // 列表缓存按声明分开:同一个查询串,先旧后新,不串值
173
+ expect(columnIn(await list(), id)).toBe("done");
174
+ expect(columnIn(await list("", AWARE), id)).toBe("abandoned");
175
+ // 旧客户端按 done 筛:带上废弃的(它眼里就是 done)
176
+ expect(ids(await list("&status=done"))).toContain(id);
177
+ expect(ids(await list("&status=done", AWARE))).not.toContain(id);
178
+ expect(ids(await list("&status=abandoned", AWARE))).toContain(id);
179
+ // 旧客户端改别的字段 / 回传它看到的 done:仍是废弃;响应里仍是 done
180
+ const p1 = await patch(id, { priority: "high" });
181
+ expect(p1.body.requirement.column).toBe("done");
182
+ const p2 = await patch(id, { column: "done" });
183
+ expect(p2.body.requirement.column).toBe("done");
184
+ expect((await get(id, AWARE)).body.requirement.column).toBe("abandoned");
185
+ // 旧客户端明确改回 pool:照办
186
+ expect((await patch(id, { column: "pool" })).body.requirement.column).toBe("pool");
187
+ // 新客户端从 abandoned 改成 done:真的完成
188
+ await patch(id, { column: "abandoned" }, AWARE);
189
+ const done = await patch(id, { column: "done" }, AWARE);
190
+ expect(done.body.requirement.column).toBe("done");
191
+ expect(done.body.requirement.completedAt).not.toBeNull();
192
+ }, 60_000);
193
+
194
+ test("events and last_event: undeclared callers see the projected column; done ↔ abandoned disappears for them", async () => {
195
+ const id = await card("ab-events");
196
+ await patch(id, { column: "abandoned" }, AWARE);
197
+ await patch(id, { column: "done" }, AWARE);
198
+ await patch(id, { column: "abandoned" }, AWARE);
199
+ const evs = async (headers: Record<string, string> = {}) => ((await send("GET", `/api/requirements/events?network_id=${NET}&requirement_id=${id}`, undefined, headers)).body.events as any[])
200
+ .filter(e => e.kind === "changed" && e.field === "column").map(e => [e.old, e.new]).reverse();
201
+ expect(await evs(AWARE)).toEqual([["pool", "abandoned"], ["abandoned", "done"], ["done", "abandoned"]]);
202
+ expect(await evs()).toEqual([["pool", "done"]]);
203
+ const legacyRaw = JSON.stringify((await send("GET", `/api/requirements/events?network_id=${NET}`)).body.events);
204
+ expect(legacyRaw).not.toContain("abandoned");
205
+ // 每行的 last_event:最新一条是 done → abandoned,旧客户端那里投影后前后一样 → 不给字段 / 摘要
206
+ const legacyRow = (await list()).body.requirements.find((r: any) => r.id === id);
207
+ expect(legacyRow.last_event.field).toBeNull();
208
+ expect(legacyRow.last_event.summary).toBeUndefined();
209
+ const awareRow = (await list("", AWARE)).body.requirements.find((r: any) => r.id === id);
210
+ expect(awareRow.last_event).toMatchObject({ field: "column", summary: "done → abandoned" });
211
+ await patch(id, { column: "doing" }, AWARE);
212
+ const back = (await list()).body.requirements.find((r: any) => r.id === id);
213
+ expect(back.last_event).toMatchObject({ field: "column", summary: "done → doing" });
214
+ expect(JSON.stringify((await list()).body.requirements)).not.toContain("abandoned");
215
+ }, 60_000);
216
+
217
+ test("checklist toggle response is projected for undeclared callers", async () => {
218
+ const id = await card("ab-check", { checklist: [{ id: "c1", text: "one" }] });
219
+ await patch(id, { column: "abandoned" }, AWARE);
220
+ const legacy = await send("PATCH", `/api/requirements/${id}/checklist/c1?network_id=${NET}`, { done: true });
221
+ expect(legacy.status).toBe(200);
222
+ expect(legacy.body.requirement.column).toBe("done");
223
+ const aware = await send("PATCH", `/api/requirements/${id}/checklist/c1?network_id=${NET}`, { done: false }, AWARE);
224
+ expect(aware.body.requirement.column).toBe("abandoned");
225
+ }, 60_000);
226
+
227
+ test("MCP declares support: requirements_update / get / list carry the real value", async () => {
228
+ const id = await card("ab-mcp");
229
+ const up = await mcp("requirements_update", { id, network_id: NET, status: "abandoned" });
230
+ expect(up.requirement.column).toBe("abandoned");
231
+ expect((await mcp("requirements_get", { id, network_id: NET })).requirement.column).toBe("abandoned");
232
+ const l = await mcp("requirements_list", { network_id: NET, status: "abandoned" });
233
+ expect(l.requirements.map((x: any) => x.id)).toContain(id);
234
+ expect((await mcp("requirements_update", { id, network_id: NET, column: "doing" })).requirement.column).toBe("doing");
235
+ }, 60_000);
236
+ });
@@ -54,7 +54,7 @@ const ENTRIES: Record<string, Entry> = {
54
54
  participants_conflict: { field: 'participants', message: 'participants (replace the whole list) cannot be combined with participants_add / participants_remove.', hint: 'Use participants_add / participants_remove to change a few people, or participants alone to replace the list.' },
55
55
  participants_add_remove_overlap: { field: 'participants_remove', message: 'The same person is in both participants_add and participants_remove.', hint: 'Send each person in only one of the two lists.' },
56
56
  too_many_participants: { field: 'participants_add', message: 'A task can have at most 100 participants.', hint: 'Remove some with participants_remove first.' },
57
- status_conflicts_with_column: { field: 'status', message: 'status is an alias of column, and the two values sent differ.', hint: 'Send only one of them (pool, doing or done).' },
57
+ status_conflicts_with_column: { field: 'status', message: 'status is an alias of column, and the two values sent differ.', hint: 'Send only one of them (pool, doing, done or abandoned).' },
58
58
  invalid_assignee: { field: 'assignee', message: 'assignee must be a string (legacy free-text field).', hint: 'Prefer owner / agent_owner.' },
59
59
  // ── 项目 ──
60
60
  invalid_project: { field: 'project_id', message: 'project_id must be a project id string or null.', hint: 'Call projects_list to get project ids (proj_…); null removes the task from its project.' },
@@ -77,14 +77,14 @@ const ENTRIES: Record<string, Entry> = {
77
77
  empty_patch: (ctx) => {
78
78
  const sent = ctx.sent ?? [];
79
79
  const writable = ctx.writable?.length ? ctx.writable.join(', ') : 'name, column, priority, due, start, description, checklist, tags, owner, agent_owner, participants, project_id, parent_id, external_ref, external_url, archived';
80
- const statusHint = sent.includes('status') ? 'To move a task between pool / doing / done, use column (status is the name of the list filter). ' : '';
80
+ const statusHint = sent.includes('status') ? 'To move a task between pool / doing / done / abandoned, use column (status is the name of the list filter). ' : '';
81
81
  return { message: sent.length ? `None of the keys sent (${sent.join(', ')}) is a writable field.` : 'The patch changes nothing.', hint: `${statusHint}Writable fields: ${writable}.` };
82
82
  },
83
83
  invalid_json: { message: 'The request body must be a JSON object.' },
84
84
  invalid_name: { field: 'name', message: 'name must be 1–80 characters.' },
85
85
  invalid_description: { field: 'description', message: 'description must be a string (markdown) of at most 20 000 characters.' },
86
- invalid_column: { field: 'column', message: 'column must be pool, doing or done.' },
87
- invalid_status: { field: 'status', message: 'status (list filter) must be pool, doing or done.' },
86
+ invalid_column: { field: 'column', message: 'column must be pool, doing, done or abandoned.' },
87
+ invalid_status: { field: 'status', message: 'status (list filter) must be pool, doing, done or abandoned.' },
88
88
  invalid_priority: { field: 'priority', message: 'priority must be high (P0), normal (P1), low (P2) or lowest (P3).' },
89
89
  invalid_due: { field: 'due', message: 'due is not a date this hub understands.', hint: `Use ${DUE_FORMATS}.` },
90
90
  invalid_start: { field: 'start', message: 'start is not a date this hub understands.', hint: `Use ${DUE_FORMATS}.` },
@@ -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_ops", "list_summary", "changes", "events", "last_event"]);
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_ops", "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 () => {
@@ -125,8 +125,8 @@ describe("view=summary", () => {
125
125
  const one = await send(admin.token, "GET", `/api/requirements/${card.id}?network_id=${NET}`);
126
126
  expect(one.body.requirement.description).toBe(f.description);
127
127
  // 比的是 summary 省掉的那部分(正文 / 子任务):两边一样多的 last_event(#506,每行约 150 B)不算,
128
- // 否则这张只有几行的小表上,固定开销会把比例拉过一半。
129
- const bare = (body: any) => JSON.stringify({ ...body, requirements: body.requirements.map(({ last_event: _l, ...r }: any) => r) }).length;
128
+ // 否则这张只有几行的小表上,固定开销会把比例拉过一半。capabilities 同理:两边一样、每加一个能力就长一截。
129
+ const bare = (body: any) => JSON.stringify({ ...body, capabilities: [], requirements: body.requirements.map(({ last_event: _l, ...r }: any) => r) }).length;
130
130
  expect(summary.body.requirements.every((r: any) => "last_event" in r)).toBe(true);
131
131
  expect(bare(summary.body)).toBeLessThan(bare(full.body) / 2);
132
132
  });
@@ -119,6 +119,40 @@ export function migrateRequirementPriorityCheck(database: DbAdapter): { rebuilt:
119
119
  return { rebuilt: true };
120
120
  }
121
121
 
122
+ /**
123
+ * 状态加 abandoned(废弃)。同 migrateRequirementPriorityCheck:旧库的 CHECK(column_name IN ('pool', 'doing', 'done'))
124
+ * SQLite 不能就地改,只重建一次;PostgreSQL 换掉默认名的列约束。已经放开的库什么都不做。存量值不改。
125
+ */
126
+ const OLD_COLUMN_CHECK = /CHECK\s*\(\s*column_name\s+IN\s*\(\s*'pool'\s*,\s*'doing'\s*,\s*'done'\s*\)\s*\)/i;
127
+ export const COLUMN_CHECK = "CHECK(column_name IN ('pool', 'doing', 'done', 'abandoned'))";
128
+ export function migrateRequirementColumnCheck(database: DbAdapter): { rebuilt: boolean } {
129
+ if (database.dialect === "postgres") {
130
+ // 只在约束还不认识 abandoned(或不存在)时动;一条 ALTER TABLE 里先删后加 = 一个语句,原子,
131
+ // 失败(比如存量里有不合法的值)整条回滚、旧约束原样留着,并且直接抛出 —— 不吞错,启动就该停下来。
132
+ const def = database.get<{ def: string }>(
133
+ "SELECT pg_get_constraintdef(c.oid) AS def FROM pg_constraint c JOIN pg_class t ON t.oid = c.conrelid WHERE t.relname = 'requirements' AND c.conname = 'requirements_column_name_check'",
134
+ )?.def ?? "";
135
+ if (def.includes("'abandoned'")) return { rebuilt: false };
136
+ database.exec(`ALTER TABLE requirements DROP CONSTRAINT IF EXISTS requirements_column_name_check, ADD CONSTRAINT requirements_column_name_check ${COLUMN_CHECK}`);
137
+ return { rebuilt: true };
138
+ }
139
+ const table = database.get<{ sql: string }>("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'requirements'");
140
+ if (!table?.sql || !OLD_COLUMN_CHECK.test(table.sql)) return { rebuilt: false };
141
+ const indexes = database.all<{ sql: string }>("SELECT sql FROM sqlite_master WHERE type = 'index' AND tbl_name = 'requirements' AND sql IS NOT NULL");
142
+ const createSql = table.sql
143
+ .replace(/^CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?["`]?requirements["`]?/i, "CREATE TABLE requirements_migrated")
144
+ .replace(OLD_COLUMN_CHECK, COLUMN_CHECK);
145
+ database.transaction(() => {
146
+ database.exec("DROP TABLE IF EXISTS requirements_migrated");
147
+ database.exec(createSql);
148
+ database.exec("INSERT INTO requirements_migrated SELECT * FROM requirements");
149
+ database.exec("DROP TABLE requirements");
150
+ database.exec("ALTER TABLE requirements_migrated RENAME TO requirements");
151
+ for (const index of indexes) database.exec(index.sql);
152
+ });
153
+ return { rebuilt: true };
154
+ }
155
+
122
156
  /**
123
157
  * 任务短号 seq:每个网络自己的 #1、#2…,界面显示和按 #N 查都用它。主键 requirement_id 不变。
124
158
  * - 加可空列 seq;没有号的旧行按 created_at(再按 requirement_id)补号,接在该网络已有的最大号后面。
@@ -68,7 +68,7 @@ describe("P3 lowest priority", () => {
68
68
  test("startup rebuilds the legacy CHECK once and keeps every row and index", async () => {
69
69
  const sql = db.get("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'requirements'").sql as string;
70
70
  expect(sql).toContain("'lowest'");
71
- expect(sql).toContain("CHECK(column_name IN ('pool', 'doing', 'done'))");
71
+ expect(sql).toContain("CHECK(column_name IN ('pool', 'doing', 'done', 'abandoned'))");
72
72
  const rows = db.all("SELECT requirement_id, network_id, title, priority, client_id, description, tags_json FROM requirements WHERE network_id = 'net_legacy' ORDER BY requirement_id") as any[];
73
73
  expect(rows.map(r => [r.requirement_id, r.priority])).toEqual([["r_high", "high"], ["r_low", "low"], ["r_normal", "normal"]]);
74
74
  for (const r of rows) {
@@ -93,7 +93,7 @@ export function aggregateStats(rows: StatsRow[], q: StatsQuery, hidden: ((nodeId
93
93
  const sparkDays = new Set(days.slice(-STATS_SPARK_DAYS));
94
94
  const daily = new Map(days.map(d => [d, 0]));
95
95
 
96
- let done = 0, approx = 0, created = 0, createdDone = 0, doing = 0, pool = 0, unattributed = 0;
96
+ let done = 0, approx = 0, created = 0, createdDone = 0, doing = 0, pool = 0, abandoned = 0, unattributed = 0;
97
97
  const byProject = new Map<string | null, number>();
98
98
  const byCompleter = new Map<string, { ref: Ref; n: number; spark: Map<string, number> }>();
99
99
  const recent: { row: StatsRow; ms: number; by: Ref | null }[] = [];
@@ -102,6 +102,7 @@ export function aggregateStats(rows: StatsRow[], q: StatsQuery, hidden: ((nodeId
102
102
  // 当前还开着的卡(不看时间范围;归档的不算「进行中」)。
103
103
  if (!row.archived && row.column_name === "doing") doing++;
104
104
  if (!row.archived && row.column_name === "pool") pool++;
105
+ if (!row.archived && row.column_name === "abandoned") abandoned++;
105
106
  const completedMs = row.column_name === "done" ? msOf(row.completed_at) : null;
106
107
  const createdMs = msOf(row.created_at);
107
108
  if (inRange(createdMs)) {
@@ -143,6 +144,8 @@ export function aggregateStats(rows: StatsRow[], q: StatsQuery, hidden: ((nodeId
143
144
  completion_rate: created ? createdDone / created : null,
144
145
  doing,
145
146
  pool,
147
+ // 废弃(关闭,不算开着也不算完成)。旧 App 忽略这个字段。
148
+ abandoned,
146
149
  },
147
150
  daily: days.map(date => ({ date, n: daily.get(date) ?? 0 })),
148
151
  by_project: [...byProject].map(([project_id, n]) => ({ project_id, n })).sort((a, b) => b.n - a.n || String(a.project_id).localeCompare(String(b.project_id))),
@@ -20,11 +20,12 @@ import { notifyLeaderDeleted, notifyParticipantChange } from "./requirement-noti
20
20
  import { departmentCardsFor, departmentSubtree } from "./department-heads.js";
21
21
  import { dueReminderTimezone, dueWithinSql, ensureDueReminders, overdueSql } from "./requirement-due-reminders.js";
22
22
  import { diffRequirement, ensureRequirementEvents, eventPublic, recordRequirementEvents, type EventRow } from "./requirement-events.js";
23
- import { ensureNetworkTags, ensureRequirementCompletedAt, ensureRequirementIndexes, ensureRequirementProjects, ensureRequirementSeq, ensureRequirementTombstones, migrateRequirementAgentOwners, migrateRequirementPriorityCheck, nextRequirementSeq } from "./requirements-migrate.js";
23
+ import { ensureNetworkTags, ensureRequirementCompletedAt, ensureRequirementIndexes, ensureRequirementProjects, ensureRequirementSeq, ensureRequirementTombstones, migrateRequirementAgentOwners, migrateRequirementColumnCheck, migrateRequirementPriorityCheck, nextRequirementSeq } from "./requirements-migrate.js";
24
24
 
25
25
  // 启动迁移:旧库里节点当负责人的卡,节点挪到 agent_owner(列由 db.ts 的加列循环加上)。
26
26
  // 放在这里而不是 db.ts:db.ts 每多一行,文档里钉着的行号就漂一次。
27
27
  migrateRequirementPriorityCheck(db);
28
+ migrateRequirementColumnCheck(db);
28
29
  migrateRequirementAgentOwners(db);
29
30
  ensureRequirementProjects(db);
30
31
  ensureRequirementIndexes(db);
@@ -52,7 +53,36 @@ export type RequirementsRequestContext = {
52
53
  strictOwner?: boolean;
53
54
  };
54
55
 
55
- const COLUMNS = new Set(["pool", "doing", "done"]);
56
+ const COLUMNS = new Set(["pool", "doing", "done", "abandoned"]);
57
+ /**
58
+ * 废弃(abandoned):和 done 一样是关闭态 —— 不算开着、不算逾期、不发到期提醒,不记完成时间;能改回 pool / doing。
59
+ * 旧客户端(desktop ≤ 0.2.220)只认 pool / doing / done,不认识的值一律当成 pool(会把废弃的卡摆回需求池、还标逾期)。
60
+ * 所以客户端没声明认识它时,响应里把 abandoned 投影成 done;声明方式二选一:
61
+ * 请求头 `X-Anet-Accept-Columns: abandoned`,或查询参数 `accept_columns=abandoned`。
62
+ * MCP(tools.ts)总是声明。文档:docs-site/docs/api/mcp-tools.md「需求池 / 任务看板」的 column 字段。
63
+ */
64
+ const ACCEPT_COLUMNS_HEADER = "x-anet-accept-columns";
65
+ function knowsAbandoned(ctx: RequirementsRequestContext): boolean {
66
+ const raw = `${ctx.req.headers.get(ACCEPT_COLUMNS_HEADER) ?? ""},${ctx.url.searchParams.get("accept_columns") ?? ""}`;
67
+ return raw.split(",").some(v => v.trim() === "abandoned");
68
+ }
69
+ /**
70
+ * 动态的旧客户端视图:column 改动的 old / new、created 的 new.column 都投影;投影后前后一样(done ↔ abandoned)= null(不回)。
71
+ */
72
+ function projectEvent<T extends { kind: string; field: string | null; old: unknown; new: unknown }>(ev: T, aware: boolean): T | null {
73
+ if (aware) return ev;
74
+ if (ev.kind === "created" && ev.new && typeof ev.new === "object" && typeof (ev.new as any).column === "string") {
75
+ return { ...ev, new: { ...(ev.new as object), column: projectColumn((ev.new as any).column, false) } };
76
+ }
77
+ if (ev.field !== "column") return ev;
78
+ const old = typeof ev.old === "string" ? projectColumn(ev.old, false) : ev.old;
79
+ const neu = typeof ev.new === "string" ? projectColumn(ev.new, false) : ev.new;
80
+ return old === neu ? null : { ...ev, old, new: neu };
81
+ }
82
+ /** 旧客户端看到的状态:abandoned → done,其余原样。 */
83
+ export function projectColumn(column: string, aware: boolean): string {
84
+ return column === "abandoned" && !aware ? "done" : column;
85
+ }
56
86
  // 界面上 P0–P3:high=P0 最高、normal=P1 普通、low=P2 低、lowest=P3 极低。存的值不变,旧客户端照常读写前三个。
57
87
  const PRIORITIES = new Set(["high", "normal", "low", "lowest"]);
58
88
  const DUE = /^\d{4}-\d{2}-\d{2}$/;
@@ -73,6 +103,7 @@ type Row = {
73
103
  parent_id: string | null;
74
104
  children_total: number | null;
75
105
  children_done: number | null;
106
+ children_abandoned?: number | null;
76
107
  participants_json: string;
77
108
  requirement_id: string;
78
109
  network_id: string;
@@ -511,7 +542,8 @@ function toPublic(row: Row) {
511
542
  updated_by: row.updated_by_json ? JSON.parse(row.updated_by_json) : null,
512
543
  // 子需求:parent_id(可空)和父卡上的子需求进度(未归档的子需求数 / 其中完成的)。
513
544
  parent_id: row.parent_id || null,
514
- children: { total: Number(row.children_total ?? 0), done: Number(row.children_done ?? 0) },
545
+ // 废弃的子需求不算进度(total 里减掉);旧客户端眼里它是 done,见 toPublicFor。
546
+ children: { total: Number(row.children_total ?? 0) - Number(row.children_abandoned ?? 0), done: Number(row.children_done ?? 0) },
515
547
  // 完成时间:进「完成」列的时刻,移出清空(不在「完成」列 = null)。completedAtApprox = 升级前就完成的卡,
516
548
  // 时刻是按 updated_at 补的近似值。completedBy = 谁移进「完成」的(近似值的卡为 null)。旧 App 忽略这三个字段。
517
549
  completedAt: row.completed_at || null,
@@ -563,7 +595,14 @@ function toPublicFor(ctx: RequirementsRequestContext, row: Row) {
563
595
  if (!perms) permsByCtx.set(ctx, perms = taskPermissionsResolver(taskCaller(ctx)));
564
596
  // viewer_can:只对「只看相关任务」的调用者出现,客户端据此画只读锁、藏删除;不出现 = 与今天一样全能(旧 Hub 也不出现)。
565
597
  const can = perms(row);
566
- const pub = can ? { ...toPublic(row), viewer_can: can } : toPublic(row);
598
+ const base = toPublic(row);
599
+ if (!knowsAbandoned(ctx)) {
600
+ base.column = projectColumn(base.column, false);
601
+ // 旧客户端把废弃的子需求当 done 列出来:进度也按「算进总数、算作完成」给,和它看到的子需求列表对得上。
602
+ const abandoned = Number(row.children_abandoned ?? 0);
603
+ base.children = { total: base.children.total + abandoned, done: base.children.done + abandoned };
604
+ }
605
+ const pub = can ? { ...base, viewer_can: can } : base;
567
606
  const hidden = hiddenNodeFilter(ctx, row.network_id);
568
607
  if (!hidden) return pub;
569
608
  return {
@@ -579,6 +618,7 @@ function toPublicFor(ctx: RequirementsRequestContext, row: Row) {
579
618
 
580
619
  const SELECT = "requirement_id, network_id, title, column_name, priority, due_on, assignee, issues_json, tags_json, created_at, owner_json, participants_json, agent_owner_json, description, checklist_json, project_id, external_ref, external_url, archived, created_by, created_by_json, updated_by_json, updated_at, parent_id, start_on, seq, completed_at, completed_by_json, completed_at_approx, " +
581
620
  "(SELECT COUNT(*) FROM requirements c WHERE c.parent_id = requirements.requirement_id AND COALESCE(c.archived, 0) = 0) AS children_total, " +
621
+ "(SELECT COUNT(*) FROM requirements c WHERE c.parent_id = requirements.requirement_id AND COALESCE(c.archived, 0) = 0 AND c.column_name = 'abandoned') AS children_abandoned, " +
582
622
  "(SELECT COUNT(*) FROM requirements c WHERE c.parent_id = requirements.requirement_id AND COALESCE(c.archived, 0) = 0 AND c.column_name = 'done') AS children_done";
583
623
 
584
624
  type PersonRef = { kind: 'user' | 'node'; id: string };
@@ -730,7 +770,7 @@ function ifNoneMatchHits(header: string | null, etag: string): boolean {
730
770
  // list_summary:GET 认 view=summary(不带描述正文与子任务条目,见 toSummary);changes:GET 认 changes=1 + updated_since
731
771
  // (改过的卡含归档的,加上 deleted 墓碑与 server_time,见 listChanges)。
732
772
  // events:GET /api/requirements/events —— 字段级的改动流水(谁、何时、旧值 → 新值,requirement-events.ts)。
733
- 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_ops", "list_summary", "changes", "events", "last_event"] as const;
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_ops", "list_summary", "changes", "events", "last_event", "column_abandoned"] as const;
734
774
 
735
775
  // ── 子需求 ──
736
776
  // parent_id:同一网络里的另一张卡;不能成环;最多 5 层(顶层是第 1 层)。删父卡 = 子卡保留、parent_id 置空。
@@ -1017,7 +1057,10 @@ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Recor
1017
1057
  archived = body.archived ? 1 : 0;
1018
1058
  }
1019
1059
  const updatedAt = new Date().toISOString();
1020
- const column = has("column") ? String(body.column) : row.column_name;
1060
+ // 旧客户端眼里废弃的卡是 done:它回传 column=done 不是「改成完成」,保持废弃。
1061
+ const column = !has("column") ? row.column_name
1062
+ : row.column_name === "abandoned" && body.column === "done" && !knowsAbandoned(ctx) ? "abandoned"
1063
+ : String(body.column);
1021
1064
  const actor = JSON.stringify(actorOf(ctx));
1022
1065
  // 完成时间:移进「完成」= 此刻 + 这次的操作者;移出 = 清空;留在「完成」(done → done、改别的字段)= 原样不动。
1023
1066
  const completed = column !== "done" ? { at: null, by: null, approx: 0 }
@@ -1083,7 +1126,9 @@ function listFilters(url: URL, sql: string, params: unknown[], ctx?: Requirement
1083
1126
  const status = q.get("status");
1084
1127
  if (status !== null) {
1085
1128
  if (!COLUMNS.has(status)) return jsonError("invalid_status", 400);
1086
- sql += ` AND column_name = ?${params.push(status)}`;
1129
+ // 旧客户端把废弃的卡看成 done:它按 done 筛时也要带上废弃的,否则同一张卡在列表里有、筛出来没有。
1130
+ if (status === "done" && ctx && !knowsAbandoned(ctx)) sql += " AND column_name IN ('done', 'abandoned')";
1131
+ else sql += ` AND column_name = ?${params.push(status)}`;
1087
1132
  }
1088
1133
  const project = q.get("project_id");
1089
1134
  if (project !== null) sql += project === "none" ? " AND project_id IS NULL" : ` AND project_id = ?${params.push(project)}`;
@@ -1319,7 +1364,9 @@ function lastEventsFor(ctx: RequirementsRequestContext, rows: readonly Row[]): M
1319
1364
  ...ids,
1320
1365
  );
1321
1366
  if (!events.length) return out;
1322
- const pubs = events.map(eventPublic);
1367
+ const aware = knowsAbandoned(ctx);
1368
+ // 投影后成了 done → done 的那条:当作没有可展示的字段(同下面隐去节点的处理)。
1369
+ const pubs = events.map(e => { const ev = eventPublic(e); return projectEvent(ev, aware) ?? { ...ev, field: null, old: null, new: null }; });
1323
1370
  const userIds = new Set<string>(), nodeIds = new Set<string>();
1324
1371
  for (const ev of pubs) if (isRef(ev.actor)) (ev.actor.kind === "user" ? userIds : nodeIds).add(ev.actor.id);
1325
1372
  // 名字规则同 GET /api/requirements/people 的 name(没设显示名 → 用户名 / alias / node_name)。
@@ -1384,6 +1431,7 @@ function listEvents(ctx: RequirementsRequestContext): Response {
1384
1431
  const serverTime = new Date().toISOString();
1385
1432
  const caller = taskCaller(ctx);
1386
1433
  const hidden = hiddenNodeFilter(ctx, networkId);
1434
+ const aware = knowsAbandoned(ctx);
1387
1435
  const seen = new Map<string, boolean>();
1388
1436
  const canSee = (id: string): boolean => {
1389
1437
  if (!caller) return true;
@@ -1399,7 +1447,8 @@ function listEvents(ctx: RequirementsRequestContext): Response {
1399
1447
  };
1400
1448
  const shown = (row: EventRow) => {
1401
1449
  if (!canSee(row.requirement_id)) return null;
1402
- const ev = eventPublic(row);
1450
+ const ev = projectEvent(eventPublic(row), aware);
1451
+ if (!ev) return null;
1403
1452
  if (!hidden) return ev;
1404
1453
  const mask = (ref: unknown) => (isHiddenRef(ref, hidden) ? null : ref);
1405
1454
  const masked = { ...ev, actor: mask(ev.actor) };
@@ -1479,7 +1528,7 @@ function listCacheAcl(ctx: RequirementsRequestContext): string | null {
1479
1528
 
1480
1529
  function listCacheKey(ctx: RequirementsRequestContext): string {
1481
1530
  const sorted = [...ctx.url.searchParams.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
1482
- return JSON.stringify({ u: ctx.auth?.userId ?? null, n: ctx.auth?.networkId ?? null, a: ctx.isAdmin, t: ctx.isNodeToken, s: ctx.scope, q: sorted });
1531
+ return JSON.stringify({ u: ctx.auth?.userId ?? null, n: ctx.auth?.networkId ?? null, a: ctx.isAdmin, t: ctx.isNodeToken, s: ctx.scope, q: sorted, c: knowsAbandoned(ctx) });
1483
1532
  }
1484
1533
 
1485
1534
  /** Test-only. */
package/src/server.ts CHANGED
@@ -39,7 +39,7 @@ import { broadcastVerdict, dispatchVerdict, humanOnlyVerdict, NODE_PERMISSION_MO
39
39
  import { validateAvatarUrl } from "./avatar-validate.js";
40
40
  import { narrowTags, parseStoredTags, validateScalarAttr } from "./node-attrs-validate.js";
41
41
  import { daemonDefaultWorkdirRoot } from "./create-node-validate.js";
42
- import { register, login, resolveToken, tokenRejection, isExpiredSessionToken, sessionIdleDays, listSessions, revokeSession, revokeOtherSessions, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, adminDeleteEmptyNetwork, renameNetwork, changePassword, issueUserToken, listTokens, createToken, revokeToken, getNetworkMembers, getNetworkHumans, getUserNetworkRole, addNetworkMember, adminCreateUser, listUsersWithMemberships, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
42
+ import { register, login, resolveToken, tokenRejection, isExpiredSessionToken, sessionIdleDays, listSessions, revokeSession, revokeOtherSessions, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, adminDeleteEmptyNetwork, renameNetwork, changePassword, issueUserToken, getUserTokenMeta, listTokens, createToken, revokeToken, getNetworkMembers, getNetworkHumans, getUserNetworkRole, addNetworkMember, adminCreateUser, listUsersWithMemberships, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
43
43
  import { abortRename, cleanupCommittedRenameSessions, commitRename, prepareRename, resolveCanonicalAlias } from "./rename.js";
44
44
  import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
45
45
  import { clientRequestIdFromMeta, idempotentTaskId, idempotentTaskMatches, type StoredIdempotentTask } from "./task-idempotency.js";
@@ -1376,7 +1376,7 @@ return Bun.serve({
1376
1376
  }
1377
1377
  try {
1378
1378
  const body = await req.json() as any;
1379
- const result = register(body.username, body.password, body.email, body.display_name, { client: { label: body.client_label, userAgent: req.headers.get("user-agent") } });
1379
+ const result = register(body.username, body.password, body.email, body.display_name, { client: { label: body.client_label, userAgent: req.headers.get("user-agent"), kind: body.client_kind } });
1380
1380
  if (result.ok) logAudit(result.user!.user_id, body.username, "register", "user", result.user!.user_id);
1381
1381
  return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
1382
1382
  } catch (e: any) {
@@ -1404,7 +1404,7 @@ return Bun.serve({
1404
1404
  { status: 429, headers: { "Retry-After": String(Math.ceil((lock.retryAfterMs ?? 1000) / 1000)) } }
1405
1405
  ));
1406
1406
  }
1407
- const result = login(body.username, body.password, { label: body.client_label, userAgent: req.headers.get("user-agent") });
1407
+ const result = login(body.username, body.password, { label: body.client_label, userAgent: req.headers.get("user-agent"), kind: body.client_kind });
1408
1408
  if (result.ok) {
1409
1409
  sharedLoginFailureLockout.recordSuccess(body.username);
1410
1410
  logAudit(result.user!.user_id, body.username, "login", "user", result.user!.user_id);
@@ -1473,9 +1473,13 @@ return Bun.serve({
1473
1473
  if (isNodeCredential(resolved)) return userTokenRequired(req);
1474
1474
  try {
1475
1475
  const body = await req.json() as any;
1476
- const result = changePassword(resolved.user.user_id, body.old_password, body.new_password, resolved.tokenId);
1476
+ // #711 不带 keep_cli_tokens 的请求(旧 app、dashboard 代理)= 改密码前的行为:撤销全部不绑网络的令牌。
1477
+ // keep_cli_tokens 必须是布尔 true 才保留命令行 / 脚本令牌。
1478
+ const meta = resolved.tokenId ? getUserTokenMeta(resolved.user.user_id, resolved.tokenId) : null;
1479
+ const result = changePassword(resolved.user.user_id, body.old_password, body.new_password, resolved.tokenId, { keepCliTokens: body.keep_cli_tokens === true });
1477
1480
  if (result.ok) {
1478
- const issued = issueUserToken(resolved.user.user_id, "password-change");
1481
+ // 换发的新令牌沿用当前这条的种类和设备标识:anet passwd 换完仍是命令行令牌。
1482
+ const issued = issueUserToken(resolved.user.user_id, "password-change", { kind: meta?.kind, clientLabel: meta?.client_label, userAgent: meta?.user_agent });
1479
1483
  if (resolved.tokenId) revokeToken(resolved.user.user_id, resolved.tokenId);
1480
1484
  logAudit(resolved.user.user_id, resolved.user.username, "password_changed", "user", resolved.user.user_id);
1481
1485
  return withCors(req, Response.json({ ...result, token: issued.token, token_id: issued.token_id }));
package/src/tools.ts CHANGED
@@ -3684,7 +3684,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3684
3684
 
3685
3685
  server.tool(
3686
3686
  "list_node_skills",
3687
- "Ask a node to list the skills its runtime loads (name, scope project|user|system, display path, frontmatter description). Read-only; no path argument. Poll get_rules_file_result — content is JSON {skills:[…]}.",
3687
+ "Ask a node to list the skills its runtime loads (name, scope project|user|system, display path, frontmatter description; team skills also include origin \"team\"). Read-only; no path argument. Poll get_rules_file_result — content is JSON {skills:[…], roots, warnings}.",
3688
3688
  {
3689
3689
  ...NODE_ID_ALIAS_FIELDS,
3690
3690
  alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting skills_capable)."),
@@ -3695,7 +3695,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
3695
3695
 
3696
3696
  server.tool(
3697
3697
  "read_node_skill",
3698
- "Ask a node for one skill's SKILL.md by name. Read-only; the node resolves the name under its own runtime's skills roots — no path argument. Poll get_rules_file_result — content is JSON {name, scope, path_rel, description, content}.",
3698
+ "Ask a node for one skill's SKILL.md by name. Read-only; the node resolves the name under its own runtime's skills roots — no path argument. Poll get_rules_file_result — content is JSON {name, scope, path_rel, description, content, origin?}. origin is \"team\" only for a team skill.",
3699
3699
  {
3700
3700
  ...NODE_ID_ALIAS_FIELDS,
3701
3701
  alias: z.string().min(1).max(200).optional().describe("Alias instead of node_id (a node, or a session reporting skills_capable)."),
@@ -5993,7 +5993,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
5993
5993
  const scope = resolveRestNetworkScope(enforceNetworkId ? null : (clientNetId ?? null), mcpAuthCtx, false);
5994
5994
  if (scope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: "access_denied", message: scope.denied }) }] };
5995
5995
  const url = new URL(`http://mcp.internal${path}`);
5996
- const req = new Request(url, { method, headers: { "content-type": "application/json" }, body: body ? JSON.stringify(body) : undefined });
5996
+ const req = new Request(url, { method, headers: { "content-type": "application/json", "x-anet-accept-columns": "abandoned" }, body: body ? JSON.stringify(body) : undefined });
5997
5997
  const res = await handleRequirementsRequest({
5998
5998
  req, url,
5999
5999
  auth: mcpAuthCtx ? { ...mcpAuthCtx, username: "", tokenId: callerTokenId ?? null, tokenName: callerTokenIsNetwork && callerAlias ? `node:${callerAlias}` : null } : null,
@@ -6021,7 +6021,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
6021
6021
  tags: z.array(z.string()).max(10).optional().describe("Task labels; each 1–20 Unicode characters. Omit to preserve, [] to clear."),
6022
6022
  name: z.string().min(1).max(80).optional(),
6023
6023
  priority: z.enum(["high", "normal", "low", "lowest"]).optional().describe("high = P0 最高, normal = P1 普通, low = P2 低, lowest = P3 极低"),
6024
- column: z.enum(["pool", "doing", "done"]).optional(),
6024
+ column: z.enum(["pool", "doing", "done", "abandoned"]).optional(),
6025
6025
  due: z.string().max(40).optional().describe("YYYY-MM-DD (all day) or ISO 8601 with Z / ±HH:MM (stored as UTC seconds); \"\" clears"),
6026
6026
  start: z.string().max(40).optional().describe("start date (Gantt): same shapes as due; \"\" clears"),
6027
6027
  description: z.string().max(20_000).optional().describe("markdown"),
@@ -6037,7 +6037,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
6037
6037
  const REQ_WRITE_KEYS = [...Object.keys(reqFields), "external_ref", "archived"];
6038
6038
  // #475 —— status 是 column 的别名(列表的筛选参数就叫 status,Agent 写任务时也常这么写)。只在 MCP 这层换名,REST 不变;
6039
6039
  // 两个都给且不同 → 400 status_conflicts_with_column,不替调用方挑一个。
6040
- const reqStatus = z.enum(["pool", "doing", "done"]).optional().describe("alias of column; must match it if both are sent");
6040
+ const reqStatus = z.enum(["pool", "doing", "done", "abandoned"]).optional().describe("alias of column; must match it if both are sent");
6041
6041
  const withColumn = (args: Record<string, unknown>, call: (args: Record<string, unknown>) => Promise<{ content: { type: "text"; text: string }[] }>) => {
6042
6042
  if (args.status === undefined) return call(args);
6043
6043
  if (args.column !== undefined && args.column !== args.status) return Promise.resolve({ content: [{ type: "text" as const, text: JSON.stringify({ ...errorBody("status_conflicts_with_column"), status: 400 }) }] });
@@ -6053,7 +6053,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
6053
6053
  const reqListShape = {
6054
6054
  network_id: z.string().max(200).optional(),
6055
6055
  seq: z.number().int().positive().optional().describe("short number (#N) of one task in this network"),
6056
- status: z.enum(["pool", "doing", "done"]).optional(),
6056
+ status: z.enum(["pool", "doing", "done", "abandoned"]).optional(),
6057
6057
  project_id: z.string().max(200).optional(),
6058
6058
  owner: z.string().max(210).optional(),
6059
6059
  agent_owner: z.string().max(210).optional(),
@@ -6134,14 +6134,14 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
6134
6134
 
6135
6135
  server.tool(
6136
6136
  "requirements_create",
6137
- "Create a requirement task. name is required. column (or its alias status) = pool / doing / done. A duplicate external_ref in the network returns 409 external_ref_exists with existing_id — use requirements_upsert_by_external_ref for syncing.",
6137
+ "Create a requirement task. name is required. column (or its alias status) = pool / doing / done / abandoned. A duplicate external_ref in the network returns 409 external_ref_exists with existing_id — use requirements_upsert_by_external_ref for syncing.",
6138
6138
  { network_id: z.string().max(200).optional(), ...reqFields, status: reqStatus, name: z.string().min(1).max(80), external_ref: z.string().max(200).optional() },
6139
6139
  async (args) => withColumn(args, a => requirementsCall("POST", "/api/requirements", args.network_id, pick(a, REQ_WRITE_KEYS))),
6140
6140
  );
6141
6141
 
6142
6142
  server.tool(
6143
6143
  "requirements_update",
6144
- "Patch a requirement task (id or \"#N\"); omitted fields keep their value. column (or its alias status) moves it between pool / doing / done. participants REPLACES the whole list; to add or remove people without dropping the others use participants_add / participants_remove. archived=true hides it from the default list (agents cannot delete).",
6144
+ "Patch a requirement task (id or \"#N\"); omitted fields keep their value. column (or its alias status) moves it between pool / doing / done / abandoned. participants REPLACES the whole list; to add or remove people without dropping the others use participants_add / participants_remove. archived=true hides it from the default list (agents cannot delete).",
6145
6145
  { id: z.string().min(1).max(200).describe(REQ_ID_DESC), network_id: z.string().max(200).optional(), ...reqFields, status: reqStatus, participants_add: reqPeopleDelta("add"), participants_remove: reqPeopleDelta("remove"), external_ref: z.string().max(200).nullable().optional(), archived: z.boolean().optional() },
6146
6146
  async (args) => withColumn(args, a => requirementsCall("PATCH", `/api/requirements/${encodeURIComponent(args.id)}`, args.network_id, pick(a, [...REQ_WRITE_KEYS, "participants_add", "participants_remove"]))),
6147
6147
  );