@sleep2agi/commhub-server 0.9.0-preview.135 → 0.9.0-preview.136

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,7 +1,7 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.135",
4
- "description": "CommHub Server \u2014 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).",
3
+ "version": "0.9.0-preview.136",
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",
7
7
  "bin": {
@@ -6,7 +6,7 @@
6
6
  // · 默认(旧 app、dashboard 代理不带字段):撤销全部不绑网络的令牌 —— 客户端自报的 cli 也活不下来;
7
7
  // · keep_cli_tokens=true(anet passwd 默认带):只撤其他 kind='login' 会话,保留的 cli 令牌列在响应里、不含令牌值;
8
8
  // · 当前会话(换发后的新令牌)永远可用,而且沿用当前令牌的种类;
9
- // · 节点 / 网络令牌永远不动;
9
+ // · 节点令牌不动;具名网络令牌默认保留,可显式revoke_network_tokens;
10
10
  // · 启动迁移按 client_label / scope 回填存量令牌的 kind(夹具库,跑两遍幂等)。
11
11
  // SQLite 由 test798 自动收进;PostgreSQL 由 tests/test2123-hub-postgres-ladder 的 run_pg_tests_rc 跑同一个文件。
12
12
 
@@ -15,7 +15,7 @@ import { mkdtempSync, rmSync } from "fs";
15
15
  import { join } from "path";
16
16
  import { tmpdir } from "os";
17
17
  import { spawnSync } from "child_process";
18
- import { register, createNetworkTokenForNode, createToken, changePassword } from "./auth.js";
18
+ import { register, createNetworkTokenForNode, createToken, createNetwork, changePassword } from "./auth.js";
19
19
  import { db, hashToken } from "./db.js";
20
20
 
21
21
  const DIR = mkdtempSync(join(tmpdir(), "anet-pwchange-kind-"));
@@ -292,3 +292,132 @@ describe("startup migration backfills api_tokens.kind on a fixture DB", () => {
292
292
  expect(Number(db.get<any>("SELECT COUNT(*) AS n FROM api_tokens WHERE token_id = ?1", id("node")).n)).toBe(1);
293
293
  }, 120_000);
294
294
  });
295
+
296
+
297
+ describe("network password opt-in", () => {
298
+ const id = (token: string) => db.get<any>("SELECT token_id FROM api_tokens WHERE token_hash=?1", hashToken(token)).token_id;
299
+
300
+ test("default lists only the caller's active named network tokens across networks, without credentials", async () => {
301
+ const f = await fleet();
302
+ const second = createNetwork(f.userId, `pwk-second-${seq}`);
303
+ expect(second.ok).toBe(true);
304
+ const extra = createToken(f.userId, "second-network-script", second.network_id);
305
+ const other = await fleet();
306
+ const r = await call(f.app, "POST", "/api/auth/password", { old_password: PW, new_password: PW2 });
307
+ expect(r.status).toBe(200);
308
+ expect(r.body.revoked_network).toBe(0);
309
+ expect(r.body.revoked).toBe(5);
310
+ expect(r.body.kept_network_tokens.map((x: any) => x.token_id).sort()).toEqual([id(f.netToken), id(extra.token!)].sort());
311
+ for (const row of r.body.kept_network_tokens) {
312
+ expect(Object.keys(row).sort()).toEqual(["created_at", "last_used_at", "name", "network_id", "token_id"]);
313
+ }
314
+ const body = JSON.stringify(r.body);
315
+ expect(body.includes("token_hash")).toBe(false);
316
+ for (const t of [f.netToken, extra.token!, f.nodeToken, other.netToken]) expect(body.includes(t)).toBe(false);
317
+ expect(await works(f.netToken)).toBe(true);
318
+ expect(await works(extra.token!)).toBe(true);
319
+ expect(await works(other.netToken)).toBe(true);
320
+ expect(await works(r.body.token)).toBe(true);
321
+ });
322
+
323
+ test("explicit boolean opt-in revokes only own named network tokens, independently of keep_cli_tokens", async () => {
324
+ const f = await fleet();
325
+ const other = await fleet();
326
+ const second = createNetwork(f.userId, `pwk-revoke-second-${seq}`);
327
+ const extra = createToken(f.userId, "second-network-script", second.network_id);
328
+ const r = await call(f.app, "POST", "/api/auth/password", { old_password: PW, new_password: PW2, keep_cli_tokens: true, revoke_network_tokens: true });
329
+ expect(r.status).toBe(200);
330
+ expect(r.body.revoked_network).toBe(2);
331
+ expect(r.body.revoked_login).toBe(2);
332
+ expect(r.body.revoked_cli).toBe(0);
333
+ expect(r.body.revoked).toBe(4);
334
+ expect(r.body.kept_network_tokens).toEqual([]);
335
+ expect(await works(f.netToken)).toBe(false);
336
+ expect(await works(extra.token!)).toBe(false);
337
+ expect(await works(other.netToken)).toBe(true);
338
+ expect(await works(other.app)).toBe(true);
339
+ expect(await works(f.nodeToken)).toBe(true);
340
+ expect(await works(f.apiToken)).toBe(true);
341
+ expect(await works(r.body.token)).toBe(true);
342
+ expect(await works(f.app)).toBe(false);
343
+ });
344
+
345
+ for (const value of [false, "true", 1, null, {}]) {
346
+ test(`non-true option ${JSON.stringify(value)} cannot revoke network tokens`, async () => {
347
+ const f = await fleet();
348
+ const r = await call(f.app, "POST", "/api/auth/password", { old_password: PW, new_password: PW2, revoke_network_tokens: value });
349
+ expect(r.status).toBe(200);
350
+ expect(r.body.revoked_network).toBe(0);
351
+ expect(r.body.kept_network_tokens).toHaveLength(1);
352
+ expect(await works(f.netToken)).toBe(true);
353
+ });
354
+ }
355
+
356
+ test("scope and binding protect node credentials; inactive rows are neither listed nor counted", async () => {
357
+ const f = await fleet();
358
+ const legacy = createNetworkTokenForNode(f.userId, f.net, `legacy-pwk-${seq}`);
359
+ expect(legacy.ok).toBe(true);
360
+ const bound = createToken(f.userId, "mislabelled-bound-node", f.net);
361
+ db.run("UPDATE api_tokens SET bound_node_id=?1 WHERE token_id=?2", ["n-protected717", bound.token_id]);
362
+ const expired = createToken(f.userId, "expired-script", f.net);
363
+ db.run("UPDATE api_tokens SET expires_at=datetime('now','-1 day') WHERE token_id=?1", [expired.token_id]);
364
+ const revoked = createToken(f.userId, "revoked-script", f.net);
365
+ db.run("UPDATE api_tokens SET revoked_at=datetime('now') WHERE token_id=?1", [revoked.token_id]);
366
+ const r = await call(f.app, "POST", "/api/auth/password", { old_password: PW, new_password: PW2, revoke_network_tokens: true });
367
+ expect(r.status).toBe(200);
368
+ expect(r.body.revoked_network).toBe(1);
369
+ expect(await works(f.nodeToken)).toBe(true);
370
+ expect(await works(bound.token!)).toBe(true);
371
+ for (const t of [bound, expired, revoked, legacy]) expect(db.get("SELECT token_id FROM api_tokens WHERE token_id=?1", t.token_id)).not.toBeNull();
372
+ });
373
+
374
+ test("bad old password or weak new password has no token side effects", async () => {
375
+ for (const body of [{ old_password: "wrong", new_password: PW2 }, { old_password: PW, new_password: "123" }]) {
376
+ const f = await fleet();
377
+ const r = await call(f.app, "POST", "/api/auth/password", { ...body, revoke_network_tokens: true });
378
+ expect(r.status).toBe(400);
379
+ for (const t of [f.app, f.netToken, f.apiToken, f.nodeToken]) expect(await works(t)).toBe(true);
380
+ expect((await call("", "POST", "/api/auth/login", { username: f.user, password: PW })).status).toBe(200);
381
+ }
382
+ });
383
+
384
+ test("network-scoped and unauthenticated credentials cannot invoke password change", async () => {
385
+ const f = await fleet();
386
+ for (const t of [f.netToken, f.nodeToken, ""]) {
387
+ expect((await call(t, "POST", "/api/auth/password", { old_password: PW, new_password: PW2, revoke_network_tokens: true })).status).toBe(t ? 403 : 401);
388
+ }
389
+ expect(await works(f.netToken)).toBe(true);
390
+ expect(await works(f.app)).toBe(true);
391
+ });
392
+
393
+ test("a failed network revoke rolls back the password and earlier session deletions", async () => {
394
+ const f = await fleet();
395
+ const victim = id(f.netToken);
396
+ const fn = `pw717_failure_${seq}`;
397
+ const trigger = `pw717_trigger_${seq}`;
398
+ if (db.dialect === "postgres") db.exec(`CREATE FUNCTION ${fn}() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF OLD.token_id='${victim}' THEN RAISE EXCEPTION 'fixture717_delete_failure'; END IF; RETURN OLD; END; $$; CREATE TRIGGER ${trigger} BEFORE DELETE ON api_tokens FOR EACH ROW EXECUTE FUNCTION ${fn}()`);
399
+ else db.exec(`CREATE TRIGGER ${trigger} BEFORE DELETE ON api_tokens WHEN OLD.token_id='${victim}' BEGIN SELECT RAISE(ABORT,'fixture717_delete_failure'); END`);
400
+ try {
401
+ expect((await call(f.app, "POST", "/api/auth/password", { old_password: PW, new_password: PW2, revoke_network_tokens: true })).status).toBe(400);
402
+ for (const t of [f.app, f.appB, f.apiToken, f.netToken, f.nodeToken]) expect(await works(t)).toBe(true);
403
+ expect((await call("", "POST", "/api/auth/login", { username: f.user, password: PW })).status).toBe(200);
404
+ expect((await call("", "POST", "/api/auth/login", { username: f.user, password: PW2 })).status).toBe(401);
405
+ } finally {
406
+ db.exec(db.dialect === "postgres" ? `DROP TRIGGER ${trigger} ON api_tokens; DROP FUNCTION ${fn}()` : `DROP TRIGGER ${trigger}`);
407
+ }
408
+ });
409
+
410
+ test("direct changePassword keeps its current row and defaults to listing network tokens", async () => {
411
+ const f = await fleet();
412
+ const cur = id(f.app);
413
+ const r = changePassword(f.userId, PW, PW2, cur);
414
+ expect(r.ok).toBe(true);
415
+ expect(r.kept_network_tokens).toHaveLength(1);
416
+ expect(await works(f.app)).toBe(true);
417
+ const r2 = changePassword(f.userId, PW2, PW, cur, { revokeNetworkTokens: true, keepCliTokens: true });
418
+ expect(r2.ok).toBe(true);
419
+ expect(r2.revoked_network).toBe(1);
420
+ expect(await works(f.app)).toBe(true);
421
+ expect(await works(f.nodeToken)).toBe(true);
422
+ });
423
+ });
package/src/auth.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * V3 Auth module — user registration, login, token management
3
3
  */
4
4
  import { db, generateId, hashPassword, verifyPassword, hashToken, generateToken, generateUserToken, generateNetworkToken, uuidv4 } from "./db.js"; import { deleteDepartmentsForNetwork, groupTx, removeMemberFromChatGroups } from "./departments.js"; // #419: also ensures network_members.department_id exists (same line on purpose: docs pin auth.ts line numbers)
5
- import { WEAK_PASSWORDS } from "./password-dict.js";
5
+ import { WEAK_PASSWORDS } from "./password-dict.js"; import { passwordNetworkTokens, type KeptNetworkToken } from "./password-network-tokens.js";
6
6
  import { checkNodeTokenClaim, legacyNodeHolder } from "./node-token-ownership.js";
7
7
  import { NETWORK_REST_COLUMNS, NETWORK_REST_SELECT, sqlColumns } from "./rest-projections.js";
8
8
  import { deleteAgentGrants, isAgentRestricted } from "./agent-access.js"; import { deleteTaskGrantsForMember, newMemberTaskAccess, type TaskAccessMode } from "./task-access.js"; // 一行两个 import:文档钉着 auth.ts 的行号
@@ -330,7 +330,7 @@ export type SessionClientInfo = { label?: unknown; userAgent?: unknown; kind?: u
330
330
  // 种类由客户端自报(client_kind / client_label),**只用于展示和「显式保留」那条路**:
331
331
  // 改密码默认仍撤销全部不绑网络的令牌(当前这条除外),调用方显式 keep_cli_tokens=true 才保留 'cli'。
332
332
  // 所以一个自称 cli 的令牌永远不能靠自报在默认改密码里活下来。
333
- // 节点 / 网络令牌(network_id 非空)不分类,改密码永远不碰。
333
+ // 节点 / 网络令牌(network_id 非空)不按cli分类;具名网络令牌另有显式撤销选项。
334
334
  export type UserTokenKind = "login" | "cli";
335
335
 
336
336
  /** 登录 / 注册请求签发的令牌算哪一类:客户端显式报 kind='cli',或 client_label 是 anet 自报的「anet <版本> · …」。 */
@@ -642,14 +642,14 @@ export function revokeOtherSessions(userId: string, currentTokenId: string): num
642
642
  return db.run(`DELETE FROM api_tokens WHERE user_id = ?1 AND ${SESSION_TOKEN_SQL} AND token_id != ?2`, [userId, currentTokenId]).changes;
643
643
  }
644
644
 
645
- export type ChangePasswordResult = { ok: boolean; error?: string; revoked?: number; revoked_login?: number; revoked_cli?: number; kept_cli_tokens?: KeptCliToken[] };
645
+ export type ChangePasswordResult = { ok: boolean; error?: string; revoked?: number; revoked_login?: number; revoked_cli?: number; kept_cli_tokens?: KeptCliToken[]; revoked_network?: number; kept_network_tokens?: KeptNetworkToken[] };
646
646
 
647
647
  /**
648
648
  * 改密码。默认撤销该用户全部不绑网络的令牌(改密码前的行为,旧 app 依赖它)。#711:opts.keepCliTokens=true
649
649
  * 时保留 kind='cli' 的(anet 命令行登录、具名脚本令牌),并在 kept_cli_tokens 里列出。当前会话和节点令牌永远保留。
650
- * revoked = 两类合计(旧字段,兼容旧客户端)。
650
+ * revokeNetworkTokens=true显式撤销具名网络令牌;默认列出保留清单。revoked含三类合计。
651
651
  */
652
- export function changePassword(userId: string, oldPassword: string, newPassword: string, currentTokenId?: string | null, opts: { keepCliTokens?: boolean } = {}): ChangePasswordResult {
652
+ export function changePassword(userId: string, oldPassword: string, newPassword: string, currentTokenId?: string | null, opts: { keepCliTokens?: boolean; revokeNetworkTokens?: boolean } = {}): ChangePasswordResult {
653
653
  const passwordError = validatePasswordStrength(newPassword, "new password");
654
654
  if (passwordError) return { ok: false, error: passwordError };
655
655
  const user = db.get<any>("SELECT password_hash FROM users WHERE user_id = ?1", userId);
@@ -663,10 +663,14 @@ export function changePassword(userId: string, oldPassword: string, newPassword:
663
663
  // password change. If the user changes via `anet passwd`, the bootstrap
664
664
  // nudge goes away on next login. SET to 0 explicitly (rather than skip)
665
665
  // so a future flag flip can't drift the state.
666
- db.run("UPDATE users SET password_hash = ?1, must_change_password = 0, updated_at = datetime('now') WHERE user_id = ?2", [hashPassword(newPassword), userId]);
667
- const keep = opts.keepCliTokens === true;
668
- const r = revokeTokensAfterPasswordChange(userId, currentTokenId, keep);
669
- return { ok: true, revoked: r.login + r.cli, revoked_login: r.login, revoked_cli: r.cli, ...(keep ? { kept_cli_tokens: r.kept } : {}) };
666
+ const nextHash = hashPassword(newPassword);
667
+ return db.transaction(() => {
668
+ db.run("UPDATE users SET password_hash = ?1, must_change_password = 0, updated_at = datetime('now') WHERE user_id = ?2", [nextHash, userId]);
669
+ const keep = opts.keepCliTokens === true;
670
+ const r = revokeTokensAfterPasswordChange(userId, currentTokenId, keep);
671
+ const network = passwordNetworkTokens(userId, currentTokenId, opts.revokeNetworkTokens === true);
672
+ return { ok: true, revoked: r.login + r.cli + network.revoked, revoked_login: r.login, revoked_cli: r.cli, ...(keep ? { kept_cli_tokens: r.kept } : {}), revoked_network: network.revoked, kept_network_tokens: network.kept };
673
+ });
670
674
  }
671
675
 
672
676
  /**
@@ -0,0 +1,25 @@
1
+ import { db } from "./db.js";
2
+
3
+ export type KeptNetworkToken = {
4
+ token_id: string;
5
+ name: string;
6
+ network_id: string;
7
+ created_at: string;
8
+ last_used_at: string | null;
9
+ };
10
+
11
+ /** Only user-created named network tokens. Node credentials have a different scope or binding. */
12
+ export function passwordNetworkTokens(userId: string, currentTokenId: string | null | undefined, revoke: boolean) {
13
+ const where = `user_id = ?1 AND network_id IS NOT NULL AND scope = 'full'
14
+ AND bound_node_id IS NULL AND revoked_at IS NULL
15
+ AND (expires_at IS NULL OR expires_at > datetime('now'))${currentTokenId ? " AND token_id != ?2" : ""}`;
16
+ const params = currentTokenId ? [userId, currentTokenId] : [userId];
17
+ if (revoke) {
18
+ return { revoked: db.run(`DELETE FROM api_tokens WHERE ${where}`, params).changes, kept: [] as KeptNetworkToken[] };
19
+ }
20
+ const kept = db.all<KeptNetworkToken>(
21
+ `SELECT token_id, name, network_id, created_at, last_used_at FROM api_tokens WHERE ${where} ORDER BY created_at DESC, token_id`,
22
+ ...params,
23
+ );
24
+ return { revoked: 0, kept };
25
+ }
package/src/server.ts CHANGED
@@ -1520,7 +1520,7 @@ return Bun.serve({
1520
1520
  // #711 不带 keep_cli_tokens 的请求(旧 app、dashboard 代理)= 改密码前的行为:撤销全部不绑网络的令牌。
1521
1521
  // keep_cli_tokens 必须是布尔 true 才保留命令行 / 脚本令牌。
1522
1522
  const meta = resolved.tokenId ? getUserTokenMeta(resolved.user.user_id, resolved.tokenId) : null;
1523
- const result = changePassword(resolved.user.user_id, body.old_password, body.new_password, resolved.tokenId, { keepCliTokens: body.keep_cli_tokens === true });
1523
+ const result = changePassword(resolved.user.user_id, body.old_password, body.new_password, resolved.tokenId, { keepCliTokens: body.keep_cli_tokens === true, revokeNetworkTokens: body.revoke_network_tokens === true });
1524
1524
  if (result.ok) {
1525
1525
  // 换发的新令牌沿用当前这条的种类和设备标识:anet passwd 换完仍是命令行令牌。
1526
1526
  const issued = issueUserToken(resolved.user.user_id, "password-change", { kind: meta?.kind, clientLabel: meta?.client_label, userAgent: meta?.user_agent });