@sleep2agi/commhub-server 0.9.0-preview.90 → 0.9.0-preview.92

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/src/server.ts CHANGED
@@ -5,6 +5,7 @@ import { statusAliasResolverRead } from "./status-alias-resolver.js";
5
5
  import { readNodeHealth } from "./node-health-store.js";
6
6
  import { assertNodeHealthy, degradedLayers } from "./node-health-guard.js";
7
7
  import { auditDepartment, createDepartment, deleteDepartment, listDepartments, setMemberDepartment, updateDepartment } from "./departments.js";
8
+ import { DEPARTMENT_SCOPE_DENIED, departmentLeaders, departmentSubtree, headScope, listDepartmentProjectGrants, managedDepartmentIds, membersIn, replaceDepartmentProjectGrants } from "./department-heads.js";
8
9
  import { redactMessageRow } from "./redact-tokens.js";
9
10
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
11
  import { parseDbTimestampMs } from "./db-timestamp.js";
@@ -23,10 +24,11 @@ import { restrictedMemberSeesFile, restrictedMemberAttachmentsDenied } from "./r
23
24
  import { dmParticipantSeesFile, listDmThread, listDmThreads, sendHumanDm } from "./human-dm.js";
24
25
  import { getTaskAccessMode, isTaskScoped, listProjectGrants, replaceTaskGrants, NEW_MEMBER_TASK_ACCESS } from "./task-access.js";
25
26
  import { canMessageAgent, isAgentRestricted, listAgentGrants, listGroupGrants, getAgentAccessMode, replaceAgentGrants, restrictedNetworkIds, usernameIsAgentAlias, listAgentGroups, createAgentGroup, updateAgentGroup, replaceAgentGroupMembers, deleteAgentGroup, usersGrantedGroup } from "./agent-access.js";
27
+ import { broadcastVerdict, dispatchVerdict, humanOnlyVerdict, NODE_PERMISSION_MODES, nodeDecide, nodeIdentity, nodePermissionDeniedBody, nodePermissionsFlag, streamVerdict, writeVerdict, type NodeIdentity, type NodePermissionMode, type Verdict } from "./node-permissions.js";
26
28
  import { validateAvatarUrl } from "./avatar-validate.js";
27
29
  import { narrowTags, parseStoredTags, validateScalarAttr } from "./node-attrs-validate.js";
28
30
  import { daemonDefaultWorkdirRoot } from "./create-node-validate.js";
29
- import { register, login, resolveToken, 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";
31
+ 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";
30
32
  import { abortRename, cleanupCommittedRenameSessions, commitRename, prepareRename, resolveCanonicalAlias } from "./rename.js";
31
33
  import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
32
34
  import { clientRequestIdFromMeta, idempotentTaskId, idempotentTaskMatches, type StoredIdempotentTask } from "./task-idempotency.js";
@@ -224,7 +226,8 @@ function requireAuth(req: Request, options: RequestTokenOptions = {}): Response
224
226
  return null;
225
227
  }
226
228
 
227
- return Response.json({ error: "unauthorized" }, { status: 401 });
229
+ // #488 —— 令牌存在但因主人的成员身份被拒:说清楚为什么(reason + hint),节点日志里一眼看懂为什么掉线。
230
+ return Response.json({ error: "unauthorized", ...(tokenRejection(token) ?? {}) }, { status: 401 });
228
231
  }
229
232
 
230
233
  function getClientIP(req: Request, server?: any): string {
@@ -316,6 +319,18 @@ function countNetworkUploads(networkId: string): number {
316
319
  return count;
317
320
  }
318
321
 
322
+ // RFC-041(#487):节点令牌的一次 REST 写 / 订阅先按 node-permissions.ts 判。用户令牌 → null(不判)。
323
+ function restNodeDenied(req: Request, restAuth: { networkId: string | null; tokenId: string | null } | null, route: string, verdictOf: (id: NodeIdentity) => Verdict): Response | null {
324
+ if (!restAuth?.networkId) return null;
325
+ const id = nodeIdentity(restAuth.tokenId, restAuth.networkId);
326
+ if (!id) return null;
327
+ const verdict = verdictOf(id);
328
+ return nodeDecide(id, route, verdict) ? withCors(req, Response.json(nodePermissionDeniedBody(verdict!.reason, route), { status: 403 })) : null;
329
+ }
330
+ /** 写别的节点 = 只有人能做;写自己 = 只看模式。 */
331
+ const nodeTargetVerdict = (targetNodeId: string) => (id: NodeIdentity): Verdict =>
332
+ id.nodeId && id.nodeId === targetNodeId ? writeVerdict(id, targetNodeId) : humanOnlyVerdict(id, targetNodeId);
333
+
319
334
  function userTokenRequired(req: Request): Response {
320
335
  return withCors(req, Response.json({ ok: false, error: "user_token_required", message: "this endpoint acts on a user account; node tokens cannot use it" }, { status: 403 }));
321
336
  }
@@ -324,7 +339,7 @@ function requireAdminAuth(req: Request): Response | null {
324
339
  const token = requestToken(req);
325
340
  if (!token) return Response.json({ ok: false, error: "auth required" }, { status: 401 });
326
341
  const resolved = resolveToken(token);
327
- if (!resolved) return Response.json({ ok: false, error: "invalid token" }, { status: 401 });
342
+ if (!resolved) return Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 });
328
343
  if (!isHubAdminCredential(resolved)) return Response.json({ ok: false, error: "admin required" }, { status: 403 });
329
344
  return null;
330
345
  }
@@ -1005,11 +1020,9 @@ return Bun.serve({
1005
1020
  // 1. legacy AUTH_TOKEN (master) → any network
1006
1021
  // 2/3. ntok_ / utok_ → must CURRENTLY be a member of the requested
1007
1022
  // network. Membership is checked unconditionally — there is NO
1008
- // token-bound-network shortcut. removeNetworkMember deletes the
1009
- // membership row but does NOT revoke the user's ntok, so the
1010
- // network_members lookup IS the revocation mechanism: an ntok
1011
- // bound to this network whose owner was removed must lose the
1012
- // stream (审查修复 per 通信龙 #461 review, finding 1).
1023
+ // token-bound-network shortcut (审查修复 per 通信龙 #461 review,
1024
+ // finding 1). Since #488 removeNetworkMember also revokes the
1025
+ // user's tokens and resolveToken refuses a non-member's ntok.
1013
1026
  const netEventsMatch = url.pathname.match(/^\/events\/network\/(.+)$/);
1014
1027
  if (netEventsMatch && req.method === "GET") {
1015
1028
  const authErr = requireAuth(req);
@@ -1026,6 +1039,8 @@ return Bun.serve({
1026
1039
  if (!observerRole) {
1027
1040
  return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
1028
1041
  }
1042
+ const streamDenied = restNodeDenied(req, authCtx, "SSE /events/network/:id", (id) => streamVerdict(id, "network"));
1043
+ if (streamDenied) return streamDenied;
1029
1044
  // 多用户 Agent 权限:受限成员只收到自己是一端的路由事件(自己发的 / 发给自己的),
1030
1045
  // 看不到网络里别人和各个 Agent 之间的流量。
1031
1046
  if (isAgentRestricted(authCtx.userId, observedNetId)) {
@@ -1101,6 +1116,11 @@ return Bun.serve({
1101
1116
  if (!session && authCtx.networkId !== scopedNetId) {
1102
1117
  return withCors(req, Response.json({ ok: false, error: "session not in requested network" }, { status: 403 }));
1103
1118
  }
1119
+ const streamDenied = restNodeDenied(req, authCtx, "SSE /events/:session", (id) => streamVerdict(id, {
1120
+ alias: sessionName,
1121
+ nodeId: db.get<{ node_id: string | null }>("SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2", sessionName, scopedNetId)?.node_id ?? null,
1122
+ }));
1123
+ if (streamDenied) return streamDenied;
1104
1124
  return createSSEStream(sessionName, scopedNetId);
1105
1125
  }
1106
1126
 
@@ -1277,11 +1297,13 @@ return Bun.serve({
1277
1297
  const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
1278
1298
  if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
1279
1299
  const resolved = resolveToken(token);
1280
- if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1300
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
1281
1301
  // 节点令牌:user 仍是签发它的用户(旧 agent-node 靠它打日志),但只列它绑定的那个网络,
1282
1302
  // 并用 credential 说清楚这是一个节点身份 —— user.role 不是这个调用者的权限。
1283
1303
  const allNetworks = getUserAllNetworks(resolved.user.user_id);
1284
- const networks = resolved.networkId ? allNetworks.filter((n: any) => n.network_id === resolved.networkId) : allNetworks;
1304
+ // RFC-040:managed_department_ids = 我负责的部门 + 全部下级(空 = 不是负责人;viewer 恒空;节点令牌不是人,恒空)。
1305
+ const networks = (resolved.networkId ? allNetworks.filter((n: any) => n.network_id === resolved.networkId) : allNetworks)
1306
+ .map((n: any) => ({ ...n, managed_department_ids: resolved.networkId ? [] : managedDepartmentIds(n.network_id, resolved.user.user_id) }));
1285
1307
  const credential = resolved.networkId
1286
1308
  ? { kind: "node", network_id: resolved.networkId, node_alias: resolved.tokenName?.startsWith("node:") ? resolved.tokenName.slice(5) : null, acts_as_owner: false }
1287
1309
  : { kind: "user" };
@@ -1292,7 +1314,7 @@ return Bun.serve({
1292
1314
  const token = req.headers.get("Authorization")?.replace("Bearer ", "");
1293
1315
  if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
1294
1316
  const resolved = resolveToken(token);
1295
- if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1317
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
1296
1318
  if (isNodeCredential(resolved)) return userTokenRequired(req);
1297
1319
  try {
1298
1320
  const body = await req.json() as any;
@@ -1317,7 +1339,7 @@ return Bun.serve({
1317
1339
  const token = req.headers.get("Authorization")?.replace("Bearer ", "");
1318
1340
  if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
1319
1341
  const resolved = resolveToken(token);
1320
- if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1342
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
1321
1343
  if (isNodeCredential(resolved)) return userTokenRequired(req);
1322
1344
  try {
1323
1345
  const body = await req.json() as any;
@@ -1339,7 +1361,7 @@ return Bun.serve({
1339
1361
  const token = req.headers.get("Authorization")?.replace("Bearer ", "");
1340
1362
  if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1341
1363
  const resolved = resolveToken(token);
1342
- if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1364
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
1343
1365
  if (isNodeCredential(resolved)) return userTokenRequired(req);
1344
1366
  try {
1345
1367
  const body = await req.json() as any;
@@ -1359,7 +1381,7 @@ return Bun.serve({
1359
1381
  const token = req.headers.get("Authorization")?.replace("Bearer ", "");
1360
1382
  if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1361
1383
  const resolved = resolveToken(token);
1362
- if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1384
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
1363
1385
  try {
1364
1386
  const body = await req.json() as any;
1365
1387
  if (!body.network_id || !body.old_alias || !body.new_alias) {
@@ -1378,7 +1400,7 @@ return Bun.serve({
1378
1400
  const token = req.headers.get("Authorization")?.replace("Bearer ", "");
1379
1401
  if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1380
1402
  const resolved = resolveToken(token);
1381
- if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1403
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
1382
1404
  try {
1383
1405
  const body = await req.json() as any;
1384
1406
  if (!body.txn_id) return withCors(req, Response.json({ ok: false, error: "txn_id required" }, { status: 400 }));
@@ -1395,7 +1417,7 @@ return Bun.serve({
1395
1417
  const token = req.headers.get("Authorization")?.replace("Bearer ", "");
1396
1418
  if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1397
1419
  const resolved = resolveToken(token);
1398
- if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1420
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
1399
1421
  try {
1400
1422
  const body = await req.json() as any;
1401
1423
  if (!body.txn_id) return withCors(req, Response.json({ ok: false, error: "txn_id required" }, { status: 400 }));
@@ -1413,7 +1435,7 @@ return Bun.serve({
1413
1435
  const token = req.headers.get("Authorization")?.replace("Bearer ", "");
1414
1436
  if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1415
1437
  const resolved = resolveToken(token);
1416
- if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1438
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
1417
1439
  if (isNodeCredential(resolved)) return userTokenRequired(req);
1418
1440
  const tokens = listTokens(resolved.user.user_id);
1419
1441
  return withCors(req, Response.json({ ok: true, tokens }));
@@ -1423,7 +1445,7 @@ return Bun.serve({
1423
1445
  const token = req.headers.get("Authorization")?.replace("Bearer ", "");
1424
1446
  if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1425
1447
  const resolved = resolveToken(token);
1426
- if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1448
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
1427
1449
  if (isNodeCredential(resolved)) return userTokenRequired(req);
1428
1450
  try {
1429
1451
  const body = await req.json() as any;
@@ -1440,7 +1462,7 @@ return Bun.serve({
1440
1462
  const token = req.headers.get("Authorization")?.replace("Bearer ", "");
1441
1463
  if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1442
1464
  const resolved = resolveToken(token);
1443
- if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1465
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
1444
1466
  if (isNodeCredential(resolved)) return userTokenRequired(req);
1445
1467
  const result = revokeToken(resolved.user.user_id, tokenDeleteMatch[1]);
1446
1468
  if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "token_revoked", "token", tokenDeleteMatch[1]);
@@ -1453,7 +1475,7 @@ return Bun.serve({
1453
1475
  const token = requestToken(req, { allowQueryToken: false });
1454
1476
  if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1455
1477
  const resolved = resolveToken(token);
1456
- if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1478
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
1457
1479
  if (isNodeCredential(resolved)) return userTokenRequired(req);
1458
1480
  const sessions = listSessions(resolved.user.user_id, resolved.tokenId);
1459
1481
  return withCors(req, Response.json({ ok: true, sessions, current_token_id: resolved.tokenId, idle_timeout_days: sessionIdleDays() }));
@@ -1463,7 +1485,7 @@ return Bun.serve({
1463
1485
  const token = requestToken(req, { allowQueryToken: false });
1464
1486
  if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1465
1487
  const resolved = resolveToken(token);
1466
- if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1488
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
1467
1489
  if (isNodeCredential(resolved)) return userTokenRequired(req);
1468
1490
  // 当前令牌必须是一次登录会话,否则「保留当前这个」无从说起(API 令牌调用会把所有会话都撤掉)。
1469
1491
  const current = resolved.tokenId ? listSessions(resolved.user.user_id, resolved.tokenId).find((x) => x.is_current) : undefined;
@@ -1478,7 +1500,7 @@ return Bun.serve({
1478
1500
  const token = requestToken(req, { allowQueryToken: false });
1479
1501
  if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1480
1502
  const resolved = resolveToken(token);
1481
- if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1503
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
1482
1504
  if (isNodeCredential(resolved)) return userTokenRequired(req);
1483
1505
  const targetId = decodeURIComponent(sessionDeleteMatch[1]);
1484
1506
  const result = revokeSession(resolved.user.user_id, targetId);
@@ -1492,7 +1514,7 @@ return Bun.serve({
1492
1514
  const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
1493
1515
  if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
1494
1516
  const resolved = resolveToken(token);
1495
- if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1517
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
1496
1518
  // V3.13: ntok_ can only see its bound network; utok_ sees all member networks
1497
1519
  if (resolved.networkId) {
1498
1520
  // ntok_ — only return the bound network
@@ -1522,7 +1544,7 @@ return Bun.serve({
1522
1544
  const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
1523
1545
  if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
1524
1546
  const resolved = resolveToken(token);
1525
- if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1547
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
1526
1548
  if (isNodeCredential(resolved)) return userTokenRequired(req);
1527
1549
  try {
1528
1550
  const body = await req.json() as any;
@@ -1722,25 +1744,76 @@ return Bun.serve({
1722
1744
  }
1723
1745
 
1724
1746
  // ── 组织架构(board #419):部门 + 成员所在部门。读:网络任何成员 / 本网络的节点令牌 / Hub 管理员;
1725
- // 写:网络 owner / admin、Hub 管理员(节点令牌不能写)。规则在 departments.ts。 ──
1747
+ // 写:网络 owner / admin、Hub 管理员(节点令牌不能写)。规则在 departments.ts。
1748
+ // RFC-040(#455)部门负责人:每次请求按 leader_user_id 现算「我负责的部门 + 全部下级」(department-heads.ts),
1749
+ // 在这棵子树里也能写;越出子树 → 403 department_scope_denied(固定一个错误)。不是负责人的成员与以前逐字节相同。 ──
1726
1750
  const deptMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/departments(?:\/([^/]+))?$/);
1727
1751
  const memberDeptMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/members\/([^/]+)\/department$/);
1728
- if (deptMatch || memberDeptMatch) {
1752
+ const deptSubMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/departments\/([^/]+)\/(project-grants|nodes)$/);
1753
+ if (deptMatch || memberDeptMatch || deptSubMatch) {
1729
1754
  const token = requestToken(req, { allowQueryToken: false });
1730
1755
  const resolved = token ? resolveToken(token) : null;
1731
1756
  if (!resolved) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1732
- const netId = decodeURIComponent((deptMatch ?? memberDeptMatch)![1]);
1757
+ const netId = decodeURIComponent((deptMatch ?? memberDeptMatch ?? deptSubMatch)![1]);
1733
1758
  if (resolved.networkId && resolved.networkId !== netId) return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
1734
1759
  const hubAdmin = isHubAdminCredential(resolved);
1735
1760
  const role = resolved.networkId ? null : getUserNetworkRole(resolved.user.user_id, netId);
1736
1761
  if (!hubAdmin && !role && !resolved.networkId) return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
1737
1762
  if (!db.get("SELECT 1 AS x FROM networks WHERE network_id = ?1", netId)) return withCors(req, Response.json({ ok: false, error: "network_not_found" }, { status: 404 }));
1763
+ const canManage = hubAdmin || (!resolved.networkId && (role === "owner" || role === "admin"));
1764
+ // 负责人范围:只算人(节点令牌不是人);owner / admin 本来全管,不必算。viewer 在 headScope 里恒为空。
1765
+ const head = canManage || resolved.networkId ? { managed: new Set<string>(), strict: new Set<string>() } : headScope(netId, resolved.user.user_id);
1766
+ const isHead = head.managed.size > 0;
1767
+ const scopeDenied = () => withCors(req, Response.json({ ok: false, error: DEPARTMENT_SCOPE_DENIED }, { status: 403 }));
1738
1768
  if (deptMatch && !deptMatch[2] && req.method === "GET") {
1739
- return withCors(req, Response.json({ ok: true, network_id: netId, ...listDepartments(netId) }));
1769
+ const listed = listDepartments(netId);
1770
+ // viewer_can:manage = 能改名 / 移动 / 删除 / 换负责人;create_child = 能在它下面建子部门。
1771
+ // project_grants(部门项目授权)只给 owner / admin:项目 id 不对只看相关任务的成员公开。
1772
+ const departments = listed.departments.map((d) => ({ ...d, viewer_can: { manage: canManage || head.strict.has(d.id), create_child: canManage || head.managed.has(d.id) } }));
1773
+ return withCors(req, Response.json({ ok: true, network_id: netId, departments, members: listed.members, ...(canManage ? { project_grants: listDepartmentProjectGrants(netId) } : {}) }));
1774
+ }
1775
+ if (deptSubMatch) {
1776
+ const deptId = decodeURIComponent(deptSubMatch[2]);
1777
+ const subtree = departmentSubtree(netId, deptId);
1778
+ if (!subtree.size) return withCors(req, Response.json({ ok: false, error: "department_not_found" }, { status: 404 }));
1779
+ if (deptSubMatch[3] === "nodes") {
1780
+ // 本部门(含下级)成员拥有的节点:只读的状态与健康(RFC-040 Q3 —— 能管人 ≠ 能用 / 能管他的 Agent)。
1781
+ if (req.method !== "GET") return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
1782
+ if (!canManage && !head.managed.has(deptId)) return scopeDenied();
1783
+ const owners = membersIn(netId, subtree);
1784
+ const rows = db.all<{ node_id: string; alias: string | null; display_name: string | null; owner_user_id: string | null }>(
1785
+ "SELECT node_id, alias, display_name, owner_user_id FROM nodes WHERE network_id = ?1 AND owner_user_id IS NOT NULL ORDER BY alias, node_id", netId,
1786
+ ).filter((n) => owners.has(n.owner_user_id!));
1787
+ const nodes = rows.map((n) => {
1788
+ const session = db.get<{ status: string | null; last_seen_at: string | null }>(
1789
+ "SELECT status, last_seen_at FROM sessions WHERE node_id = ?1 AND network_id = ?2 ORDER BY last_seen_at DESC LIMIT 1", n.node_id, netId,
1790
+ );
1791
+ const h = n.alias ? readNodeHealth(netId, n.alias) : null;
1792
+ return {
1793
+ node_id: n.node_id, alias: n.alias, display_name: n.display_name, owner_user_id: n.owner_user_id,
1794
+ status: session?.status ?? "offline", last_seen_at: session?.last_seen_at ?? null,
1795
+ health: h?.health ?? null, degraded: degradedLayers(h?.health).map((l) => ({ layer: l.layer, label: l.label, reason: l.reason })),
1796
+ };
1797
+ });
1798
+ return withCors(req, Response.json({ ok: true, network_id: netId, department_id: deptId, nodes }));
1799
+ }
1800
+ // 部门项目授权:只有 owner / admin(和按人授权一样)。
1801
+ if (!canManage) return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
1802
+ if (req.method === "GET") {
1803
+ return withCors(req, Response.json({ ok: true, network_id: netId, department_id: deptId, project_grants: listDepartmentProjectGrants(netId, deptId).map(({ project_id, can_edit }) => ({ project_id, can_edit })) }));
1804
+ }
1805
+ if (req.method !== "PUT") return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
1806
+ let grantBody: Record<string, unknown> = {};
1807
+ try { const b = await req.json(); grantBody = b && typeof b === "object" && !Array.isArray(b) ? b as Record<string, unknown> : {}; } catch { return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 })); }
1808
+ const r = replaceDepartmentProjectGrants(netId, deptId, grantBody.project_grants, resolved.user.user_id);
1809
+ if (!r.ok) return withCors(req, Response.json({ ok: false, error: r.error, ...(r.detail ? { detail: r.detail } : {}) }, { status: r.status }));
1810
+ auditDepartment(resolved.user, "department_project_grants_set", netId, JSON.stringify({ id: deptId, project_grants: r.project_grants }));
1811
+ // 子树里的人看见的卡变了:断开实时流让它按新权限重连。
1812
+ for (const uid of membersIn(netId, subtree)) closeUserStreamsInNetwork(netId, uid);
1813
+ return withCors(req, Response.json({ ok: true, network_id: netId, department_id: deptId, project_grants: r.project_grants }));
1740
1814
  }
1741
- const canManage = hubAdmin || (!resolved.networkId && (role === "owner" || role === "admin"));
1742
1815
  if (!["POST", "PATCH", "PUT", "DELETE"].includes(req.method)) return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
1743
- if (!canManage) return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
1816
+ if (!canManage && !isHead) return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
1744
1817
  let body: Record<string, unknown> = {};
1745
1818
  if (req.method !== "DELETE") {
1746
1819
  try { const b = await req.json(); body = b && typeof b === "object" && !Array.isArray(b) ? b as Record<string, unknown> : {}; } catch { return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 })); }
@@ -1749,27 +1822,66 @@ return Bun.serve({
1749
1822
  const { status, ...rest } = r;
1750
1823
  return withCors(req, Response.json(rest, { status: r.ok ? okStatus : status ?? 400 }));
1751
1824
  };
1825
+ // 负责人的写:只在本部门子树里。部门 / 上级不存在的情况交给 departments.ts 回原来的 404 / 400。
1826
+ const exists = (id: unknown) => typeof id === "string" && departmentSubtree(netId, id).size > 0;
1827
+ const blank = (v: unknown) => v === undefined || v === null || v === "";
1828
+ const inManaged = (id: unknown) => typeof id === "string" && head.managed.has(id);
1829
+ const leaderOk = (v: unknown) => blank(v) || (typeof v === "string" && membersIn(netId, head.managed).has(v));
1830
+ const via = canManage ? "admin" : "leader";
1831
+ // 组织架构变了,负责人们的「本部门」跟着变:断开他们(和被调动的人)的实时流,按新权限重连。
1832
+ const leadersBefore = departmentLeaders(netId);
1833
+ const kick = (extra: string[] = []) => { for (const uid of new Set([...leadersBefore, ...departmentLeaders(netId), ...extra])) closeUserStreamsInNetwork(netId, uid); };
1752
1834
  if (memberDeptMatch) {
1753
1835
  if (req.method !== "PUT") return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
1754
1836
  const targetUid = decodeURIComponent(memberDeptMatch[2]);
1837
+ if (!canManage) {
1838
+ // 调动只在子树内:人原来在本部门(含下级),目标也在本部门。调进 / 调出(含「未分配」)归管理员。
1839
+ const current = db.get<{ department_id: string | null }>("SELECT department_id FROM network_members WHERE network_id = ?1 AND user_id = ?2", netId, targetUid);
1840
+ if (current && (!inManaged(current.department_id) || (!inManaged(body.department_id) && (blank(body.department_id) || exists(body.department_id))))) return scopeDenied();
1841
+ }
1755
1842
  const r = setMemberDepartment(netId, targetUid, body.department_id);
1756
- if (r.ok) auditDepartment(resolved.user, "member_department_set", netId, `${targetUid} → ${r.department_id ?? "(unassigned)"}`);
1843
+ if (r.ok) {
1844
+ auditDepartment(resolved.user, "member_department_set", netId, `${targetUid} → ${r.department_id ?? "(unassigned)"}${via === "leader" ? " via=leader" : ""}`);
1845
+ kick([targetUid]);
1846
+ }
1757
1847
  return reply(r);
1758
1848
  }
1759
1849
  const deptId = deptMatch![2] ? decodeURIComponent(deptMatch![2]) : null;
1850
+ const tag = (detail: Record<string, unknown>) => JSON.stringify(via === "leader" ? { ...detail, via } : detail);
1760
1851
  if (!deptId && req.method === "POST") {
1852
+ if (!canManage) {
1853
+ // 只能建在本部门(含下级)下面;建顶层部门归管理员。新部门的负责人必须是本部门成员。
1854
+ if (blank(body.parent_id) || (exists(body.parent_id) && !inManaged(body.parent_id))) return scopeDenied();
1855
+ if (!leaderOk(body.leader_user_id)) return scopeDenied();
1856
+ }
1761
1857
  const r = createDepartment(netId, resolved.user.user_id, body);
1762
- if (r.ok) auditDepartment(resolved.user, "department_created", netId, JSON.stringify({ id: r.department.id, name: r.department.name, parent_id: r.department.parent_id }));
1858
+ if (r.ok) {
1859
+ auditDepartment(resolved.user, "department_created", netId, tag({ id: r.department.id, name: r.department.name, parent_id: r.department.parent_id }));
1860
+ kick();
1861
+ }
1763
1862
  return reply(r, 201);
1764
1863
  }
1765
1864
  if (deptId && req.method === "PATCH") {
1865
+ if (!canManage && exists(deptId)) {
1866
+ // 自己负责的那个部门(名字 / 上级 / 负责人)归上一级负责人或管理员;下级部门只能在子树里挪。
1867
+ if (!head.strict.has(deptId)) return scopeDenied();
1868
+ if (body.parent_id !== undefined && (blank(body.parent_id) || (exists(body.parent_id) && !inManaged(body.parent_id)))) return scopeDenied();
1869
+ if (body.leader_user_id !== undefined && !leaderOk(body.leader_user_id)) return scopeDenied();
1870
+ }
1766
1871
  const r = updateDepartment(netId, deptId, body);
1767
- if (r.ok) auditDepartment(resolved.user, "department_updated", netId, JSON.stringify({ id: deptId, ...body }));
1872
+ if (r.ok) {
1873
+ auditDepartment(resolved.user, "department_updated", netId, tag({ id: deptId, ...body }));
1874
+ kick();
1875
+ }
1768
1876
  return reply(r);
1769
1877
  }
1770
1878
  if (deptId && req.method === "DELETE") {
1879
+ if (!canManage && exists(deptId) && !head.strict.has(deptId)) return scopeDenied();
1771
1880
  const r = deleteDepartment(netId, deptId);
1772
- if (r.ok) auditDepartment(resolved.user, "department_deleted", netId, deptId);
1881
+ if (r.ok) {
1882
+ auditDepartment(resolved.user, "department_deleted", netId, via === "leader" ? `${deptId} via=leader` : deptId);
1883
+ kick();
1884
+ }
1773
1885
  return reply(r as { ok: boolean; status?: number } & Record<string, unknown>);
1774
1886
  }
1775
1887
  return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
@@ -1781,7 +1893,7 @@ return Bun.serve({
1781
1893
  const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
1782
1894
  if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1783
1895
  const resolved = resolveToken(token);
1784
- if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1896
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
1785
1897
  if (isNodeCredential(resolved)) return userTokenRequired(req);
1786
1898
  const netId = membersMatch[1];
1787
1899
  const targetUid = membersMatch[2];
@@ -1833,7 +1945,7 @@ return Bun.serve({
1833
1945
  const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
1834
1946
  if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1835
1947
  const resolved = resolveToken(token);
1836
- if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1948
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
1837
1949
  if (isNodeCredential(resolved)) return userTokenRequired(req);
1838
1950
  const netId = url.pathname.split("/")[3];
1839
1951
  const callerRole = getUserNetworkRole(resolved.user.user_id, netId);
@@ -1850,7 +1962,7 @@ return Bun.serve({
1850
1962
  const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
1851
1963
  if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1852
1964
  const resolved = resolveToken(token);
1853
- if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1965
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
1854
1966
  if (isNodeCredential(resolved)) return userTokenRequired(req);
1855
1967
  const body = await req.json() as any;
1856
1968
  const result = joinByInvite(body.invite_code, resolved.user.user_id);
@@ -1908,7 +2020,7 @@ return Bun.serve({
1908
2020
  const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
1909
2021
  if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1910
2022
  const resolved = resolveToken(token);
1911
- if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
2023
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
1912
2024
  const networkId = netDetailMatch[1];
1913
2025
  const network = db.get<any>(`SELECT ${NETWORK_REST_SELECT} FROM networks WHERE network_id = ?1`, networkId);
1914
2026
  if (!network) return withCors(req, Response.json({ ok: false, error: "network not found" }, { status: 404 }));
@@ -1940,7 +2052,7 @@ return Bun.serve({
1940
2052
  const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
1941
2053
  if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1942
2054
  const resolved = resolveToken(token);
1943
- if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
2055
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
1944
2056
  if (isNodeCredential(resolved)) return userTokenRequired(req);
1945
2057
  // Hub 管理员删别人的网络:同一个资源、同一个动词,只是换一条判据 —— 只允许空网络
1946
2058
  // (计数见 auth.ts adminDeleteEmptyNetwork)。owner 自己删仍走下面原来的路径,行为不变。
@@ -1964,7 +2076,7 @@ return Bun.serve({
1964
2076
  const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
1965
2077
  if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1966
2078
  const resolved = resolveToken(token);
1967
- if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
2079
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
1968
2080
  if (isNodeCredential(resolved)) return userTokenRequired(req);
1969
2081
  try {
1970
2082
  const body = await req.json() as any;
@@ -2177,6 +2289,67 @@ return Bun.serve({
2177
2289
  // GET /api/dm?network_id=&with=<user_id>[&limit&before] → 双向会话记录(新的在前)
2178
2290
  // GET /api/dm/threads?network_id= → 每个对方一行 + 未读数
2179
2291
  // 已读沿用 POST /api/messages/ack(私信就是收件人 user_inbox 里的行)。
2292
+ // ── RFC-041(#487):节点自己的权限 —— 改模式、看「本来会拦」的报表。只给人(用户令牌)。 ──
2293
+ const permModeMatch = url.pathname.match(/^\/api\/nodes\/([^/]+)\/permission-mode$/);
2294
+ if (permModeMatch && req.method === "PUT") {
2295
+ if (!restAuth) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
2296
+ if (restAuth.networkId) return userTokenRequired(req);
2297
+ let body: any;
2298
+ try { body = await req.json(); } catch { return withCors(req, Response.json({ ok: false, error: "invalid_json" }, { status: 400 })); }
2299
+ const mode = body?.mode;
2300
+ if (!NODE_PERMISSION_MODES.includes(mode)) {
2301
+ return withCors(req, Response.json({ ok: false, error: "invalid_permission_mode", message: `mode must be one of ${NODE_PERMISSION_MODES.join(", ")}` }, { status: 400 }));
2302
+ }
2303
+ const nodeRef = decodeURIComponent(permModeMatch[1]);
2304
+ const node = db.get<{ node_id: string; network_id: string | null; owner_user_id: string | null; permission_mode: string | null }>(
2305
+ "SELECT node_id, network_id, owner_user_id, permission_mode FROM nodes WHERE node_id = ?1", nodeRef);
2306
+ const role = node?.network_id ? getUserNetworkRole(restAuth.userId, node.network_id) : null;
2307
+ // 看不见这个节点(不在它的网络里)与不存在同一个 404。
2308
+ if (!node || !node.network_id || (!role && !isAdmin)) return withCors(req, Response.json({ ok: false, error: "node_not_found" }, { status: 404 }));
2309
+ // 节点主人、网络 owner / admin、Hub 管理员能改;部门负责人和别的成员不能(RFC-041 §2.3 / §2.4)。
2310
+ if (!isAdmin && node.owner_user_id !== restAuth.userId && role !== "owner" && role !== "admin") {
2311
+ return withCors(req, Response.json({ ok: false, error: "permission_denied", message: "only the node's owner or a network owner/admin can change its permission mode" }, { status: 403 }));
2312
+ }
2313
+ const previous = (node.permission_mode || "normal") as NodePermissionMode;
2314
+ db.run("UPDATE nodes SET permission_mode = ?1 WHERE node_id = ?2", [mode, node.node_id]);
2315
+ logAudit(restAuth.userId, restAuth.username, "node_permission_mode_changed", "node", node.node_id, JSON.stringify({ from: previous, to: mode }), getClientIP(req), node.network_id);
2316
+ return withCors(req, Response.json({ ok: true, node_id: node.node_id, permission_mode: mode, previous }));
2317
+ }
2318
+ const permReportMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/node-permission-report$/);
2319
+ if (permReportMatch && req.method === "GET") {
2320
+ if (!restAuth) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
2321
+ if (restAuth.networkId) return userTokenRequired(req);
2322
+ const networkId = decodeURIComponent(permReportMatch[1]);
2323
+ const role = getUserNetworkRole(restAuth.userId, networkId);
2324
+ if (!isAdmin && role !== "owner" && role !== "admin") {
2325
+ return withCors(req, Response.json({ ok: false, error: "permission_denied", message: "network owner/admin only" }, { status: 403 }));
2326
+ }
2327
+ const sinceRaw = url.searchParams.get("since");
2328
+ const sinceMs = sinceRaw ? Date.parse(sinceRaw) : Date.now() - 7 * 86_400_000;
2329
+ if (!Number.isFinite(sinceMs)) return withCors(req, Response.json({ ok: false, error: "invalid_since" }, { status: 400 }));
2330
+ const sinceHour = new Date(sinceMs).toISOString().slice(0, 13);
2331
+ const rows = db.all<{ node_id: string; route: string; reason: string; hits: number | string; sample: string | null; last_hour: string }>(
2332
+ `SELECT node_id, route, reason, SUM(hits) AS hits, MAX(sample) AS sample, MAX(hour) AS last_hour
2333
+ FROM node_permission_log WHERE network_id = ?1 AND hour >= ?2
2334
+ GROUP BY node_id, route, reason ORDER BY node_id, route, reason`,
2335
+ networkId, sinceHour);
2336
+ const byNode = new Map<string, { node_id: string; alias: string | null; permission_mode: string; total: number; by_reason: Record<string, number>; routes: Array<{ route: string; reason: string; hits: number; sample: string | null; last_hour: string }> }>();
2337
+ for (const r of rows) {
2338
+ let entry = byNode.get(r.node_id);
2339
+ if (!entry) {
2340
+ const n = db.get<{ alias: string | null; permission_mode: string | null }>("SELECT alias, permission_mode FROM nodes WHERE node_id = ?1", r.node_id);
2341
+ entry = { node_id: r.node_id, alias: n?.alias ?? null, permission_mode: n?.permission_mode || "normal", total: 0, by_reason: {}, routes: [] };
2342
+ byNode.set(r.node_id, entry);
2343
+ }
2344
+ const hits = Number(r.hits);
2345
+ entry.total += hits;
2346
+ entry.by_reason[r.reason] = (entry.by_reason[r.reason] ?? 0) + hits;
2347
+ entry.routes.push({ route: r.route, reason: r.reason, hits, sample: r.sample, last_hour: r.last_hour });
2348
+ }
2349
+ const nodes = [...byNode.values()].sort((a, b) => b.total - a.total || a.node_id.localeCompare(b.node_id));
2350
+ return withCors(req, Response.json({ ok: true, network_id: networkId, since: new Date(sinceMs).toISOString(), mode: nodePermissionsFlag(), total: nodes.reduce((n, e) => n + e.total, 0), nodes }));
2351
+ }
2352
+
2180
2353
  if (url.pathname === "/api/dm" || url.pathname === "/api/dm/threads") {
2181
2354
  if (!restAuth || !requestToken(req).startsWith("utok_")) {
2182
2355
  return withCors(req, Response.json({ ok: false, error: "user_token_required" }, { status: 403 }));
@@ -3096,6 +3269,11 @@ return Bun.serve({
3096
3269
  }
3097
3270
  const canonical = resolveCanonicalAlias(taskNetId, body.alias);
3098
3271
  const targetAlias = canonical.alias;
3272
+ const nodeDenied = restNodeDenied(req, restAuth, "POST /api/task", (id) => dispatchVerdict(id, {
3273
+ alias: targetAlias,
3274
+ nodeId: db.get<{ node_id: string | null }>("SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2", targetAlias, taskNetId)?.node_id ?? null,
3275
+ }));
3276
+ if (nodeDenied) return nodeDenied;
3099
3277
  const target = resolveRestDeliveryTarget(targetAlias, taskNetId);
3100
3278
  if (target.state === "not_found") {
3101
3279
  return withCors(req, Response.json({
@@ -3331,6 +3509,8 @@ return Bun.serve({
3331
3509
  if (!canRestWriteNetwork(restAuth, restScope.networkId, isAdmin)) {
3332
3510
  return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
3333
3511
  }
3512
+ const broadcastDenied = restNodeDenied(req, restAuth, "POST /api/broadcast", broadcastVerdict);
3513
+ if (broadcastDenied) return broadcastDenied;
3334
3514
  let sql = "SELECT alias, node_id, network_id FROM sessions WHERE alias IS NOT NULL";
3335
3515
  const params: any[] = [];
3336
3516
  sql = addNetworkScope(sql, params, restScope);
@@ -3634,7 +3814,7 @@ return Bun.serve({
3634
3814
  const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
3635
3815
  if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
3636
3816
  const resolved = resolveToken(token);
3637
- if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
3817
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
3638
3818
  if (!isHubAdminCredential(resolved)) return withCors(req, Response.json({ ok: false, error: "admin only" }, { status: 403 }));
3639
3819
  const limit = Math.min(Number(url.searchParams.get("limit")) || 200, LOG_RING_CAP);
3640
3820
  const since = url.searchParams.get("since"); // ISO timestamp; only return logs newer
@@ -3650,7 +3830,7 @@ return Bun.serve({
3650
3830
  const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
3651
3831
  if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
3652
3832
  const resolved = resolveToken(token);
3653
- if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
3833
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
3654
3834
  // 审计日志是账号的记录;节点令牌不能读签发者(常是管理员)的审计日志。
3655
3835
  if (isNodeCredential(resolved)) return userTokenRequired(req);
3656
3836
  const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 200);
@@ -3703,6 +3883,8 @@ return Bun.serve({
3703
3883
  if (!canRestWriteNetwork(restAuth, nodeNetId, isAdmin)) {
3704
3884
  return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
3705
3885
  }
3886
+ const nodeWriteDenied = restNodeDenied(req, restAuth, "DELETE /api/nodes/:id", nodeTargetVerdict(node.node_id));
3887
+ if (nodeWriteDenied) return nodeWriteDenied;
3706
3888
 
3707
3889
  db.transaction(() => {
3708
3890
  db.run("DELETE FROM nodes WHERE node_id = ?1", [node.node_id]);
@@ -3813,6 +3995,8 @@ return Bun.serve({
3813
3995
  if (!canRestWriteNetwork(restAuth, nodeNetId, isAdmin)) {
3814
3996
  return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
3815
3997
  }
3998
+ const nodeWriteDenied = restNodeDenied(req, restAuth, "PUT /api/nodes/:id/attrs", nodeTargetVerdict(node.node_id));
3999
+ if (nodeWriteDenied) return nodeWriteDenied;
3816
4000
 
3817
4001
  // Optimistic lock BEFORE any narrowing work, so a losing writer never
3818
4002
  // has side effects.
@@ -3912,6 +4096,8 @@ return Bun.serve({
3912
4096
  if (!canRestWriteNetwork(restAuth, nodeNetId, isAdmin)) {
3913
4097
  return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
3914
4098
  }
4099
+ const nodeWriteDenied = restNodeDenied(req, restAuth, "PUT /api/nodes/:id/avatar", nodeTargetVerdict(node.node_id));
4100
+ if (nodeWriteDenied) return nodeWriteDenied;
3915
4101
 
3916
4102
  db.run(
3917
4103
  "UPDATE nodes SET avatar_url = ?1, updated_at = datetime('now') WHERE node_id = ?2",
@@ -4177,7 +4363,7 @@ return Bun.serve({
4177
4363
  config_path, channels, server, hostname,
4178
4364
  network_id, created_at, updated_at,
4179
4365
  config_snapshot, lifecycle_state, avatar_url,
4180
- display_name, team, tags, attrs_revision
4366
+ display_name, team, tags, attrs_revision, permission_mode
4181
4367
  FROM nodes WHERE 1=1`;
4182
4368
  const params: any[] = [];
4183
4369
  sql = addAgentNetworkScope(sql, params, restScope, { alias: "alias", nodeId: "node_id" });