@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.
- package/package.json +1 -1
- package/src/auth-password-change-cli-tokens-http.test.ts +294 -0
- package/src/auth.ts +85 -14
- package/src/binding-generation-http.test.ts +121 -0
- package/src/create-node.ts +70 -26
- package/src/daemon-token-owner-http.test.ts +10 -5
- package/src/db.ts +15 -1
- package/src/node-caller-identity-http.test.ts +714 -0
- package/src/node-daemon-bindings.test.ts +1 -1
- package/src/node-permissions.ts +45 -12
- package/src/requirement-due-reminders.ts +3 -3
- package/src/requirement-notify.ts +1 -1
- package/src/requirements-abandoned-http.test.ts +236 -0
- package/src/requirements-errors.ts +4 -4
- package/src/requirements-http.test.ts +1 -1
- package/src/requirements-list-slim-http.test.ts +2 -2
- package/src/requirements-migrate.ts +34 -0
- package/src/requirements-priority-lowest.test.ts +1 -1
- package/src/requirements-stats.ts +4 -1
- package/src/requirements.ts +62 -17
- package/src/rest-identity.ts +29 -4
- package/src/server.ts +67 -23
- package/src/tools.ts +55 -26
package/src/requirements.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
799
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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. */
|
package/src/rest-identity.ts
CHANGED
|
@@ -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
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
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
|
|
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 ===
|
|
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 =
|
|
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
|
-
//
|
|
1034
|
-
//
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
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
|
|
1210
|
-
//
|
|
1211
|
-
//
|
|
1212
|
-
|
|
1213
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|