@sleep2agi/commhub-server 0.9.0-preview.111 → 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.
@@ -5,6 +5,7 @@ import { createHash } from "node:crypto";
5
5
  import { parsePeopleQuery, peopleDirectory, resolvePersonName } from "./requirements-people.js";
6
6
  import { encodeCursor, matchesTaskId, matchesTerms, parseListQuery, type ListQuery, type NameMaps } from "./requirements-search.js";
7
7
  import { db, logAudit } from "./db.js";
8
+ import { resolveNodeCaller } from "./create-node.js";
8
9
  import { markGzipReusable } from "./http-gzip.js";
9
10
  import { applyTagOp, normalizeTags, parseTagOp, storedTags, type TagOp } from "./requirement-tags.js";
10
11
  import { aggregateStats, parseStatsQuery, type StatsRow } from "./requirements-stats.js";
@@ -19,11 +20,12 @@ import { notifyLeaderDeleted, notifyParticipantChange } from "./requirement-noti
19
20
  import { departmentCardsFor, departmentSubtree } from "./department-heads.js";
20
21
  import { dueReminderTimezone, dueWithinSql, ensureDueReminders, overdueSql } from "./requirement-due-reminders.js";
21
22
  import { diffRequirement, ensureRequirementEvents, eventPublic, recordRequirementEvents, type EventRow } from "./requirement-events.js";
22
- 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";
23
24
 
24
25
  // 启动迁移:旧库里节点当负责人的卡,节点挪到 agent_owner(列由 db.ts 的加列循环加上)。
25
26
  // 放在这里而不是 db.ts:db.ts 每多一行,文档里钉着的行号就漂一次。
26
27
  migrateRequirementPriorityCheck(db);
28
+ migrateRequirementColumnCheck(db);
27
29
  migrateRequirementAgentOwners(db);
28
30
  ensureRequirementProjects(db);
29
31
  ensureRequirementIndexes(db);
@@ -51,7 +53,36 @@ export type RequirementsRequestContext = {
51
53
  strictOwner?: boolean;
52
54
  };
53
55
 
54
- 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
+ }
55
86
  // 界面上 P0–P3:high=P0 最高、normal=P1 普通、low=P2 低、lowest=P3 极低。存的值不变,旧客户端照常读写前三个。
56
87
  const PRIORITIES = new Set(["high", "normal", "low", "lowest"]);
57
88
  const DUE = /^\d{4}-\d{2}-\d{2}$/;
@@ -72,6 +103,7 @@ type Row = {
72
103
  parent_id: string | null;
73
104
  children_total: number | null;
74
105
  children_done: number | null;
106
+ children_abandoned?: number | null;
75
107
  participants_json: string;
76
108
  requirement_id: string;
77
109
  network_id: string;
@@ -510,7 +542,8 @@ function toPublic(row: Row) {
510
542
  updated_by: row.updated_by_json ? JSON.parse(row.updated_by_json) : null,
511
543
  // 子需求:parent_id(可空)和父卡上的子需求进度(未归档的子需求数 / 其中完成的)。
512
544
  parent_id: row.parent_id || null,
513
- 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) },
514
547
  // 完成时间:进「完成」列的时刻,移出清空(不在「完成」列 = null)。completedAtApprox = 升级前就完成的卡,
515
548
  // 时刻是按 updated_at 补的近似值。completedBy = 谁移进「完成」的(近似值的卡为 null)。旧 App 忽略这三个字段。
516
549
  completedAt: row.completed_at || null,
@@ -562,7 +595,14 @@ function toPublicFor(ctx: RequirementsRequestContext, row: Row) {
562
595
  if (!perms) permsByCtx.set(ctx, perms = taskPermissionsResolver(taskCaller(ctx)));
563
596
  // viewer_can:只对「只看相关任务」的调用者出现,客户端据此画只读锁、藏删除;不出现 = 与今天一样全能(旧 Hub 也不出现)。
564
597
  const can = perms(row);
565
- 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;
566
606
  const hidden = hiddenNodeFilter(ctx, row.network_id);
567
607
  if (!hidden) return pub;
568
608
  return {
@@ -578,6 +618,7 @@ function toPublicFor(ctx: RequirementsRequestContext, row: Row) {
578
618
 
579
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, " +
580
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, " +
581
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";
582
623
 
583
624
  type PersonRef = { kind: 'user' | 'node'; id: string };
@@ -729,7 +770,7 @@ function ifNoneMatchHits(header: string | null, etag: string): boolean {
729
770
  // list_summary:GET 认 view=summary(不带描述正文与子任务条目,见 toSummary);changes:GET 认 changes=1 + updated_since
730
771
  // (改过的卡含归档的,加上 deleted 墓碑与 server_time,见 listChanges)。
731
772
  // events:GET /api/requirements/events —— 字段级的改动流水(谁、何时、旧值 → 新值,requirement-events.ts)。
732
- 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;
733
774
 
734
775
  // ── 子需求 ──
735
776
  // parent_id:同一网络里的另一张卡;不能成环;最多 5 层(顶层是第 1 层)。删父卡 = 子卡保留、parent_id 置空。
@@ -795,13 +836,8 @@ function actorOf(ctx: RequirementsRequestContext): Actor | null {
795
836
  if (!ctx.auth) return null;
796
837
  if (!ctx.isNodeToken) return { kind: "user", id: ctx.auth.userId };
797
838
  const tokenId = ctx.auth.tokenId ?? null;
798
- const bound = tokenId ? db.get<{ bound_node_id: string | null }>("SELECT bound_node_id FROM api_tokens WHERE token_id = ?1", tokenId)?.bound_node_id : null;
799
- if (bound) return { kind: "node", id: bound };
800
- const alias = ctx.auth.tokenName?.startsWith("node:") ? ctx.auth.tokenName.slice(5) : null;
801
- if (alias && ctx.auth.networkId) {
802
- const node = db.get<{ node_id: string }>("SELECT node_id FROM nodes WHERE network_id = ?1 AND alias = ?2", ctx.auth.networkId, alias);
803
- if (node) return { kind: "node", id: node.node_id };
804
- }
839
+ const resolved = tokenId ? resolveNodeCaller(tokenId) : null;
840
+ if (resolved?.ok && resolved.kind === "node") return { kind: "node", id: resolved.nodeId };
805
841
  return { kind: "node", id: `token:${tokenId ?? "unknown"}` };
806
842
  }
807
843
 
@@ -1021,7 +1057,10 @@ function patchRequirement(ctx: RequirementsRequestContext, row: Row, body: Recor
1021
1057
  archived = body.archived ? 1 : 0;
1022
1058
  }
1023
1059
  const updatedAt = new Date().toISOString();
1024
- 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);
1025
1064
  const actor = JSON.stringify(actorOf(ctx));
1026
1065
  // 完成时间:移进「完成」= 此刻 + 这次的操作者;移出 = 清空;留在「完成」(done → done、改别的字段)= 原样不动。
1027
1066
  const completed = column !== "done" ? { at: null, by: null, approx: 0 }
@@ -1087,7 +1126,9 @@ function listFilters(url: URL, sql: string, params: unknown[], ctx?: Requirement
1087
1126
  const status = q.get("status");
1088
1127
  if (status !== null) {
1089
1128
  if (!COLUMNS.has(status)) return jsonError("invalid_status", 400);
1090
- 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)}`;
1091
1132
  }
1092
1133
  const project = q.get("project_id");
1093
1134
  if (project !== null) sql += project === "none" ? " AND project_id IS NULL" : ` AND project_id = ?${params.push(project)}`;
@@ -1323,7 +1364,9 @@ function lastEventsFor(ctx: RequirementsRequestContext, rows: readonly Row[]): M
1323
1364
  ...ids,
1324
1365
  );
1325
1366
  if (!events.length) return out;
1326
- 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 }; });
1327
1370
  const userIds = new Set<string>(), nodeIds = new Set<string>();
1328
1371
  for (const ev of pubs) if (isRef(ev.actor)) (ev.actor.kind === "user" ? userIds : nodeIds).add(ev.actor.id);
1329
1372
  // 名字规则同 GET /api/requirements/people 的 name(没设显示名 → 用户名 / alias / node_name)。
@@ -1388,6 +1431,7 @@ function listEvents(ctx: RequirementsRequestContext): Response {
1388
1431
  const serverTime = new Date().toISOString();
1389
1432
  const caller = taskCaller(ctx);
1390
1433
  const hidden = hiddenNodeFilter(ctx, networkId);
1434
+ const aware = knowsAbandoned(ctx);
1391
1435
  const seen = new Map<string, boolean>();
1392
1436
  const canSee = (id: string): boolean => {
1393
1437
  if (!caller) return true;
@@ -1403,7 +1447,8 @@ function listEvents(ctx: RequirementsRequestContext): Response {
1403
1447
  };
1404
1448
  const shown = (row: EventRow) => {
1405
1449
  if (!canSee(row.requirement_id)) return null;
1406
- const ev = eventPublic(row);
1450
+ const ev = projectEvent(eventPublic(row), aware);
1451
+ if (!ev) return null;
1407
1452
  if (!hidden) return ev;
1408
1453
  const mask = (ref: unknown) => (isHiddenRef(ref, hidden) ? null : ref);
1409
1454
  const masked = { ...ev, actor: mask(ev.actor) };
@@ -1483,7 +1528,7 @@ function listCacheAcl(ctx: RequirementsRequestContext): string | null {
1483
1528
 
1484
1529
  function listCacheKey(ctx: RequirementsRequestContext): string {
1485
1530
  const sorted = [...ctx.url.searchParams.entries()].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
1486
- 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) });
1487
1532
  }
1488
1533
 
1489
1534
  /** Test-only. */
@@ -12,15 +12,20 @@
12
12
  // task row and, once sender labels reach the copresence TUIs, rendered to a
13
13
  // human as an attribution.
14
14
  //
15
- // This is the same decision expressed as a pure function so every credential
16
- // kind is a directly constructible test input rather than a hand-assembled
17
- // Request (the #503 convention in this codebase).
15
+ // Production network tokens pass tokenId and resolve through resolveNodeCaller.
16
+ // A token whose name is not `node:` (not_a_node_token, the registration token
17
+ // before report_status) falls back to nodeAliasForToken, same as main.
18
+ // tokenId omitted keeps the name-only shape used by the unit cases below.
19
+
20
+ import { resolveNodeCaller } from "./create-node.js";
18
21
 
19
22
  export interface RestIdentityInput {
20
23
  /** Raw bearer token, only its prefix matters (`ntok_` = network-bound). */
21
24
  token: string;
22
25
  /** `api_tokens.name`; for node tokens it is `node:<alias>`. */
23
26
  tokenName: string | null | undefined;
27
+ /** Set on the production path. Network tokens then ignore tokenName as authority. */
28
+ tokenId?: string | null;
24
29
  /** Username resolved from the authenticated token. */
25
30
  authenticatedUsername?: string | null;
26
31
  /** Caller-supplied `body.from`. */
@@ -46,8 +51,28 @@ export function nodeAliasForToken(token: string, tokenName: string | null | unde
46
51
  }
47
52
 
48
53
  export function resolveRestFromSession(input: RestIdentityInput): RestIdentityResult {
49
- const alias = nodeAliasForToken(input.token, input.tokenName);
50
54
  const requested = typeof input.requestedFrom === "string" ? input.requestedFrom.trim() : "";
55
+ let alias: string | null;
56
+ if (input.token.startsWith("ntok_") && input.tokenId) {
57
+ const resolved = resolveNodeCaller(input.tokenId);
58
+ if (resolved.ok) {
59
+ alias = resolved.alias;
60
+ } else if (resolved.reason === "not_a_node_token") { // board679-plain-ntok-rest
61
+ // Name is not `node:` yet. Main signs that as api, and refuses a claim.
62
+ // not_owner / bound_node_missing / ambiguous stay the hard reject.
63
+ alias = nodeAliasForToken(input.token, input.tokenName);
64
+ } else {
65
+ return {
66
+ ok: false,
67
+ error: "from_session_identity_mismatch",
68
+ message: "network token from_session does not match token-bound node alias",
69
+ tokenAlias: "",
70
+ requestedFromSession: requested,
71
+ };
72
+ }
73
+ } else {
74
+ alias = nodeAliasForToken(input.token, input.tokenName);
75
+ }
51
76
 
52
77
  // A network-bound token whose name we cannot resolve to an alias (`node:`,
53
78
  // `node: `, or a name that was never node-scoped) is the degenerate case.
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";
@@ -75,6 +75,7 @@ import {
75
75
  TASK_REST_SELECT,
76
76
  } from "./rest-projections.js";
77
77
  import { resolveRestFromSession } from "./rest-identity.js";
78
+ import { resolveNodeCaller } from "./create-node.js";
78
79
  import { stampTaskAuthOrigin, type TaskAuthOrigin } from "./task-auth-origin.js";
79
80
  import { diagnoseTask } from "./task-diagnostic.js";
80
81
  import { assertScheduledTaskBackendSupported, handleScheduledTaskRequest, startScheduledTaskScheduler } from "./scheduled-tasks.js";
@@ -197,12 +198,12 @@ function sweepStaleRateLimits(): void {
197
198
  }
198
199
 
199
200
  // ── Factory: 每个请求创建新的 McpServer(stateless 模式)──
200
- function createServer(clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null, callerTokenIsNetwork = false, callerTokenId?: string | null, includeProtocolTools = false): McpServer {
201
+ function createServer(clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null, callerTokenIsNetwork = false, callerTokenId?: string | null, includeProtocolTools = false, callerIdentityRejected = false): McpServer {
201
202
  const server = new McpServer({
202
203
  name: "commhub",
203
204
  version: "0.5.0",
204
205
  });
205
- registerTools(server, clientIP, enforceNetworkId, enforceUserId, callerAlias, callerTokenIsNetwork, callerTokenId, { includeProtocol: includeProtocolTools });
206
+ registerTools(server, clientIP, enforceNetworkId, enforceUserId, callerAlias, callerTokenIsNetwork, callerTokenId, { includeProtocol: includeProtocolTools }, callerIdentityRejected);
206
207
  return server;
207
208
  }
208
209
 
@@ -272,6 +273,16 @@ function nodeSubscriberInfo(req: Request, server: any, tokenId: string | null, a
272
273
  return { nodeId, instanceId, remote, userAgent: req.headers.get("user-agent")?.slice(0, 200) ?? null };
273
274
  }
274
275
 
276
+ // The stream belongs to a node id, not a name. A session that never stored
277
+ // node_id still owns its stream when this network has exactly one nodes row
278
+ // for the alias. Null does not match null.
279
+ function streamOwnerNodeId(alias: string, networkId: string): string | null {
280
+ const fromSession = db.get<{ node_id: string | null }>("SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2", alias, networkId)?.node_id ?? null;
281
+ if (fromSession) return fromSession;
282
+ const rows = db.all<{ node_id: string }>("SELECT node_id FROM nodes WHERE alias = ?1 AND network_id = ?2", alias, networkId);
283
+ return rows.length === 1 ? rows[0].node_id : null;
284
+ }
285
+
275
286
  function isLocalhostIP(ip: string): boolean {
276
287
  return ip === "127.0.0.1" || ip === "::1" || ip === "::ffff:127.0.0.1" || ip === "localhost";
277
288
  }
@@ -310,17 +321,24 @@ function isHubAdminCredential(resolved: { networkId: string | null; user: { role
310
321
  function nodeTokenAlias(resolved: { tokenName: string | null }): string | null {
311
322
  return resolved.tokenName?.startsWith("node:") ? resolved.tokenName.slice("node:".length) : null;
312
323
  }
313
- function nodeMayRename(resolved: { networkId: string | null; tokenName: string | null }, networkId: unknown, oldAlias: unknown): boolean {
324
+ function resolvedNodeAlias(resolved: { networkId: string | null; tokenName: string | null; tokenId?: string | null }): string | null {
325
+ if (!resolved.tokenId) return nodeTokenAlias(resolved);
326
+ const caller = resolveNodeCaller(resolved.tokenId);
327
+ if (!caller.ok) return null;
328
+ if (resolved.networkId && caller.networkId !== resolved.networkId) return null;
329
+ return caller.alias;
330
+ }
331
+ function nodeMayRename(resolved: { networkId: string | null; tokenName: string | null; tokenId?: string | null }, networkId: unknown, oldAlias: unknown): boolean {
314
332
  if (!resolved.networkId) return true;
315
- return networkId === resolved.networkId && typeof oldAlias === "string" && oldAlias === nodeTokenAlias(resolved);
333
+ return networkId === resolved.networkId && typeof oldAlias === "string" && oldAlias === resolvedNodeAlias(resolved);
316
334
  }
317
- function nodeMayRenameTxn(resolved: { networkId: string | null; tokenName: string | null }, txnId: unknown): boolean {
335
+ function nodeMayRenameTxn(resolved: { networkId: string | null; tokenName: string | null; tokenId?: string | null }, txnId: unknown): boolean {
318
336
  if (!resolved.networkId) return true;
319
337
  if (typeof txnId !== "string") return false;
320
338
  const txn = db.get<{ network_id: string; old_alias: string; new_alias: string }>("SELECT network_id, old_alias, new_alias FROM rename_txn WHERE txn_id = ?1", txnId);
321
339
  if (!txn || txn.network_id !== resolved.networkId) return false;
322
340
  // commit 前后令牌名可能已被改成新 alias(api_tokens.name 跟随改名),两个都算它自己。
323
- const self = nodeTokenAlias(resolved);
341
+ const self = resolvedNodeAlias(resolved);
324
342
  return self === txn.old_alias || self === txn.new_alias;
325
343
  }
326
344
 
@@ -1030,11 +1048,20 @@ return Bun.serve({
1030
1048
  const token = requestToken(req);
1031
1049
  const authCtx = resolveRequestAuth(req);
1032
1050
  const enforceNetId = authCtx?.networkId || null;
1033
- // Derive the calling alias from the token name (e.g., 'node:视频审查')
1034
- // so peer agents see the real sender instead of 'hub' on send_task.
1035
- const callerAlias = authCtx?.tokenName?.startsWith("node:")
1036
- ? authCtx.tokenName.slice("node:".length)
1037
- : (authCtx?.username || null);
1051
+ // A resolved node token speaks as that node. A token whose name is not
1052
+ // `node:` (the registration network token) keeps the username. Only a
1053
+ // `node:` token refused as not_owner / bound_node_missing / ambiguous
1054
+ // is impersonation, and must not fall through to a client-supplied from.
1055
+ let callerAlias: string | null = authCtx?.username || null;
1056
+ let callerIdentityRejected = false;
1057
+ if (token?.startsWith("ntok_")) {
1058
+ const resolvedCaller = authCtx?.tokenId ? resolveNodeCaller(authCtx.tokenId) : null;
1059
+ if (resolvedCaller?.ok) callerAlias = resolvedCaller.alias;
1060
+ else if (resolvedCaller && resolvedCaller.reason !== "not_a_node_token") { // board679-plain-ntok
1061
+ callerAlias = null;
1062
+ callerIdentityRejected = true;
1063
+ }
1064
+ }
1038
1065
  const transport = new WebStandardStreamableHTTPServerTransport({
1039
1066
  sessionIdGenerator: undefined,
1040
1067
  });
@@ -1052,7 +1079,7 @@ return Bun.serve({
1052
1079
  labelRoute(req, mcpParsedBody === undefined ? "?" : mcpRouteLabel(mcpParsedBody));
1053
1080
  }
1054
1081
  // #478:tools/list 按调用者过滤;X-Anet-Tools: all / ?tools=all 时节点令牌也列出协议工具。
1055
- const mcpServer = createServer(clientIP, enforceNetId, authCtx?.userId || null, callerAlias, !!token?.startsWith("ntok_"), authCtx?.tokenId || null, wantsAllTools(req, url));
1082
+ const mcpServer = createServer(clientIP, enforceNetId, authCtx?.userId || null, callerAlias, !!token?.startsWith("ntok_"), authCtx?.tokenId || null, wantsAllTools(req, url), callerIdentityRejected);
1056
1083
  await mcpServer.connect(transport);
1057
1084
  const response = await transport.handleRequest(req, mcpParsedBody === undefined ? undefined : { parsedBody: mcpParsedBody });
1058
1085
  // Disconnect after response to prevent McpServer leak
@@ -1206,11 +1233,22 @@ return Bun.serve({
1206
1233
  nodeId: db.get<{ node_id: string | null }>("SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2", sessionName, scopedNetId)?.node_id ?? null,
1207
1234
  }));
1208
1235
  if (streamDenied) return streamDenied;
1209
- // #507 — the node's OWN stream (token bound to this alias) is node-scoped:
1210
- // at most one per (network, alias), newest wins. A node token watching some
1211
- // other alias stays a plain monitor and can never supersede that node.
1212
- if (authCtx.tokenName === `node:${sessionName}` && authCtx.networkId === scopedNetId) {
1213
- return createSSEStream(sessionName, scopedNetId, { node: nodeSubscriberInfo(req, server, authCtx.tokenId, sessionName, scopedNetId) });
1236
+ // #507 — the node's OWN stream is node-scoped: at most one per (network,
1237
+ // alias), newest wins. Own means the resolved node id equals this stream's
1238
+ // node id. A matching token name is not enough, and a monitor never supersedes.
1239
+ const resolvedCaller = authCtx.tokenId ? resolveNodeCaller(authCtx.tokenId) : null;
1240
+ const streamNodeId = streamOwnerNodeId(sessionName, scopedNetId);
1241
+ if (
1242
+ resolvedCaller?.ok
1243
+ && resolvedCaller.kind === "node"
1244
+ && resolvedCaller.nodeId
1245
+ && streamNodeId
1246
+ && resolvedCaller.nodeId === streamNodeId
1247
+ && resolvedCaller.networkId === scopedNetId
1248
+ ) {
1249
+ const info = nodeSubscriberInfo(req, server, authCtx.tokenId, sessionName, scopedNetId);
1250
+ if (!info.nodeId) info.nodeId = resolvedCaller.nodeId;
1251
+ return createSSEStream(sessionName, scopedNetId, { node: info });
1214
1252
  }
1215
1253
  return createSSEStream(sessionName, scopedNetId);
1216
1254
  }
@@ -1338,7 +1376,7 @@ return Bun.serve({
1338
1376
  }
1339
1377
  try {
1340
1378
  const body = await req.json() as any;
1341
- 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 } });
1342
1380
  if (result.ok) logAudit(result.user!.user_id, body.username, "register", "user", result.user!.user_id);
1343
1381
  return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
1344
1382
  } catch (e: any) {
@@ -1366,7 +1404,7 @@ return Bun.serve({
1366
1404
  { status: 429, headers: { "Retry-After": String(Math.ceil((lock.retryAfterMs ?? 1000) / 1000)) } }
1367
1405
  ));
1368
1406
  }
1369
- 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 });
1370
1408
  if (result.ok) {
1371
1409
  sharedLoginFailureLockout.recordSuccess(body.username);
1372
1410
  logAudit(result.user!.user_id, body.username, "login", "user", result.user!.user_id);
@@ -1395,8 +1433,9 @@ return Bun.serve({
1395
1433
  // RFC-040:managed_department_ids = 我负责的部门 + 全部下级(空 = 不是负责人;viewer 恒空;节点令牌不是人,恒空)。
1396
1434
  const networks = (resolved.networkId ? allNetworks.filter((n: any) => n.network_id === resolved.networkId) : allNetworks)
1397
1435
  .map((n: any) => ({ ...n, managed_department_ids: resolved.networkId ? [] : managedDepartmentIds(n.network_id, resolved.user.user_id) }));
1436
+ const nodeCaller = resolved.networkId && resolved.tokenId ? resolveNodeCaller(resolved.tokenId) : null;
1398
1437
  const credential = resolved.networkId
1399
- ? { kind: "node", network_id: resolved.networkId, node_alias: resolved.tokenName?.startsWith("node:") ? resolved.tokenName.slice(5) : null, acts_as_owner: false }
1438
+ ? { kind: "node", network_id: resolved.networkId, node_alias: nodeCaller?.ok ? nodeCaller.alias : null, acts_as_owner: false }
1400
1439
  : { kind: "user" };
1401
1440
  return withCors(req, Response.json({ ok: true, user: resolved.user, networks, current_network: resolved.networkId, credential }));
1402
1441
  }
@@ -1434,9 +1473,13 @@ return Bun.serve({
1434
1473
  if (isNodeCredential(resolved)) return userTokenRequired(req);
1435
1474
  try {
1436
1475
  const body = await req.json() as any;
1437
- 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 });
1438
1480
  if (result.ok) {
1439
- 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 });
1440
1483
  if (resolved.tokenId) revokeToken(resolved.user.user_id, resolved.tokenId);
1441
1484
  logAudit(resolved.user.user_id, resolved.user.username, "password_changed", "user", resolved.user.user_id);
1442
1485
  return withCors(req, Response.json({ ...result, token: issued.token, token_id: issued.token_id }));
@@ -3546,6 +3589,7 @@ return Bun.serve({
3546
3589
  const identity = resolveRestFromSession({
3547
3590
  token: requestToken(req),
3548
3591
  tokenName: restAuth?.tokenName,
3592
+ tokenId: restAuth?.tokenId,
3549
3593
  authenticatedUsername: restAuth?.username,
3550
3594
  requestedFrom: body.from,
3551
3595
  });