@sleep2agi/commhub-server 0.9.0-preview.67 → 0.9.0-preview.68

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
@@ -10,14 +10,17 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/
10
10
  import { z } from "zod/v4";
11
11
  import { registerTools } from "./tools.js";
12
12
  import { db, logTaskEvent, logAudit, syncScheduledRunForTask } from "./db.js";
13
- import { createSSEStream, createNetworkObserverStream, createUserEventStream, pushEvent, pushNetworkObserverEvent, getSSEStats, PRINTABLE_OBSERVER_KEY_PREFIX } from "./push.js";
13
+ import { createSSEStream, createNetworkObserverStream, createUserEventStream, pushEvent, pushNetworkObserverEvent, getSSEStats, PRINTABLE_OBSERVER_KEY_PREFIX, closeUserStreamsInNetwork } from "./push.js";
14
14
  import { assertNodeActive } from "./lifecycle-guard.js";
15
15
  import { pendingInboxCount } from "./inbox-count.js";
16
- import { addNetworkScope, canRestWriteNetwork, getUserNetworkIds, resolveRestNetworkScope, resolveRestWriteNetworkId, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
16
+ import { addAgentNetworkScope, addHumanNetworkScope, addNetworkScope, addOwnTrafficScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, resolveRestWriteNetworkId, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
17
+ import { restrictedMemberSeesFile, restrictedMemberAttachmentsDenied } from "./restricted-files.js";
18
+ import { listDmThread, listDmThreads, sendHumanDm } from "./human-dm.js";
19
+ import { canMessageAgent, isAgentRestricted, listAgentGrants, getAgentAccessMode, replaceAgentGrants, restrictedNetworkIds, usernameIsAgentAlias } from "./agent-access.js";
17
20
  import { validateAvatarUrl } from "./avatar-validate.js";
18
21
  import { narrowTags, parseStoredTags, validateScalarAttr } from "./node-attrs-validate.js";
19
22
  import { daemonDefaultWorkdirRoot } from "./create-node-validate.js";
20
- import { register, login, resolveToken, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, issueUserToken, listTokens, createToken, revokeToken, getNetworkMembers, getUserNetworkRole, addNetworkMember, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
23
+ import { register, login, resolveToken, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, issueUserToken, listTokens, createToken, revokeToken, getNetworkMembers, getNetworkHumans, getUserNetworkRole, addNetworkMember, adminCreateUser, listUsersWithMemberships, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
21
24
  import { abortRename, cleanupCommittedRenameSessions, commitRename, prepareRename, resolveCanonicalAlias } from "./rename.js";
22
25
  import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
23
26
  import { clientRequestIdFromMeta, idempotentTaskId, idempotentTaskMatches, type StoredIdempotentTask } from "./task-idempotency.js";
@@ -228,12 +231,45 @@ function isTmuxAllowedIP(ip: string): boolean {
228
231
  return isLocalhostIP(ip) || TMUX_ALLOWLIST.has(ip);
229
232
  }
230
233
 
234
+ // 节点令牌(ntok_ / 邀请码令牌,api_tokens.network_id 非空)是一个节点在一个网络里的身份。
235
+ // resolveToken 把它解析成「签发它的用户」,但它**不继承**那个用户的账号权限与 Hub 管理员身份:
236
+ // 账号 / 令牌 / 网络与成员管理只接受用户令牌,管理员判定一律要求用户令牌。
237
+ // 生产上几乎所有节点令牌都由管理员签发 —— 不这样做,任何一个节点都等于管理员。
238
+ function isNodeCredential(resolved: { networkId: string | null } | null | undefined): boolean {
239
+ return !!resolved?.networkId;
240
+ }
241
+ function isHubAdminCredential(resolved: { networkId: string | null; user: { role: string } } | null | undefined): boolean {
242
+ return !!resolved && !resolved.networkId && resolved.user.role === "admin";
243
+ }
244
+ // 节点改名(`anet node rename` 用节点自己的 ntok 驱动 2PC):节点令牌只能改**它自己**——
245
+ // 绑定网络内、old_alias 就是令牌所代表的那个节点。改别的节点、别的网络仍然要用户令牌。
246
+ function nodeTokenAlias(resolved: { tokenName: string | null }): string | null {
247
+ return resolved.tokenName?.startsWith("node:") ? resolved.tokenName.slice("node:".length) : null;
248
+ }
249
+ function nodeMayRename(resolved: { networkId: string | null; tokenName: string | null }, networkId: unknown, oldAlias: unknown): boolean {
250
+ if (!resolved.networkId) return true;
251
+ return networkId === resolved.networkId && typeof oldAlias === "string" && oldAlias === nodeTokenAlias(resolved);
252
+ }
253
+ function nodeMayRenameTxn(resolved: { networkId: string | null; tokenName: string | null }, txnId: unknown): boolean {
254
+ if (!resolved.networkId) return true;
255
+ if (typeof txnId !== "string") return false;
256
+ 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);
257
+ if (!txn || txn.network_id !== resolved.networkId) return false;
258
+ // commit 前后令牌名可能已被改成新 alias(api_tokens.name 跟随改名),两个都算它自己。
259
+ const self = nodeTokenAlias(resolved);
260
+ return self === txn.old_alias || self === txn.new_alias;
261
+ }
262
+
263
+ function userTokenRequired(req: Request): Response {
264
+ 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 }));
265
+ }
266
+
231
267
  function requireAdminAuth(req: Request): Response | null {
232
268
  const token = requestToken(req);
233
269
  if (!token) return Response.json({ ok: false, error: "auth required" }, { status: 401 });
234
270
  const resolved = resolveToken(token);
235
271
  if (!resolved) return Response.json({ ok: false, error: "invalid token" }, { status: 401 });
236
- if (resolved.user.role !== "admin") return Response.json({ ok: false, error: "admin required" }, { status: 403 });
272
+ if (!isHubAdminCredential(resolved)) return Response.json({ ok: false, error: "admin required" }, { status: 403 });
237
273
  return null;
238
274
  }
239
275
 
@@ -353,6 +389,7 @@ export function normalizeEntry(
353
389
  export function authorizeFileDownload(
354
390
  principal: Principal,
355
391
  entry: { ownerId: string | null; networkId: string | null },
392
+ fileId?: string,
356
393
  ): boolean {
357
394
  if (principal.kind === "legacy-master") return true;
358
395
  if (principal.kind === "admin-utok") return true;
@@ -360,7 +397,11 @@ export function authorizeFileDownload(
360
397
  if (entry.networkId !== null) {
361
398
  if (principal.kind === "anonymous" || principal.kind === "dev-open-anon") return false;
362
399
  if (principal.kind === "ntok") return principal.boundNetworkId === entry.networkId;
363
- return !!getUserNetworkRole(principal.userId, entry.networkId);
400
+ if (!getUserNetworkRole(principal.userId, entry.networkId)) return false;
401
+ // 多用户 Agent 权限:受限成员只能下载自己传的,或者出现在自己与授权 Agent 往来 / 自己收件箱里的文件。
402
+ if (!isAgentRestricted(principal.userId, entry.networkId)) return true;
403
+ if (entry.ownerId !== null && entry.ownerId === principal.userId) return true;
404
+ return !!fileId && restrictedMemberSeesFile(principal.userId, principal.username, entry.networkId, fileId);
364
405
  }
365
406
 
366
407
  if (principal.kind === "ntok" || principal.kind === "utok") {
@@ -892,7 +933,16 @@ return Bun.serve({
892
933
  if (!observerRole) {
893
934
  return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
894
935
  }
895
- return createNetworkObserverStream(observedNetId);
936
+ // 多用户 Agent 权限:受限成员只收到自己是一端的路由事件(自己发的 / 发给自己的),
937
+ // 看不到网络里别人和各个 Agent 之间的流量。
938
+ if (isAgentRestricted(authCtx.userId, observedNetId)) {
939
+ const me = authCtx.username;
940
+ return createNetworkObserverStream(observedNetId, {
941
+ subscriberUserId: authCtx.userId,
942
+ eventFilter: (event) => event.from === me || event.to === me,
943
+ });
944
+ }
945
+ return createNetworkObserverStream(observedNetId, { subscriberUserId: authCtx.userId });
896
946
  }
897
947
 
898
948
  // GET /events/users/me?network_id=... → Desktop/user-client stream.
@@ -1000,8 +1050,25 @@ return Bun.serve({
1000
1050
  return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
1001
1051
  }
1002
1052
  if (sessionName === authCtx.username) {
1053
+ // 多用户 Agent 权限:受限成员的用户名撞上本网络某个 Agent 的 alias 时,这个频道其实是那个
1054
+ // Agent 的任务流 —— 不给订阅(入网时已拦撞名,这里兜住 Agent 之后才注册同名 alias 的情况)。
1055
+ if (isAgentRestricted(authCtx.userId, scopedNetId) && usernameIsAgentAlias(scopedNetId, authCtx.username)) {
1056
+ return withCors(req, Response.json({
1057
+ ok: false,
1058
+ error: "channel not allowed: must be your username or an existing agent in your network"
1059
+ }, { status: 403 }));
1060
+ }
1003
1061
  return createSSEStream(sessionName, scopedNetId);
1004
1062
  }
1063
+ // 多用户 Agent 权限:Agent 频道推的是**所有人**发给这个 Agent 的任务原文,
1064
+ // 受限成员即使被授权了这个 Agent 也不能订阅(他只该看到自己与它的往来,走自己的用户名频道)。
1065
+ // 与「频道不存在」同一个 403 文案,不给 alias 探测留差异。
1066
+ if (isAgentRestricted(authCtx.userId, scopedNetId)) {
1067
+ return withCors(req, Response.json({
1068
+ ok: false,
1069
+ error: "channel not allowed: must be your username or an existing agent in your network"
1070
+ }, { status: 403 }));
1071
+ }
1005
1072
  const utokSession = db.get<any>(
1006
1073
  "SELECT 1 FROM sessions WHERE alias = ?1 AND network_id = ?2",
1007
1074
  sessionName, scopedNetId
@@ -1118,8 +1185,14 @@ return Bun.serve({
1118
1185
  if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
1119
1186
  const resolved = resolveToken(token);
1120
1187
  if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1121
- const networks = getUserAllNetworks(resolved.user.user_id);
1122
- return withCors(req, Response.json({ ok: true, user: resolved.user, networks, current_network: resolved.networkId }));
1188
+ // 节点令牌:user 仍是签发它的用户(旧 agent-node 靠它打日志),但只列它绑定的那个网络,
1189
+ // 并用 credential 说清楚这是一个节点身份 —— user.role 不是这个调用者的权限。
1190
+ const allNetworks = getUserAllNetworks(resolved.user.user_id);
1191
+ const networks = resolved.networkId ? allNetworks.filter((n: any) => n.network_id === resolved.networkId) : allNetworks;
1192
+ const credential = resolved.networkId
1193
+ ? { kind: "node", network_id: resolved.networkId, node_alias: resolved.tokenName?.startsWith("node:") ? resolved.tokenName.slice(5) : null, acts_as_owner: false }
1194
+ : { kind: "user" };
1195
+ return withCors(req, Response.json({ ok: true, user: resolved.user, networks, current_network: resolved.networkId, credential }));
1123
1196
  }
1124
1197
 
1125
1198
  if (url.pathname === "/api/auth/me" && req.method === "PUT") {
@@ -1127,6 +1200,7 @@ return Bun.serve({
1127
1200
  if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
1128
1201
  const resolved = resolveToken(token);
1129
1202
  if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1203
+ if (isNodeCredential(resolved)) return userTokenRequired(req);
1130
1204
  try {
1131
1205
  const body = await req.json() as any;
1132
1206
  const updates: string[] = [];
@@ -1151,6 +1225,7 @@ return Bun.serve({
1151
1225
  if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
1152
1226
  const resolved = resolveToken(token);
1153
1227
  if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1228
+ if (isNodeCredential(resolved)) return userTokenRequired(req);
1154
1229
  try {
1155
1230
  const body = await req.json() as any;
1156
1231
  const result = changePassword(resolved.user.user_id, body.old_password, body.new_password, resolved.tokenId);
@@ -1172,6 +1247,7 @@ return Bun.serve({
1172
1247
  if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1173
1248
  const resolved = resolveToken(token);
1174
1249
  if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1250
+ if (isNodeCredential(resolved)) return userTokenRequired(req);
1175
1251
  try {
1176
1252
  const body = await req.json() as any;
1177
1253
  if (!body.network_id || !body.node_name) return withCors(req, Response.json({ ok: false, error: "network_id and node_name required" }, { status: 400 }));
@@ -1196,6 +1272,7 @@ return Bun.serve({
1196
1272
  if (!body.network_id || !body.old_alias || !body.new_alias) {
1197
1273
  return withCors(req, Response.json({ ok: false, error: "network_id, old_alias, new_alias required" }, { status: 400 }));
1198
1274
  }
1275
+ if (!nodeMayRename(resolved, body.network_id, body.old_alias)) return userTokenRequired(req);
1199
1276
  const result = prepareRename(resolved.user.user_id, body.network_id, body.old_alias, body.new_alias);
1200
1277
  if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "node_rename_prepared", "node", body.old_alias, body.new_alias);
1201
1278
  return withCors(req, Response.json(result, { status: result.ok || result.code === "node_local_only" ? 200 : 400 }));
@@ -1212,6 +1289,7 @@ return Bun.serve({
1212
1289
  try {
1213
1290
  const body = await req.json() as any;
1214
1291
  if (!body.txn_id) return withCors(req, Response.json({ ok: false, error: "txn_id required" }, { status: 400 }));
1292
+ if (!nodeMayRenameTxn(resolved, body.txn_id)) return userTokenRequired(req);
1215
1293
  const result = commitRename(resolved.user.user_id, body.txn_id);
1216
1294
  if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "node_rename_committed", "node", body.txn_id);
1217
1295
  return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
@@ -1228,6 +1306,7 @@ return Bun.serve({
1228
1306
  try {
1229
1307
  const body = await req.json() as any;
1230
1308
  if (!body.txn_id) return withCors(req, Response.json({ ok: false, error: "txn_id required" }, { status: 400 }));
1309
+ if (!nodeMayRenameTxn(resolved, body.txn_id)) return userTokenRequired(req);
1231
1310
  const result = abortRename(resolved.user.user_id, body.txn_id);
1232
1311
  if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "node_rename_aborted", "node", body.txn_id);
1233
1312
  return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
@@ -1242,6 +1321,7 @@ return Bun.serve({
1242
1321
  if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1243
1322
  const resolved = resolveToken(token);
1244
1323
  if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1324
+ if (isNodeCredential(resolved)) return userTokenRequired(req);
1245
1325
  const tokens = listTokens(resolved.user.user_id);
1246
1326
  return withCors(req, Response.json({ ok: true, tokens }));
1247
1327
  }
@@ -1251,6 +1331,7 @@ return Bun.serve({
1251
1331
  if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1252
1332
  const resolved = resolveToken(token);
1253
1333
  if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1334
+ if (isNodeCredential(resolved)) return userTokenRequired(req);
1254
1335
  try {
1255
1336
  const body = await req.json() as any;
1256
1337
  const result = createToken(resolved.user.user_id, body.name || "api-token", body.network_id);
@@ -1267,6 +1348,7 @@ return Bun.serve({
1267
1348
  if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1268
1349
  const resolved = resolveToken(token);
1269
1350
  if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1351
+ if (isNodeCredential(resolved)) return userTokenRequired(req);
1270
1352
  const result = revokeToken(resolved.user.user_id, tokenDeleteMatch[1]);
1271
1353
  if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "token_revoked", "token", tokenDeleteMatch[1]);
1272
1354
  return withCors(req, Response.json(result, { status: result.ok ? 200 : 404 }));
@@ -1308,6 +1390,7 @@ return Bun.serve({
1308
1390
  if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
1309
1391
  const resolved = resolveToken(token);
1310
1392
  if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1393
+ if (isNodeCredential(resolved)) return userTokenRequired(req);
1311
1394
  try {
1312
1395
  const body = await req.json() as any;
1313
1396
  const result = createNetwork(resolved.user.user_id, body.name, body.description);
@@ -1317,6 +1400,78 @@ return Bun.serve({
1317
1400
  }
1318
1401
  }
1319
1402
 
1403
+ // ── 多用户 Agent 权限:成员可访问的 Agent(owner/admin 管理) ──
1404
+ // GET → { agent_access: 'all'|'granted', grants: [{node_id, alias, can_message}] }
1405
+ // PUT { grants?: [{node_id | alias, can_message?}] | string[], agent_access?: 'all'|'granted' } → 整体替换
1406
+ const grantsMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/members\/([^/]+)\/agent-grants$/);
1407
+ if (grantsMatch && (req.method === "GET" || req.method === "PUT")) {
1408
+ const token = requestToken(req, { allowQueryToken: false });
1409
+ const resolved = token ? resolveToken(token) : null;
1410
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1411
+ // 授权是用户层面的管理动作,网络令牌(节点身份)不能改人的权限。
1412
+ if (resolved.networkId) return withCors(req, Response.json({ ok: false, error: "user_token_required" }, { status: 403 }));
1413
+ const netId = decodeURIComponent(grantsMatch[1]);
1414
+ const targetUid = decodeURIComponent(grantsMatch[2]);
1415
+ const hubAdmin = resolved.user.role === "admin";
1416
+ const callerRole = getUserNetworkRole(resolved.user.user_id, netId);
1417
+ if (!hubAdmin && callerRole !== "owner" && callerRole !== "admin") {
1418
+ return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
1419
+ }
1420
+ const targetRole = getUserNetworkRole(targetUid, netId);
1421
+ if (!targetRole) return withCors(req, Response.json({ ok: false, error: "member_not_found" }, { status: 404 }));
1422
+ if (req.method === "GET") {
1423
+ return withCors(req, Response.json({
1424
+ ok: true,
1425
+ network_id: netId,
1426
+ user_id: targetUid,
1427
+ role: targetRole,
1428
+ agent_access: getAgentAccessMode(netId, targetUid),
1429
+ restricted: isAgentRestricted(targetUid, netId),
1430
+ grants: listAgentGrants(netId, targetUid),
1431
+ }));
1432
+ }
1433
+ // 网络 admin 不能改 owner / 其他 admin 的授权(对他们本来也不生效,但别让 admin 去碰平级)。
1434
+ if (!hubAdmin && callerRole !== "owner" && (targetRole === "owner" || targetRole === "admin")) {
1435
+ return withCors(req, Response.json({ ok: false, error: "owner required" }, { status: 403 }));
1436
+ }
1437
+ let body: any;
1438
+ try { body = await req.json(); } catch { return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 })); }
1439
+ if (!body || typeof body !== "object" || Array.isArray(body)) return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 }));
1440
+ if (body.grants === undefined && body.node_ids === undefined && body.agent_access === undefined) {
1441
+ return withCors(req, Response.json({ ok: false, error: "grants or agent_access required" }, { status: 400 }));
1442
+ }
1443
+ const result = replaceAgentGrants({
1444
+ networkId: netId,
1445
+ userId: targetUid,
1446
+ grants: body.grants ?? body.node_ids,
1447
+ agentAccess: body.agent_access,
1448
+ actorUserId: resolved.user.user_id,
1449
+ });
1450
+ if (!result.ok) {
1451
+ return withCors(req, Response.json({ ok: false, error: result.error, ...(result.detail ? { detail: result.detail } : {}) }, { status: result.status }));
1452
+ }
1453
+ logAudit(resolved.user.user_id, resolved.user.username, "member_agent_grants_changed", "network", netId,
1454
+ JSON.stringify({ user_id: targetUid, agent_access: result.agent_access, grants: result.grants.map((g) => ({ node_id: g.node_id, alias: g.alias, can_message: g.can_message })) }).slice(0, 4000),
1455
+ undefined, netId);
1456
+ // 已连着的观察流 / 用户流按旧权限鉴权过,断开让它按新权限重连。
1457
+ closeUserStreamsInNetwork(netId, targetUid);
1458
+ return withCors(req, Response.json({ ok: true, network_id: netId, user_id: targetUid, agent_access: result.agent_access, restricted: isAgentRestricted(targetUid, netId), grants: result.grants }));
1459
+ }
1460
+
1461
+ // ── 多用户:网络里的人类成员通讯录 —— 任何成员(含受限成员)都能看,只有身份字段 ──
1462
+ const humansMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/humans$/);
1463
+ if (humansMatch && req.method === "GET") {
1464
+ const token = requestToken(req, { allowQueryToken: false });
1465
+ const resolved = token ? resolveToken(token) : null;
1466
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1467
+ const netId = decodeURIComponent(humansMatch[1]);
1468
+ if (resolved.networkId && resolved.networkId !== netId) return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
1469
+ if (resolved.user.role !== "admin" && !getUserNetworkRole(resolved.user.user_id, netId)) {
1470
+ return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
1471
+ }
1472
+ return withCors(req, Response.json({ ok: true, network_id: netId, humans: getNetworkHumans(netId) }));
1473
+ }
1474
+
1320
1475
  // ── V3.13: Network members + invites ──
1321
1476
  const membersMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/members(?:\/([^/]+))?$/);
1322
1477
  if (membersMatch) {
@@ -1324,6 +1479,7 @@ return Bun.serve({
1324
1479
  if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1325
1480
  const resolved = resolveToken(token);
1326
1481
  if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1482
+ if (isNodeCredential(resolved)) return userTokenRequired(req);
1327
1483
  const netId = membersMatch[1];
1328
1484
  const targetUid = membersMatch[2];
1329
1485
  const callerRole = getUserNetworkRole(resolved.user.user_id, netId);
@@ -1337,21 +1493,31 @@ return Bun.serve({
1337
1493
  if (req.method === "POST") {
1338
1494
  if (!["owner", "admin"].includes(callerRole)) return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
1339
1495
  const body = await req.json() as any;
1340
- const result = addNetworkMember(netId, body.user_id, body.role || "member", resolved.user.user_id);
1341
- if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "member_added", "network", netId, `${body.user_id} as ${body.role || "member"}`);
1496
+ // agent_access 缺省 'granted'(新成员看不到任何 Agent);owner/admin 可显式传 'all' 加一个完全信任的成员。
1497
+ if (body.agent_access !== undefined && body.agent_access !== "all" && body.agent_access !== "granted") {
1498
+ return withCors(req, Response.json({ ok: false, error: "invalid_agent_access" }, { status: 400 }));
1499
+ }
1500
+ const result = addNetworkMember(netId, body.user_id, body.role || "member", resolved.user.user_id, { agentAccess: body.agent_access });
1501
+ if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "member_added", "network", netId, `${body.user_id} as ${body.role || "member"} agent_access=${body.agent_access === "all" ? "all" : "granted"}`);
1342
1502
  return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
1343
1503
  }
1344
1504
  if (req.method === "PUT" && targetUid) {
1345
1505
  if (callerRole !== "owner") return withCors(req, Response.json({ ok: false, error: "owner required" }, { status: 403 }));
1346
1506
  const body = await req.json() as any;
1347
1507
  const result = updateMemberRole(netId, targetUid, body.role);
1348
- if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "member_role_changed", "network", netId, `${targetUid} → ${body.role}`);
1508
+ if (result.ok) {
1509
+ logAudit(resolved.user.user_id, resolved.user.username, "member_role_changed", "network", netId, `${targetUid} → ${body.role}`);
1510
+ closeUserStreamsInNetwork(netId, targetUid);
1511
+ }
1349
1512
  return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
1350
1513
  }
1351
1514
  if (req.method === "DELETE" && targetUid) {
1352
1515
  if (!["owner", "admin"].includes(callerRole)) return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
1353
1516
  const result = removeNetworkMember(netId, targetUid);
1354
- if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "member_removed", "network", netId, targetUid);
1517
+ if (result.ok) {
1518
+ logAudit(resolved.user.user_id, resolved.user.username, "member_removed", "network", netId, targetUid);
1519
+ closeUserStreamsInNetwork(netId, targetUid);
1520
+ }
1355
1521
  return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
1356
1522
  }
1357
1523
  }
@@ -1361,6 +1527,7 @@ return Bun.serve({
1361
1527
  if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1362
1528
  const resolved = resolveToken(token);
1363
1529
  if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1530
+ if (isNodeCredential(resolved)) return userTokenRequired(req);
1364
1531
  const netId = url.pathname.split("/")[3];
1365
1532
  const callerRole = getUserNetworkRole(resolved.user.user_id, netId);
1366
1533
  if (!callerRole || !["owner", "admin"].includes(callerRole)) {
@@ -1377,18 +1544,52 @@ return Bun.serve({
1377
1544
  if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1378
1545
  const resolved = resolveToken(token);
1379
1546
  if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1547
+ if (isNodeCredential(resolved)) return userTokenRequired(req);
1380
1548
  const body = await req.json() as any;
1381
1549
  const result = joinByInvite(body.invite_code, resolved.user.user_id);
1382
1550
  if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "network_joined", "network", result.network_id, `via invite, role=${result.role}`);
1383
1551
  return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
1384
1552
  }
1385
1553
 
1554
+ // ── 多用户账号:管理员建号 / 列用户 ──
1555
+ // POST {username, password, display_name?, email?, network_id?, role?}
1556
+ // Hub 管理员:任意;某网络 owner/admin:只能带上自己管的 network_id(新用户进这个网络)。
1557
+ // 新成员默认 agent_access='granted' —— 看不到任何 Agent,等管理员在 agent-grants 里授权。
1558
+ // GET → Hub 管理员:全部用户 + 每人所在网络 / 角色 / agent_access。
1559
+ if (url.pathname === "/api/admin/users" && (req.method === "GET" || req.method === "POST")) {
1560
+ const token = requestToken(req, { allowQueryToken: false });
1561
+ const resolved = token ? resolveToken(token) : null;
1562
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1563
+ if (resolved.networkId) return withCors(req, Response.json({ ok: false, error: "user_token_required" }, { status: 403 }));
1564
+ const hubAdmin = resolved.user.role === "admin";
1565
+ if (req.method === "GET") {
1566
+ if (!hubAdmin) return withCors(req, Response.json({ ok: false, error: "admin required" }, { status: 403 }));
1567
+ return withCors(req, Response.json({ ok: true, users: listUsersWithMemberships() }));
1568
+ }
1569
+ const clientIP = getClientIP(req, server);
1570
+ if (!checkRateLimit(`admin-users:${clientIP}`, 30)) {
1571
+ return withCors(req, Response.json({ ok: false, error: "too many requests, try again later" }, { status: 429 }));
1572
+ }
1573
+ let body: any;
1574
+ try { body = await req.json(); } catch { return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 })); }
1575
+ if (!body || typeof body !== "object" || Array.isArray(body)) return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 }));
1576
+ const result = adminCreateUser(body, { userId: resolved.user.user_id, isHubAdmin: hubAdmin });
1577
+ if (!result.ok) {
1578
+ logAudit(resolved.user.user_id, resolved.user.username, "admin_user_create_denied", "user", undefined,
1579
+ JSON.stringify({ username: String(body.username ?? "").slice(0, 50), network_id: body.network_id ?? null, error: result.error }), clientIP);
1580
+ return withCors(req, Response.json({ ok: false, error: result.error }, { status: result.status }));
1581
+ }
1582
+ logAudit(resolved.user.user_id, resolved.user.username, "admin_user_created", "user", result.user.user_id,
1583
+ JSON.stringify({ username: result.user.username, membership: result.membership }), clientIP, result.membership?.network_id);
1584
+ return withCors(req, Response.json({ ok: true, user: result.user, personal_network_id: result.network_id, membership: result.membership }));
1585
+ }
1586
+
1386
1587
  // ── V3: Admin APIs (require auth) ──
1387
1588
  if (url.pathname === "/api/users" && req.method === "GET") {
1388
1589
  const token = req.headers.get("Authorization")?.replace("Bearer ", "");
1389
1590
  if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1390
1591
  const resolved = resolveToken(token);
1391
- if (!resolved || resolved.user.role !== "admin") {
1592
+ if (!isHubAdminCredential(resolved)) {
1392
1593
  return withCors(req, Response.json({ ok: false, error: "admin required" }, { status: 403 }));
1393
1594
  }
1394
1595
  const users = db.all("SELECT user_id, username, display_name, email, role, created_at FROM users ORDER BY created_at");
@@ -1406,13 +1607,22 @@ return Bun.serve({
1406
1607
  if (!network) return withCors(req, Response.json({ ok: false, error: "network not found" }, { status: 404 }));
1407
1608
  // Membership check: must be a member or system admin
1408
1609
  const viewerRole = getUserNetworkRole(resolved.user.user_id, networkId);
1409
- if (!viewerRole && resolved.user.role !== "admin") {
1610
+ if (resolved.networkId && resolved.networkId !== networkId) {
1611
+ return withCors(req, Response.json({ ok: false, error: "access denied" }, { status: 403 }));
1612
+ }
1613
+ if (!viewerRole && !isHubAdminCredential(resolved)) {
1410
1614
  return withCors(req, Response.json({ ok: false, error: "access denied" }, { status: 403 }));
1411
1615
  }
1412
- // Get network stats
1413
- const nodeCount = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM nodes WHERE network_id = ?1", networkId);
1414
- const sessionCount = db.get<{ cnt: number }>("SELECT COUNT(*) as cnt FROM sessions WHERE network_id = ?1", networkId);
1415
- const taskStats = db.all<any>("SELECT status, COUNT(*) as count FROM tasks WHERE network_id = ?1 GROUP BY status", networkId);
1616
+ // Get network stats —— 受限成员只数得到授权给他的 Agent 与自己的往来。
1617
+ const detailScope: RestNetworkScope = resolved.user.role === "admin" || resolved.networkId
1618
+ ? { networkId, networkIds: null }
1619
+ : resolveRestNetworkScope(networkId, { userId: resolved.user.user_id, networkId: null, username: resolved.user.username }, false);
1620
+ const nodeParams: unknown[] = [];
1621
+ const nodeCount = db.get<{ cnt: number }>(addAgentNetworkScope("SELECT COUNT(*) as cnt FROM nodes WHERE 1=1", nodeParams, detailScope, { alias: "alias", nodeId: "node_id" }), ...nodeParams);
1622
+ const sessionParams: unknown[] = [];
1623
+ const sessionCount = db.get<{ cnt: number }>(addAgentNetworkScope("SELECT COUNT(*) as cnt FROM sessions WHERE 1=1", sessionParams, detailScope, { alias: "alias", nodeId: "node_id" }), ...sessionParams);
1624
+ const taskParams: unknown[] = [];
1625
+ const taskStats = db.all<any>(addOwnTrafficScope("SELECT status, COUNT(*) as count FROM tasks WHERE 1=1", taskParams, detailScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" }) + " GROUP BY status", ...taskParams);
1416
1626
  return withCors(req, Response.json({
1417
1627
  ok: true, network,
1418
1628
  stats: { nodes: nodeCount?.cnt || 0, sessions: sessionCount?.cnt || 0, tasks: taskStats },
@@ -1424,6 +1634,7 @@ return Bun.serve({
1424
1634
  if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1425
1635
  const resolved = resolveToken(token);
1426
1636
  if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1637
+ if (isNodeCredential(resolved)) return userTokenRequired(req);
1427
1638
  const result = deleteNetwork(resolved.user.user_id, netDetailMatch[1]);
1428
1639
  if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "network_deleted", "network", netDetailMatch[1]);
1429
1640
  return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
@@ -1434,6 +1645,7 @@ return Bun.serve({
1434
1645
  if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1435
1646
  const resolved = resolveToken(token);
1436
1647
  if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
1648
+ if (isNodeCredential(resolved)) return userTokenRequired(req);
1437
1649
  try {
1438
1650
  const body = await req.json() as any;
1439
1651
  if (body.name) {
@@ -1514,6 +1726,10 @@ return Bun.serve({
1514
1726
  healthAuth.userId,
1515
1727
  ).map((r) => r.network_id),
1516
1728
  );
1729
+ // 多用户 Agent 权限:键里带着 alias,受限网络整网不出现在这里(与 addNetworkScope 同一 fail-closed 口径)。
1730
+ if (!healthAuth.networkId) {
1731
+ for (const restricted of restrictedNetworkIds(healthAuth.userId)) memberNets.delete(restricted);
1732
+ }
1517
1733
  // If member has no networks at all, keep field present but empty
1518
1734
  // so the dashboard can distinguish "authenticated + none active"
1519
1735
  // from "not authenticated at all".
@@ -1573,8 +1789,12 @@ return Bun.serve({
1573
1789
  // Resolve network scope for REST queries — enforce isolation
1574
1790
  // Token-bound networkId takes precedence (ntok_ → forced), then query param
1575
1791
  const restAuth = resolveRequestAuth(req);
1576
- const isAdmin = !!(restAuth?.username && db.get<any>("SELECT role FROM users WHERE username = ?1", restAuth.username)?.role === "admin");
1792
+ // Hub 管理员身份只随用户令牌;节点令牌即使由管理员签发也不是管理员(见 isNodeCredential)。
1793
+ const isAdmin = !!(restAuth?.username && !restAuth.networkId && db.get<any>("SELECT role FROM users WHERE username = ?1", restAuth.username)?.role === "admin");
1577
1794
  const restScope = resolveRestNetworkScope(url.searchParams.get("network_id"), restAuth, isAdmin);
1795
+ // 多用户 Agent 权限:下面凡是读 Agent 数据的查询,受限成员在受限网络里默认拿不到任何行
1796
+ // (addNetworkScope fail-closed);逐条审过的路径改用 addAgentNetworkScope / addOwnTrafficScope
1797
+ // 按授权放行,人类数据用 addHumanNetworkScope。
1578
1798
  if (restScope.denied) {
1579
1799
  return withCors(req, Response.json({ ok: false, error: restScope.denied }, { status: 403 }));
1580
1800
  }
@@ -1627,6 +1847,47 @@ return Bun.serve({
1627
1847
  });
1628
1848
  if (scheduledResponse) return withCors(req, scheduledResponse);
1629
1849
 
1850
+ // ── 多用户:人与人私信(同网络两个用户之间;受限成员也可以) ──
1851
+ // POST /api/dm {network_id?, to_user_id | to_username, message, attachments?, client_request_id?}
1852
+ // GET /api/dm?network_id=&with=<user_id>[&limit&before] → 双向会话记录(新的在前)
1853
+ // GET /api/dm/threads?network_id= → 每个对方一行 + 未读数
1854
+ // 已读沿用 POST /api/messages/ack(私信就是收件人 user_inbox 里的行)。
1855
+ if (url.pathname === "/api/dm" || url.pathname === "/api/dm/threads") {
1856
+ if (!restAuth || !requestToken(req).startsWith("utok_")) {
1857
+ return withCors(req, Response.json({ ok: false, error: "user_token_required" }, { status: 403 }));
1858
+ }
1859
+ if (req.method === "POST" && url.pathname === "/api/dm") {
1860
+ let body: any;
1861
+ try { body = await req.json(); } catch { return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 })); }
1862
+ if (!body || typeof body !== "object" || Array.isArray(body)) return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 }));
1863
+ const dmNet = typeof body.network_id === "string" && body.network_id ? body.network_id : singleNetworkId(restScope);
1864
+ if (!dmNet) return withCors(req, Response.json({ ok: false, error: "network_id_required" }, { status: 400 }));
1865
+ const sent = sendHumanDm({
1866
+ networkId: dmNet,
1867
+ sender: { userId: restAuth.userId, username: restAuth.username },
1868
+ toUserId: body.to_user_id, toUsername: body.to_username,
1869
+ message: body.message, attachments: body.attachments, clientRequestId: body.client_request_id,
1870
+ });
1871
+ if (!sent.ok) return withCors(req, Response.json({ ok: false, error: sent.error }, { status: sent.status }));
1872
+ return withCors(req, Response.json({ ok: true, message: sent.message, delivered: sent.delivered }));
1873
+ }
1874
+ if (req.method === "GET") {
1875
+ const dmNet = url.searchParams.get("network_id") || singleNetworkId(restScope);
1876
+ if (!dmNet) return withCors(req, Response.json({ ok: false, error: "network_id_required" }, { status: 400 }));
1877
+ if (!getUserNetworkRole(restAuth.userId, dmNet)) {
1878
+ return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
1879
+ }
1880
+ if (url.pathname === "/api/dm/threads") {
1881
+ return withCors(req, Response.json({ ok: true, network_id: dmNet, threads: listDmThreads(dmNet, restAuth.userId) }));
1882
+ }
1883
+ const other = url.searchParams.get("with") ?? "";
1884
+ if (!other) return withCors(req, Response.json({ ok: false, error: "with_required" }, { status: 400 }));
1885
+ const limit = Number(url.searchParams.get("limit")) || 50;
1886
+ return withCors(req, Response.json({ ok: true, network_id: dmNet, with: other, messages: listDmThread(dmNet, restAuth.userId, other, limit, url.searchParams.get("before")) }));
1887
+ }
1888
+ return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
1889
+ }
1890
+
1630
1891
  const requirementsResponse = await handleRequirementsRequest({
1631
1892
  req,
1632
1893
  url,
@@ -1652,6 +1913,13 @@ return Bun.serve({
1652
1913
  // under DEV_OPEN this endpoint is anonymously readable BY DESIGN
1653
1914
  // (dev convenience — production never sets COMMHUB_DEV_OPEN, so the
1654
1915
  // topology detail stays gated in every real deployment).
1916
+ // 节点令牌:只看它绑定网络的那部分(投递可达性探针要用);不再因签发者是管理员而看到全部网络。
1917
+ if (restAuth?.networkId && !isAdmin) {
1918
+ const bound = restAuth.networkId;
1919
+ const sse = getSSEStats();
1920
+ const sessions = Object.fromEntries(Object.entries(sse.sessions).filter(([key]) => key.split(":").includes(bound)));
1921
+ return withCors(req, Response.json({ ok: true, total: Object.values(sessions).reduce((a, b) => a + b, 0), sessions, scope: "network", network_id: bound }));
1922
+ }
1655
1923
  if (restAuth && !isAdmin) {
1656
1924
  return withCors(req, Response.json({ ok: false, error: "admin or master token required" }, { status: 403 }));
1657
1925
  }
@@ -1676,7 +1944,7 @@ return Bun.serve({
1676
1944
  let sql = isLight
1677
1945
  ? "SELECT alias, status, agent, task, server, updated_at, network_id FROM sessions WHERE 1=1"
1678
1946
  : `SELECT ${SESSION_REST_SELECT} FROM sessions WHERE 1=1`;
1679
- sql = addNetworkScope(sql, params, restScope);
1947
+ sql = addAgentNetworkScope(sql, params, restScope, { alias: "alias", nodeId: "node_id" });
1680
1948
  sql += " ORDER BY updated_at DESC";
1681
1949
  // `model` comes straight from the explicit sessions projection; `runtime` is
1682
1950
  // derived from the raw `agent` field. Both default to null for old nodes
@@ -2107,7 +2375,8 @@ return Bun.serve({
2107
2375
  }
2108
2376
  }
2109
2377
 
2110
- if (!canRestWriteNetwork(authCtx, uploadNetId, principal.kind === "admin-utok")) {
2378
+ // 上传是人类侧写入(给私信 / 给授权 Agent 的任务挂附件),受限成员也可以;下载另有授权判定。
2379
+ if (!canRestWriteNetworkAsHuman(authCtx, uploadNetId, principal.kind === "admin-utok")) {
2111
2380
  return earlyReject(Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
2112
2381
  }
2113
2382
 
@@ -2273,7 +2542,7 @@ return Bun.serve({
2273
2542
  // "no such file". Both branches return the same 404 shape
2274
2543
  // for BOTH GET and HEAD (HEAD honours body-omission but the
2275
2544
  // status code and headers are the authoritative signal).
2276
- if (!authorizeFileDownload(resolvePrincipal(req, { allowQueryToken: false }), normalizeEntry(entry))) {
2545
+ if (!authorizeFileDownload(resolvePrincipal(req, { allowQueryToken: false }), normalizeEntry(entry), fileId)) {
2277
2546
  return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
2278
2547
  }
2279
2548
 
@@ -2399,7 +2668,27 @@ return Bun.serve({
2399
2668
  message: "network_id is required when the user token has zero or multiple network memberships",
2400
2669
  }, { status: 400 }));
2401
2670
  }
2402
- if (!canRestWriteNetwork(restAuth, taskNetId, isAdmin)) {
2671
+ // 多用户 Agent 权限:受限成员只能给授权且 can_message 的 Agent 发;
2672
+ // 目标不存在 / 没授权 / 只读授权 → 同一个 403,不留 alias 探测差异。发件人固定为自己的用户名。
2673
+ const restrictedSender = !!restAuth && !isAdmin && !restAuth.networkId && isAgentRestricted(restAuth.userId, taskNetId!);
2674
+ if (restrictedSender) {
2675
+ if (!canRestWriteNetworkAsHuman(restAuth, taskNetId, isAdmin)) {
2676
+ return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
2677
+ }
2678
+ const restrictedTarget = resolveCanonicalAlias(taskNetId, body.alias).alias;
2679
+ const restrictedSession = db.get<{ node_id: string | null }>("SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2", restrictedTarget, taskNetId);
2680
+ if (!restrictedSession || !canMessageAgent(restAuth!.userId, taskNetId, { alias: restrictedTarget, nodeId: restrictedSession.node_id })) {
2681
+ return withCors(req, Response.json({ ok: false, error: "agent_not_granted", message: "this agent has not been granted to you; ask a network admin" }, { status: 403 }));
2682
+ }
2683
+ if (typeof body.from === "string" && body.from.trim() && body.from.trim() !== restAuth!.username) {
2684
+ return withCors(req, Response.json({ ok: false, error: "from_session_identity_mismatch", message: "restricted members always send as their own username" }, { status: 403 }));
2685
+ }
2686
+ for (const raw of [(body as any).attachments, (body as any).meta?.attachments]) {
2687
+ if (restrictedMemberAttachmentsDenied(restAuth!.userId, restAuth!.username, taskNetId!, raw)) {
2688
+ return withCors(req, Response.json({ ok: false, error: "attachment_not_accessible" }, { status: 403 }));
2689
+ }
2690
+ }
2691
+ } else if (!canRestWriteNetwork(restAuth, taskNetId, isAdmin)) {
2403
2692
  return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
2404
2693
  }
2405
2694
  const canonical = resolveCanonicalAlias(taskNetId, body.alias);
@@ -2722,7 +3011,8 @@ return Bun.serve({
2722
3011
  const userInboxAliasCollides = (username: string, scope: RestNetworkScope): boolean => {
2723
3012
  const p: any[] = [username];
2724
3013
  let q = "SELECT 1 AS hit FROM nodes WHERE alias = ?1";
2725
- q = addNetworkScope(q, p, scope);
3014
+ // 撞名检测要看见网络里**所有**节点(受限成员看不见的也算),否则撞名的节点待办会被当成用户未读。
3015
+ q = addHumanNetworkScope(q, p, scope);
2726
3016
  return !!db.get<{ hit: number }>(q + " LIMIT 1", ...p);
2727
3017
  };
2728
3018
 
@@ -2745,7 +3035,7 @@ return Bun.serve({
2745
3035
  FROM user_inbox WHERE user_id = ?1`;
2746
3036
  if (unackedOnly) sql += " AND acked = 0";
2747
3037
  // 复用 alias 分支同一个 scope 助手:新读路径漏 scope 是已被审计证实的真风险。
2748
- sql = addNetworkScope(sql, params, restScope);
3038
+ sql = addHumanNetworkScope(sql, params, restScope);
2749
3039
  sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
2750
3040
  params.push(limit);
2751
3041
  const rows = db.all<any>(sql, ...params);
@@ -2753,7 +3043,7 @@ return Bun.serve({
2753
3043
  // 未读数与列表用同一个 user_id + 同一个 scope 助手算,避免两处口径漂开。
2754
3044
  const countParams: any[] = [callerUserId];
2755
3045
  let countSql = "SELECT COUNT(*) AS cnt FROM user_inbox WHERE user_id = ?1 AND acked = 0";
2756
- countSql = addNetworkScope(countSql, countParams, restScope);
3046
+ countSql = addHumanNetworkScope(countSql, countParams, restScope);
2757
3047
  const unread = db.get<{ cnt: number }>(countSql, ...countParams)?.cnt ?? 0;
2758
3048
 
2759
3049
  // #1828 —— 按 agent 分的未读:user_inbox(agent 主动发给用户)+ inbox 里发给**这个用户名**的
@@ -2765,14 +3055,16 @@ return Bun.serve({
2765
3055
  for (const r of agentRows) byAgent[r.agent || "hub"] = (byAgent[r.agent || "hub"] ?? 0) + Number(r.n ?? 0);
2766
3056
  };
2767
3057
  const uiAgentParams: any[] = [callerUserId];
2768
- let uiAgentSql = "SELECT from_session AS agent, COUNT(*) AS n FROM user_inbox WHERE user_id = ?1 AND acked = 0";
2769
- uiAgentSql = addNetworkScope(uiAgentSql, uiAgentParams, restScope);
3058
+ // 人与人私信(kind=human_dm)的 from_session 是发信人的用户名,不是 Agent —— 不进按 Agent 的未读,
3059
+ // 否则客户端会把发私信的人画成一个幽灵 Agent。私信未读走 /api/dm/threads。
3060
+ let uiAgentSql = "SELECT from_session AS agent, COUNT(*) AS n FROM user_inbox WHERE user_id = ?1 AND acked = 0 AND kind != 'human_dm'";
3061
+ uiAgentSql = addHumanNetworkScope(uiAgentSql, uiAgentParams, restScope);
2770
3062
  addByAgent(db.all<{ agent: string | null; n: number }>(uiAgentSql + " GROUP BY from_session", ...uiAgentParams));
2771
3063
  const callerUsername = restAuth?.username ?? "";
2772
3064
  if (callerUsername && !userInboxAliasCollides(callerUsername, restScope)) {
2773
3065
  const ibParams: any[] = [callerUsername];
2774
3066
  let ibSql = "SELECT from_session AS agent, COUNT(*) AS n FROM inbox WHERE session_name = ?1 AND acked = 0 AND type IN ('reply', 'task', 'message')";
2775
- ibSql = addNetworkScope(ibSql, ibParams, restScope);
3067
+ ibSql = addHumanNetworkScope(ibSql, ibParams, restScope);
2776
3068
  addByAgent(db.all<{ agent: string | null; n: number }>(ibSql + " GROUP BY from_session", ...ibParams));
2777
3069
  }
2778
3070
  const unreadTotal = Object.values(byAgent).reduce((a, b) => a + b, 0);
@@ -2804,7 +3096,7 @@ return Bun.serve({
2804
3096
  params.push(alias);
2805
3097
  sql += ` AND session_name = ?${params.length}`;
2806
3098
  }
2807
- sql = addNetworkScope(sql, params, restScope);
3099
+ sql = addOwnTrafficScope(sql, params, restScope, { from: "from_session", to: "session_name" });
2808
3100
  sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
2809
3101
  params.push(limit);
2810
3102
  const rows = db.all(sql, ...params);
@@ -2813,7 +3105,7 @@ return Bun.serve({
2813
3105
  if (alias) {
2814
3106
  const countParams: any[] = [alias];
2815
3107
  let countSql = "SELECT COUNT(*) AS cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
2816
- countSql = addNetworkScope(countSql, countParams, restScope);
3108
+ countSql = addOwnTrafficScope(countSql, countParams, restScope, { from: "from_session", to: "session_name" });
2817
3109
  pendingCount = db.get<{ cnt: number }>(countSql, ...countParams)?.cnt ?? 0;
2818
3110
  }
2819
3111
 
@@ -2843,7 +3135,7 @@ return Bun.serve({
2843
3135
  const uiParams: any[] = [callerUserId, agentRaw];
2844
3136
  let uiSql = `UPDATE user_inbox SET acked = 1, acked_at = datetime('now')
2845
3137
  WHERE user_id = ?1 AND from_session = ?2 AND acked = 0`;
2846
- uiSql = addNetworkScope(uiSql, uiParams, restScope);
3138
+ uiSql = addHumanNetworkScope(uiSql, uiParams, restScope);
2847
3139
  const uiAcked = db.run(uiSql, uiParams).changes ?? 0;
2848
3140
  let agentInboxAcked = 0;
2849
3141
  const agentCallerUsername = restAuth?.username ?? "";
@@ -2851,7 +3143,7 @@ return Bun.serve({
2851
3143
  const ibAgentParams: any[] = [agentCallerUsername, agentRaw];
2852
3144
  let ibAgentSql = `UPDATE inbox SET acked = 1
2853
3145
  WHERE session_name = ?1 AND from_session = ?2 AND acked = 0 AND type IN ('reply', 'task', 'message')`;
2854
- ibAgentSql = addNetworkScope(ibAgentSql, ibAgentParams, restScope);
3146
+ ibAgentSql = addHumanNetworkScope(ibAgentSql, ibAgentParams, restScope);
2855
3147
  agentInboxAcked = db.run(ibAgentSql, ibAgentParams).changes ?? 0;
2856
3148
  }
2857
3149
  return withCors(req, Response.json({
@@ -2871,7 +3163,7 @@ return Bun.serve({
2871
3163
  const placeholders = ids.map((_, i) => `?${i + 2}`).join(", ");
2872
3164
  let sql = `UPDATE user_inbox SET acked = 1, acked_at = datetime('now')
2873
3165
  WHERE user_id = ?1 AND message_id IN (${placeholders}) AND acked = 0`;
2874
- sql = addNetworkScope(sql, params, restScope);
3166
+ sql = addHumanNetworkScope(sql, params, restScope);
2875
3167
  const res = db.run(sql, params);
2876
3168
  // #1828 —— 同一批 id 也可能是 inbox 里发给这个用户名的 reply/task/message 行(agent 回复);
2877
3169
  // 用户看过就 ack 掉。只动 session_name = 调用者用户名 的行,别人的 id 匹配不到;
@@ -2882,7 +3174,7 @@ return Bun.serve({
2882
3174
  const ibParams: any[] = [callerUsername, ...ids];
2883
3175
  let ibSql = `UPDATE inbox SET acked = 1
2884
3176
  WHERE session_name = ?1 AND id IN (${placeholders}) AND acked = 0 AND type IN ('reply', 'task', 'message')`;
2885
- ibSql = addNetworkScope(ibSql, ibParams, restScope);
3177
+ ibSql = addHumanNetworkScope(ibSql, ibParams, restScope);
2886
3178
  inboxAcked = db.run(ibSql, ibParams).changes ?? 0;
2887
3179
  }
2888
3180
  return withCors(req, Response.json({ ok: true, scope: "ids", acked: (res.changes ?? 0) + inboxAcked, acked_user_inbox: res.changes ?? 0, acked_inbox: inboxAcked }));
@@ -2934,7 +3226,7 @@ return Bun.serve({
2934
3226
  if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
2935
3227
  const resolved = resolveToken(token);
2936
3228
  if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
2937
- if (resolved.user.role !== "admin") return withCors(req, Response.json({ ok: false, error: "admin only" }, { status: 403 }));
3229
+ if (!isHubAdminCredential(resolved)) return withCors(req, Response.json({ ok: false, error: "admin only" }, { status: 403 }));
2938
3230
  const limit = Math.min(Number(url.searchParams.get("limit")) || 200, LOG_RING_CAP);
2939
3231
  const since = url.searchParams.get("since"); // ISO timestamp; only return logs newer
2940
3232
  let entries = logRing.slice(-limit);
@@ -2950,6 +3242,8 @@ return Bun.serve({
2950
3242
  if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
2951
3243
  const resolved = resolveToken(token);
2952
3244
  if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
3245
+ // 审计日志是账号的记录;节点令牌不能读签发者(常是管理员)的审计日志。
3246
+ if (isNodeCredential(resolved)) return userTokenRequired(req);
2953
3247
  const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 200);
2954
3248
  const action = url.searchParams.get("action");
2955
3249
  const userId = url.searchParams.get("user_id");
@@ -2971,7 +3265,13 @@ return Bun.serve({
2971
3265
  const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 500);
2972
3266
  let sql = `SELECT ${TASK_EVENT_REST_SELECT} FROM task_events WHERE 1=1`;
2973
3267
  const params: any[] = [];
2974
- sql = addNetworkScope(sql, params, restScope);
3268
+ if (restScope.agentRestriction) {
3269
+ // 受限成员:只看得到自己与授权 Agent 往来的那些任务的事件。
3270
+ sql += " AND task_id IN (SELECT task_id FROM tasks WHERE 1=1";
3271
+ sql = addOwnTrafficScope(sql, params, restScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" }) + ")";
3272
+ } else {
3273
+ sql = addNetworkScope(sql, params, restScope);
3274
+ }
2975
3275
  if (taskId) { sql += ` AND task_id = ?${params.length + 1}`; params.push(taskId); }
2976
3276
  sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
2977
3277
  params.push(limit);
@@ -3277,6 +3577,10 @@ return Bun.serve({
3277
3577
  : "missing_network_id";
3278
3578
  return withCors(req, Response.json({ ok: false, error, memberships }, { status: 400 }));
3279
3579
  }
3580
+ // 多用户 Agent 权限:daemon 也是 Agent,受限成员看不到。
3581
+ if (restAuth && !isAdmin && isAgentRestricted(restAuth.userId, effectiveNetId)) {
3582
+ return withCors(req, Response.json({ ok: true, daemons: [], count: 0 }));
3583
+ }
3280
3584
  // Role for member-脱敏 (admin/owner = full host_telemetry, others = masked)
3281
3585
  let isPrivileged = false;
3282
3586
  if (restAuth?.userId) {
@@ -3467,7 +3771,7 @@ return Bun.serve({
3467
3771
  display_name, team, tags, attrs_revision
3468
3772
  FROM nodes WHERE 1=1`;
3469
3773
  const params: any[] = [];
3470
- sql = addNetworkScope(sql, params, restScope);
3774
+ sql = addAgentNetworkScope(sql, params, restScope, { alias: "alias", nodeId: "node_id" });
3471
3775
  if (nodeId) { sql += ` AND node_id = ?${params.length + 1}`; params.push(nodeId); }
3472
3776
  if (alias) { sql += ` AND alias = ?${params.length + 1}`; params.push(alias); }
3473
3777
  sql += " ORDER BY updated_at DESC";
@@ -3539,7 +3843,7 @@ return Bun.serve({
3539
3843
  const taskId = decodeURIComponent(taskPathMatch[1] ?? "");
3540
3844
  const params: any[] = [taskId];
3541
3845
  let sql = `SELECT ${TASK_REST_SELECT} FROM tasks WHERE task_id = ?1`;
3542
- sql = addNetworkScope(sql, params, restScope);
3846
+ sql = addOwnTrafficScope(sql, params, restScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
3543
3847
  sql += " LIMIT 1";
3544
3848
  const task = db.get(sql, ...params);
3545
3849
  if (!task) {
@@ -3628,7 +3932,7 @@ return Bun.serve({
3628
3932
 
3629
3933
  let sql = `SELECT ${TASK_REST_SELECT} FROM tasks WHERE 1=1`;
3630
3934
  const params: any[] = [];
3631
- sql = addNetworkScope(sql, params, restScope);
3935
+ sql = addOwnTrafficScope(sql, params, restScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
3632
3936
  if (taskId) { sql += ` AND task_id = ?${params.length + 1}`; params.push(taskId); }
3633
3937
  if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
3634
3938
  if (toName) { sql += ` AND to_name = ?${params.length + 1}`; params.push(toName); }
@@ -3651,7 +3955,7 @@ return Bun.serve({
3651
3955
  }
3652
3956
  const statsParams: any[] = [];
3653
3957
  let statsSql = "SELECT status, COUNT(*) as count FROM tasks WHERE 1=1";
3654
- statsSql = addNetworkScope(statsSql, statsParams, restScope);
3958
+ statsSql = addOwnTrafficScope(statsSql, statsParams, restScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
3655
3959
  statsSql += " GROUP BY status";
3656
3960
  const stats = db.all<any>(statsSql, ...statsParams);
3657
3961
  return withCors(req, Response.json({ ok: true, tasks: rows, count: rows.length, stats }));