@sleep2agi/commhub-server 0.9.0-preview.91 → 0.9.0-preview.93

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
@@ -3,8 +3,10 @@ import { markGzipReusable, maybeGzipResponse, trimLightTask } from "./http-gzip"
3
3
  import { ifNoneMatchHits, memoStatusBody } from "./status-read-cache";
4
4
  import { statusAliasResolverRead } from "./status-alias-resolver.js";
5
5
  import { readNodeHealth } from "./node-health-store.js";
6
+ import { wantsAllTools } from "./tool-audience.js";
6
7
  import { assertNodeHealthy, degradedLayers } from "./node-health-guard.js";
7
8
  import { auditDepartment, createDepartment, deleteDepartment, listDepartments, setMemberDepartment, updateDepartment } from "./departments.js";
9
+ import { addManualMember, canManageGroup, createDepartmentGroup, getDepartmentGroup, groupById, groupViewerCan, isGroupMember, listGroupMembers, listVisibleGroups, readGroup, removeManualMember, renameGroup, syncDepartmentGroups } from "./department-groups.js";
8
10
  import { DEPARTMENT_SCOPE_DENIED, departmentLeaders, departmentSubtree, headScope, listDepartmentProjectGrants, managedDepartmentIds, membersIn, replaceDepartmentProjectGrants } from "./department-heads.js";
9
11
  import { redactMessageRow } from "./redact-tokens.js";
10
12
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
@@ -22,8 +24,10 @@ import { pendingInboxCount } from "./inbox-count.js";
22
24
  import { addAgentNetworkScope, addHumanNetworkScope, addNetworkScope, addOwnTrafficScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, resolveRestWriteNetworkId, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
23
25
  import { restrictedMemberSeesFile, restrictedMemberAttachmentsDenied } from "./restricted-files.js";
24
26
  import { dmParticipantSeesFile, listDmThread, listDmThreads, sendHumanDm } from "./human-dm.js";
27
+ import { groupMemberSeesFile, groupUnreadFor, listGroupMessages, listGroupThreads, markGroupRead, memberGroup, sendGroupMessage } from "./group-messages.js";
25
28
  import { getTaskAccessMode, isTaskScoped, listProjectGrants, replaceTaskGrants, NEW_MEMBER_TASK_ACCESS } from "./task-access.js";
26
29
  import { canMessageAgent, isAgentRestricted, listAgentGrants, listGroupGrants, getAgentAccessMode, replaceAgentGrants, restrictedNetworkIds, usernameIsAgentAlias, listAgentGroups, createAgentGroup, updateAgentGroup, replaceAgentGroupMembers, deleteAgentGroup, usersGrantedGroup } from "./agent-access.js";
30
+ import { broadcastVerdict, dispatchVerdict, humanOnlyVerdict, NODE_PERMISSION_MODES, nodeDecide, nodeIdentity, nodePermissionDeniedBody, nodePermissionsFlag, streamVerdict, writeVerdict, type NodeIdentity, type NodePermissionMode, type Verdict } from "./node-permissions.js";
27
31
  import { validateAvatarUrl } from "./avatar-validate.js";
28
32
  import { narrowTags, parseStoredTags, validateScalarAttr } from "./node-attrs-validate.js";
29
33
  import { daemonDefaultWorkdirRoot } from "./create-node-validate.js";
@@ -123,7 +127,11 @@ const SERVER_VERSION = (() => {
123
127
  })();
124
128
 
125
129
  // Feature flags advertised on /health (see the /health handler). Exported for tests.
126
- export const HUB_HEALTH_CAPABILITIES = ["status_node_id"] as const;
130
+ // node_permission_mode(#489):节点有 permission_mode、GET /api/nodes 每行带 viewer_can.permission_mode,
131
+ // PUT /api/nodes/:id/permission-mode 与 GET /api/networks/:id/node-permission-report 可用(RFC-041 第一阶段)。
132
+ // chat_groups(RFC-042,#457):部门群可用 —— …/departments/:dept/group、…/chat-groups(资料 / 成员 / 消息 / 已读)、
133
+ // GET /api/dm/threads 的 group_threads;群对象带 viewer_can {manage, post},会话行带 last_message 预览,成员带 username / display_name。
134
+ export const HUB_HEALTH_CAPABILITIES = ["status_node_id", "node_permission_mode", "chat_groups"] as const;
127
135
 
128
136
  // In-memory log ring buffer — last N lines streamed via /api/server-logs.
129
137
  // Wraps console.log/info/warn/error so EVERY existing log call lands here
@@ -179,12 +187,12 @@ function sweepStaleRateLimits(): void {
179
187
  }
180
188
 
181
189
  // ── Factory: 每个请求创建新的 McpServer(stateless 模式)──
182
- function createServer(clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null, callerTokenIsNetwork = false, callerTokenId?: string | null): McpServer {
190
+ function createServer(clientIP?: string, enforceNetworkId?: string | null, enforceUserId?: string | null, callerAlias?: string | null, callerTokenIsNetwork = false, callerTokenId?: string | null, includeProtocolTools = false): McpServer {
183
191
  const server = new McpServer({
184
192
  name: "commhub",
185
193
  version: "0.5.0",
186
194
  });
187
- registerTools(server, clientIP, enforceNetworkId, enforceUserId, callerAlias, callerTokenIsNetwork, callerTokenId);
195
+ registerTools(server, clientIP, enforceNetworkId, enforceUserId, callerAlias, callerTokenIsNetwork, callerTokenId, { includeProtocol: includeProtocolTools });
188
196
  return server;
189
197
  }
190
198
 
@@ -318,6 +326,18 @@ function countNetworkUploads(networkId: string): number {
318
326
  return count;
319
327
  }
320
328
 
329
+ // RFC-041(#487):节点令牌的一次 REST 写 / 订阅先按 node-permissions.ts 判。用户令牌 → null(不判)。
330
+ function restNodeDenied(req: Request, restAuth: { networkId: string | null; tokenId: string | null } | null, route: string, verdictOf: (id: NodeIdentity) => Verdict): Response | null {
331
+ if (!restAuth?.networkId) return null;
332
+ const id = nodeIdentity(restAuth.tokenId, restAuth.networkId);
333
+ if (!id) return null;
334
+ const verdict = verdictOf(id);
335
+ return nodeDecide(id, route, verdict) ? withCors(req, Response.json(nodePermissionDeniedBody(verdict!.reason, route), { status: 403 })) : null;
336
+ }
337
+ /** 写别的节点 = 只有人能做;写自己 = 只看模式。 */
338
+ const nodeTargetVerdict = (targetNodeId: string) => (id: NodeIdentity): Verdict =>
339
+ id.nodeId && id.nodeId === targetNodeId ? writeVerdict(id, targetNodeId) : humanOnlyVerdict(id, targetNodeId);
340
+
321
341
  function userTokenRequired(req: Request): Response {
322
342
  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 }));
323
343
  }
@@ -440,6 +460,10 @@ export function normalizeEntry(
440
460
  // legacy master token still pass above, as for every other file.
441
461
  // Files uploaded any other way never become DM-scoped, including when
442
462
  // they are later forwarded into a DM.
463
+ // RFC-042 (#457, group messages): a DM-scoped file is also readable by
464
+ // the CURRENT members of a chat group with a message carrying it
465
+ // (group-messages.ts groupMemberSeesFile); the same widening applies to
466
+ // restricted members below. Removed members lose it immediately.
443
467
  // - Everything else → deny (404). New uploads in production always
444
468
  // carry a truthy owner_id (requireAuth blocks the null-owner-
445
469
  // producing path when DEV_OPEN=off and no legacy master token is
@@ -467,12 +491,12 @@ export function authorizeFileDownload(
467
491
  // 私信文件:只看是不是这段私信里的人,与受限 / 不受限无关。
468
492
  if (entry.dmScoped) {
469
493
  if (entry.ownerId !== null && entry.ownerId === principal.userId) return true;
470
- return !!fileId && dmParticipantSeesFile(principal.userId, entry.networkId, fileId);
494
+ return !!fileId && (dmParticipantSeesFile(principal.userId, entry.networkId, fileId) || groupMemberSeesFile(principal.userId, entry.networkId, fileId));
471
495
  }
472
496
  // 多用户 Agent 权限:受限成员只能下载自己传的,或者出现在自己与授权 Agent 往来 / 自己收件箱里的文件。
473
497
  if (!isAgentRestricted(principal.userId, entry.networkId)) return true;
474
498
  if (entry.ownerId !== null && entry.ownerId === principal.userId) return true;
475
- return !!fileId && restrictedMemberSeesFile(principal.userId, principal.username, entry.networkId, fileId);
499
+ return !!fileId && (restrictedMemberSeesFile(principal.userId, principal.username, entry.networkId, fileId) || groupMemberSeesFile(principal.userId, entry.networkId, fileId));
476
500
  }
477
501
 
478
502
  if (principal.kind === "ntok" || principal.kind === "utok") {
@@ -979,7 +1003,8 @@ return Bun.serve({
979
1003
  }
980
1004
  labelRoute(req, mcpParsedBody === undefined ? "?" : mcpRouteLabel(mcpParsedBody));
981
1005
  }
982
- const mcpServer = createServer(clientIP, enforceNetId, authCtx?.userId || null, callerAlias, !!token?.startsWith("ntok_"), authCtx?.tokenId || null);
1006
+ // #478:tools/list 按调用者过滤;X-Anet-Tools: all / ?tools=all 时节点令牌也列出协议工具。
1007
+ const mcpServer = createServer(clientIP, enforceNetId, authCtx?.userId || null, callerAlias, !!token?.startsWith("ntok_"), authCtx?.tokenId || null, wantsAllTools(req, url));
983
1008
  await mcpServer.connect(transport);
984
1009
  const response = await transport.handleRequest(req, mcpParsedBody === undefined ? undefined : { parsedBody: mcpParsedBody });
985
1010
  // Disconnect after response to prevent McpServer leak
@@ -1007,11 +1032,9 @@ return Bun.serve({
1007
1032
  // 1. legacy AUTH_TOKEN (master) → any network
1008
1033
  // 2/3. ntok_ / utok_ → must CURRENTLY be a member of the requested
1009
1034
  // network. Membership is checked unconditionally — there is NO
1010
- // token-bound-network shortcut. removeNetworkMember deletes the
1011
- // membership row but does NOT revoke the user's ntok, so the
1012
- // network_members lookup IS the revocation mechanism: an ntok
1013
- // bound to this network whose owner was removed must lose the
1014
- // stream (审查修复 per 通信龙 #461 review, finding 1).
1035
+ // token-bound-network shortcut (审查修复 per 通信龙 #461 review,
1036
+ // finding 1). Since #488 removeNetworkMember also revokes the
1037
+ // user's tokens and resolveToken refuses a non-member's ntok.
1015
1038
  const netEventsMatch = url.pathname.match(/^\/events\/network\/(.+)$/);
1016
1039
  if (netEventsMatch && req.method === "GET") {
1017
1040
  const authErr = requireAuth(req);
@@ -1028,6 +1051,8 @@ return Bun.serve({
1028
1051
  if (!observerRole) {
1029
1052
  return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
1030
1053
  }
1054
+ const streamDenied = restNodeDenied(req, authCtx, "SSE /events/network/:id", (id) => streamVerdict(id, "network"));
1055
+ if (streamDenied) return streamDenied;
1031
1056
  // 多用户 Agent 权限:受限成员只收到自己是一端的路由事件(自己发的 / 发给自己的),
1032
1057
  // 看不到网络里别人和各个 Agent 之间的流量。
1033
1058
  if (isAgentRestricted(authCtx.userId, observedNetId)) {
@@ -1103,6 +1128,11 @@ return Bun.serve({
1103
1128
  if (!session && authCtx.networkId !== scopedNetId) {
1104
1129
  return withCors(req, Response.json({ ok: false, error: "session not in requested network" }, { status: 403 }));
1105
1130
  }
1131
+ const streamDenied = restNodeDenied(req, authCtx, "SSE /events/:session", (id) => streamVerdict(id, {
1132
+ alias: sessionName,
1133
+ nodeId: db.get<{ node_id: string | null }>("SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2", sessionName, scopedNetId)?.node_id ?? null,
1134
+ }));
1135
+ if (streamDenied) return streamDenied;
1106
1136
  return createSSEStream(sessionName, scopedNetId);
1107
1137
  }
1108
1138
 
@@ -1731,7 +1761,7 @@ return Bun.serve({
1731
1761
  // 在这棵子树里也能写;越出子树 → 403 department_scope_denied(固定一个错误)。不是负责人的成员与以前逐字节相同。 ──
1732
1762
  const deptMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/departments(?:\/([^/]+))?$/);
1733
1763
  const memberDeptMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/members\/([^/]+)\/department$/);
1734
- const deptSubMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/departments\/([^/]+)\/(project-grants|nodes)$/);
1764
+ const deptSubMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/departments\/([^/]+)\/(project-grants|nodes|group)$/);
1735
1765
  if (deptMatch || memberDeptMatch || deptSubMatch) {
1736
1766
  const token = requestToken(req, { allowQueryToken: false });
1737
1767
  const resolved = token ? resolveToken(token) : null;
@@ -1779,6 +1809,35 @@ return Bun.serve({
1779
1809
  });
1780
1810
  return withCors(req, Response.json({ ok: true, network_id: netId, department_id: deptId, nodes }));
1781
1811
  }
1812
+ if (deptSubMatch[3] === "group") {
1813
+ // RFC-042(#457)部门群:GET 看部门挂着的群,POST 建群。Agent(节点令牌)不进群,一律 403。
1814
+ // 建:owner / admin / Hub 管理员,或这个部门(含上级)的负责人。读:上面这些人,和群成员;别人 → 404(不暴露有没有群)。
1815
+ if (resolved.networkId) return withCors(req, Response.json({ ok: false, error: "humans_only" }, { status: 403 }));
1816
+ const mayCreate = canManage || head.managed.has(deptId);
1817
+ if (req.method === "GET") {
1818
+ const linked = getDepartmentGroup(netId, deptId);
1819
+ // 兜底对账(RFC-042 §4):读之前先把成员对齐,再判「是不是成员」。
1820
+ if (linked) db.transaction(() => { syncDepartmentGroups(netId, linked.id); });
1821
+ const group = linked ? getDepartmentGroup(netId, deptId) : null;
1822
+ if (!group || (!mayCreate && !isGroupMember(group.id, resolved.user.user_id))) {
1823
+ return withCors(req, Response.json({ ok: false, error: "department_group_not_found" }, { status: 404 }));
1824
+ }
1825
+ const viewerCan = groupViewerCan(group, isGroupMember(group.id, resolved.user.user_id), canManage, head.managed);
1826
+ return withCors(req, Response.json({ ok: true, network_id: netId, department_id: deptId, group: { ...group, viewer_can: viewerCan }, members: listGroupMembers(group.id) }));
1827
+ }
1828
+ if (req.method !== "POST") return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
1829
+ if (!mayCreate) return isHead ? scopeDenied() : withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
1830
+ let groupBody: Record<string, unknown> = {};
1831
+ try { const t = await req.text(); const b = t ? JSON.parse(t) : {}; groupBody = 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 })); }
1832
+ const r = createDepartmentGroup(netId, deptId, resolved.user.user_id, groupBody);
1833
+ if (!r.ok) {
1834
+ const { ok: _ok, status, ...rest } = r;
1835
+ return withCors(req, Response.json({ ok: false, ...rest }, { status }));
1836
+ }
1837
+ auditDepartment(resolved.user, "department_group_created", netId, JSON.stringify({ id: deptId, group_id: r.group.id, members: r.members.length, ...(canManage ? {} : { via: "leader" }) }));
1838
+ const createdCan = groupViewerCan(r.group, r.members.some((m) => m.user_id === resolved.user.user_id), canManage, head.managed);
1839
+ return withCors(req, Response.json({ ok: true, network_id: netId, department_id: deptId, group: { ...r.group, viewer_can: createdCan }, members: r.members }, { status: 201 }));
1840
+ }
1782
1841
  // 部门项目授权:只有 owner / admin(和按人授权一样)。
1783
1842
  if (!canManage) return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
1784
1843
  if (req.method === "GET") {
@@ -1869,6 +1928,108 @@ return Bun.serve({
1869
1928
  return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
1870
1929
  }
1871
1930
 
1931
+ // ── RFC-042(#457):群(目前只有部门群)。
1932
+ // GET …/chat-groups = 我在里面的群(owner / admin / Hub 管理员看到全部);GET …/chat-groups/:gid = 群资料 + 成员
1933
+ // (群成员、owner / admin、该部门(含上级)负责人;别人 404)。建群走 POST …/departments/:dept/group。
1934
+ // 第二个 PR:PATCH …/chat-groups/:gid {name} 改群名;POST …/chat-groups/:gid/members {user_id} 手动拉人;
1935
+ // DELETE …/chat-groups/:gid/members/:uid 手动移人(source='department' 的人 → 409 department_member)。
1936
+ // 管群 = owner / admin / Hub 管理员,或挂部门的群的该部门(含上级)负责人。看得见但管不了 → 403 group_manage_denied;
1937
+ // 看不见 → 404 group_not_found(不暴露有没有群)。Agent(节点令牌)不进群,一律 403 humans_only。 ──
1938
+ const chatGroupsMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/chat-groups(?:\/([^/]+)(?:\/(members|messages|read)(?:\/([^/]+))?)?)?$/);
1939
+ if (chatGroupsMatch) {
1940
+ const token = requestToken(req, { allowQueryToken: false });
1941
+ const resolved = token ? resolveToken(token) : null;
1942
+ if (!resolved) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
1943
+ if (resolved.networkId) return withCors(req, Response.json({ ok: false, error: "humans_only" }, { status: 403 }));
1944
+ const netId = decodeURIComponent(chatGroupsMatch[1]);
1945
+ const hubAdmin = isHubAdminCredential(resolved);
1946
+ const role = getUserNetworkRole(resolved.user.user_id, netId);
1947
+ if (!hubAdmin && !role) return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
1948
+ const canManage = hubAdmin || role === "owner" || role === "admin";
1949
+ const gid = chatGroupsMatch[2] ? decodeURIComponent(chatGroupsMatch[2]) : null;
1950
+ const sub = chatGroupsMatch[3];
1951
+ const memberUid = chatGroupsMatch[4] ? decodeURIComponent(chatGroupsMatch[4]) : null;
1952
+ // viewer 当负责人不获得任何东西(headScope 已保证);owner / admin 本来全管,不必算。
1953
+ const managed = canManage ? new Set<string>() : headScope(netId, resolved.user.user_id).managed;
1954
+ if (!gid) {
1955
+ if (req.method !== "GET") return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
1956
+ // 第三个 PR:每个群附上我的 unread / last_message_at(只对我在里面的群;管理者看到的非成员群是 0 / null —— 不让管理身份窥探消息时间)。
1957
+ // App 补口(只增):last_message 预览(同一口径,非成员群 null)和 viewer_can {manage, post}(与写接口同一判据)。
1958
+ // listGroupThreads 已经批量带上 last_message,整张列表固定几次查询,不逐群查。
1959
+ const threads = new Map(listGroupThreads(netId, resolved.user.user_id).map((t) => [t.group_id, t]));
1960
+ const groups = listVisibleGroups(netId, resolved.user.user_id, canManage).map((g) => {
1961
+ const t = g.is_member ? threads.get(g.id) : undefined;
1962
+ return { ...g, unread: t?.unread ?? 0, last_message_at: t?.last_at ?? null, last_message: t?.last_message ?? null, viewer_can: groupViewerCan(g, g.is_member, canManage, managed) };
1963
+ });
1964
+ return withCors(req, Response.json({ ok: true, network_id: netId, groups }));
1965
+ }
1966
+ const notFound = () => withCors(req, Response.json({ ok: false, error: "group_not_found" }, { status: 404 }));
1967
+ // 第三个 PR(群消息):GET / POST …/:gid/messages、POST …/:gid/read。只认**当前群成员** —— owner / admin / 负责人
1968
+ // 不是成员也读不到消息(他们看得到群资料,但消息只给群里的人);别人(含被移出的人)一律 404 group_not_found。
1969
+ if (sub === "messages" || sub === "read") {
1970
+ if (memberUid) return notFound();
1971
+ const ok = (sub === "messages" && (req.method === "GET" || req.method === "POST")) || (sub === "read" && req.method === "POST");
1972
+ if (!ok) return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
1973
+ if (!memberGroup(netId, gid, resolved.user.user_id)) return notFound();
1974
+ const failedMsg = (r: { status: number; error: string }) => withCors(req, Response.json({ ok: false, error: r.error }, { status: r.status }));
1975
+ if (sub === "messages" && req.method === "GET") {
1976
+ const page = listGroupMessages(gid, resolved.user.user_id, Number(url.searchParams.get("limit")) || 50, url.searchParams.get("before"));
1977
+ if ("ok" in page) return failedMsg(page);
1978
+ return withCors(req, Response.json({ ok: true, network_id: netId, group_id: gid, ...page, unread: groupUnreadFor(gid, resolved.user.user_id) }));
1979
+ }
1980
+ let mbody: Record<string, unknown> = {};
1981
+ try { const t = await req.text(); const b = t ? JSON.parse(t) : {}; if (!b || typeof b !== "object" || Array.isArray(b)) throw new Error("not an object"); mbody = b as Record<string, unknown>; } catch { return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 })); }
1982
+ if (sub === "read") {
1983
+ const r = markGroupRead(netId, gid, resolved.user.user_id, mbody.seq);
1984
+ if (!r.ok) return failedMsg(r);
1985
+ return withCors(req, Response.json({ ok: true, network_id: netId, group_id: gid, last_read_seq: r.last_read_seq, unread: r.unread }));
1986
+ }
1987
+ const sent = sendGroupMessage({
1988
+ networkId: netId, groupId: gid,
1989
+ sender: { userId: resolved.user.user_id, username: resolved.user.username },
1990
+ message: mbody.message, attachments: mbody.attachments, clientRequestId: mbody.client_request_id,
1991
+ });
1992
+ if (!sent.ok) return failedMsg(sent);
1993
+ return withCors(req, Response.json({ ok: true, network_id: netId, group_id: gid, message: sent.message, duplicate: sent.duplicate, delivered_to: sent.delivered_to }));
1994
+ }
1995
+ if (!sub && req.method === "GET") {
1996
+ const got = readGroup(netId, gid, resolved.user.user_id, canManage, managed);
1997
+ if (!got) return notFound();
1998
+ return withCors(req, Response.json({ ok: true, network_id: netId, ...got }));
1999
+ }
2000
+ const allowed = (!sub && req.method === "PATCH") || (sub === "members" && !memberUid && req.method === "POST") || (sub === "members" && memberUid && req.method === "DELETE");
2001
+ if (!allowed) return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
2002
+ const group = groupById(netId, gid);
2003
+ if (!group) return notFound();
2004
+ if (!canManageGroup(group, canManage, managed)) {
2005
+ return isGroupMember(gid, resolved.user.user_id)
2006
+ ? withCors(req, Response.json({ ok: false, error: "group_manage_denied" }, { status: 403 }))
2007
+ : notFound();
2008
+ }
2009
+ let body: Record<string, unknown> = {};
2010
+ if (req.method !== "DELETE") {
2011
+ try { const t = await req.text(); const b = t ? JSON.parse(t) : {}; 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 })); }
2012
+ }
2013
+ const via = canManage ? {} : { via: "leader" };
2014
+ const failed = (r: { status: number; error: string }) => withCors(req, Response.json({ ok: false, error: r.error }, { status: r.status }));
2015
+ if (!sub) {
2016
+ const r = renameGroup(netId, gid, body);
2017
+ if (!r.ok) return failed(r);
2018
+ auditDepartment(resolved.user, "chat_group_renamed", netId, JSON.stringify({ group_id: gid, name: r.group.name, ...via }));
2019
+ return withCors(req, Response.json({ ok: true, network_id: netId, group: { ...r.group, viewer_can: groupViewerCan(r.group, isGroupMember(gid, resolved.user.user_id), canManage, managed) } }));
2020
+ }
2021
+ if (!memberUid) {
2022
+ const r = addManualMember(netId, gid, body.user_id);
2023
+ if (!r.ok) return failed(r);
2024
+ auditDepartment(resolved.user, "chat_group_member_added", netId, JSON.stringify({ group_id: gid, user_id: r.member.user_id, ...via }));
2025
+ return withCors(req, Response.json({ ok: true, network_id: netId, group_id: gid, member: r.member }, { status: 201 }));
2026
+ }
2027
+ const r = removeManualMember(netId, gid, memberUid);
2028
+ if (!r.ok) return failed(r);
2029
+ auditDepartment(resolved.user, "chat_group_member_removed", netId, JSON.stringify({ group_id: gid, user_id: memberUid, ...via }));
2030
+ return withCors(req, Response.json({ ok: true, network_id: netId, group_id: gid, removed: memberUid }));
2031
+ }
2032
+
1872
2033
  // ── V3.13: Network members + invites ──
1873
2034
  const membersMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/members(?:\/([^/]+))?$/);
1874
2035
  if (membersMatch) {
@@ -2271,6 +2432,67 @@ return Bun.serve({
2271
2432
  // GET /api/dm?network_id=&with=<user_id>[&limit&before] → 双向会话记录(新的在前)
2272
2433
  // GET /api/dm/threads?network_id= → 每个对方一行 + 未读数
2273
2434
  // 已读沿用 POST /api/messages/ack(私信就是收件人 user_inbox 里的行)。
2435
+ // ── RFC-041(#487):节点自己的权限 —— 改模式、看「本来会拦」的报表。只给人(用户令牌)。 ──
2436
+ const permModeMatch = url.pathname.match(/^\/api\/nodes\/([^/]+)\/permission-mode$/);
2437
+ if (permModeMatch && req.method === "PUT") {
2438
+ if (!restAuth) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
2439
+ if (restAuth.networkId) return userTokenRequired(req);
2440
+ let body: any;
2441
+ try { body = await req.json(); } catch { return withCors(req, Response.json({ ok: false, error: "invalid_json" }, { status: 400 })); }
2442
+ const mode = body?.mode;
2443
+ if (!NODE_PERMISSION_MODES.includes(mode)) {
2444
+ return withCors(req, Response.json({ ok: false, error: "invalid_permission_mode", message: `mode must be one of ${NODE_PERMISSION_MODES.join(", ")}` }, { status: 400 }));
2445
+ }
2446
+ const nodeRef = decodeURIComponent(permModeMatch[1]);
2447
+ const node = db.get<{ node_id: string; network_id: string | null; owner_user_id: string | null; permission_mode: string | null }>(
2448
+ "SELECT node_id, network_id, owner_user_id, permission_mode FROM nodes WHERE node_id = ?1", nodeRef);
2449
+ const role = node?.network_id ? getUserNetworkRole(restAuth.userId, node.network_id) : null;
2450
+ // 看不见这个节点(不在它的网络里)与不存在同一个 404。
2451
+ if (!node || !node.network_id || (!role && !isAdmin)) return withCors(req, Response.json({ ok: false, error: "node_not_found" }, { status: 404 }));
2452
+ // 节点主人、网络 owner / admin、Hub 管理员能改;部门负责人和别的成员不能(RFC-041 §2.3 / §2.4)。
2453
+ if (!isAdmin && node.owner_user_id !== restAuth.userId && role !== "owner" && role !== "admin") {
2454
+ 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 }));
2455
+ }
2456
+ const previous = (node.permission_mode || "normal") as NodePermissionMode;
2457
+ db.run("UPDATE nodes SET permission_mode = ?1 WHERE node_id = ?2", [mode, node.node_id]);
2458
+ logAudit(restAuth.userId, restAuth.username, "node_permission_mode_changed", "node", node.node_id, JSON.stringify({ from: previous, to: mode }), getClientIP(req), node.network_id);
2459
+ return withCors(req, Response.json({ ok: true, node_id: node.node_id, permission_mode: mode, previous }));
2460
+ }
2461
+ const permReportMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/node-permission-report$/);
2462
+ if (permReportMatch && req.method === "GET") {
2463
+ if (!restAuth) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
2464
+ if (restAuth.networkId) return userTokenRequired(req);
2465
+ const networkId = decodeURIComponent(permReportMatch[1]);
2466
+ const role = getUserNetworkRole(restAuth.userId, networkId);
2467
+ if (!isAdmin && role !== "owner" && role !== "admin") {
2468
+ return withCors(req, Response.json({ ok: false, error: "permission_denied", message: "network owner/admin only" }, { status: 403 }));
2469
+ }
2470
+ const sinceRaw = url.searchParams.get("since");
2471
+ const sinceMs = sinceRaw ? Date.parse(sinceRaw) : Date.now() - 7 * 86_400_000;
2472
+ if (!Number.isFinite(sinceMs)) return withCors(req, Response.json({ ok: false, error: "invalid_since" }, { status: 400 }));
2473
+ const sinceHour = new Date(sinceMs).toISOString().slice(0, 13);
2474
+ const rows = db.all<{ node_id: string; route: string; reason: string; hits: number | string; sample: string | null; last_hour: string }>(
2475
+ `SELECT node_id, route, reason, SUM(hits) AS hits, MAX(sample) AS sample, MAX(hour) AS last_hour
2476
+ FROM node_permission_log WHERE network_id = ?1 AND hour >= ?2
2477
+ GROUP BY node_id, route, reason ORDER BY node_id, route, reason`,
2478
+ networkId, sinceHour);
2479
+ 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 }> }>();
2480
+ for (const r of rows) {
2481
+ let entry = byNode.get(r.node_id);
2482
+ if (!entry) {
2483
+ const n = db.get<{ alias: string | null; permission_mode: string | null }>("SELECT alias, permission_mode FROM nodes WHERE node_id = ?1", r.node_id);
2484
+ entry = { node_id: r.node_id, alias: n?.alias ?? null, permission_mode: n?.permission_mode || "normal", total: 0, by_reason: {}, routes: [] };
2485
+ byNode.set(r.node_id, entry);
2486
+ }
2487
+ const hits = Number(r.hits);
2488
+ entry.total += hits;
2489
+ entry.by_reason[r.reason] = (entry.by_reason[r.reason] ?? 0) + hits;
2490
+ entry.routes.push({ route: r.route, reason: r.reason, hits, sample: r.sample, last_hour: r.last_hour });
2491
+ }
2492
+ const nodes = [...byNode.values()].sort((a, b) => b.total - a.total || a.node_id.localeCompare(b.node_id));
2493
+ 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 }));
2494
+ }
2495
+
2274
2496
  if (url.pathname === "/api/dm" || url.pathname === "/api/dm/threads") {
2275
2497
  if (!restAuth || !requestToken(req).startsWith("utok_")) {
2276
2498
  return withCors(req, Response.json({ ok: false, error: "user_token_required" }, { status: 403 }));
@@ -2297,7 +2519,8 @@ return Bun.serve({
2297
2519
  return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
2298
2520
  }
2299
2521
  if (url.pathname === "/api/dm/threads") {
2300
- return withCors(req, Response.json({ ok: true, network_id: dmNet, threads: listDmThreads(dmNet, restAuth.userId) }));
2522
+ // group_threads(RFC-042 第三个 PR,只增字段):我在里面的群,每个带 unread / last_at / last_message 预览 —— App 私信未读角标可以一起算。
2523
+ return withCors(req, Response.json({ ok: true, network_id: dmNet, threads: listDmThreads(dmNet, restAuth.userId), group_threads: listGroupThreads(dmNet, restAuth.userId) }));
2301
2524
  }
2302
2525
  const other = url.searchParams.get("with") ?? "";
2303
2526
  if (!other) return withCors(req, Response.json({ ok: false, error: "with_required" }, { status: 400 }));
@@ -3190,6 +3413,11 @@ return Bun.serve({
3190
3413
  }
3191
3414
  const canonical = resolveCanonicalAlias(taskNetId, body.alias);
3192
3415
  const targetAlias = canonical.alias;
3416
+ const nodeDenied = restNodeDenied(req, restAuth, "POST /api/task", (id) => dispatchVerdict(id, {
3417
+ alias: targetAlias,
3418
+ nodeId: db.get<{ node_id: string | null }>("SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2", targetAlias, taskNetId)?.node_id ?? null,
3419
+ }));
3420
+ if (nodeDenied) return nodeDenied;
3193
3421
  const target = resolveRestDeliveryTarget(targetAlias, taskNetId);
3194
3422
  if (target.state === "not_found") {
3195
3423
  return withCors(req, Response.json({
@@ -3425,6 +3653,8 @@ return Bun.serve({
3425
3653
  if (!canRestWriteNetwork(restAuth, restScope.networkId, isAdmin)) {
3426
3654
  return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
3427
3655
  }
3656
+ const broadcastDenied = restNodeDenied(req, restAuth, "POST /api/broadcast", broadcastVerdict);
3657
+ if (broadcastDenied) return broadcastDenied;
3428
3658
  let sql = "SELECT alias, node_id, network_id FROM sessions WHERE alias IS NOT NULL";
3429
3659
  const params: any[] = [];
3430
3660
  sql = addNetworkScope(sql, params, restScope);
@@ -3797,6 +4027,8 @@ return Bun.serve({
3797
4027
  if (!canRestWriteNetwork(restAuth, nodeNetId, isAdmin)) {
3798
4028
  return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
3799
4029
  }
4030
+ const nodeWriteDenied = restNodeDenied(req, restAuth, "DELETE /api/nodes/:id", nodeTargetVerdict(node.node_id));
4031
+ if (nodeWriteDenied) return nodeWriteDenied;
3800
4032
 
3801
4033
  db.transaction(() => {
3802
4034
  db.run("DELETE FROM nodes WHERE node_id = ?1", [node.node_id]);
@@ -3907,6 +4139,8 @@ return Bun.serve({
3907
4139
  if (!canRestWriteNetwork(restAuth, nodeNetId, isAdmin)) {
3908
4140
  return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
3909
4141
  }
4142
+ const nodeWriteDenied = restNodeDenied(req, restAuth, "PUT /api/nodes/:id/attrs", nodeTargetVerdict(node.node_id));
4143
+ if (nodeWriteDenied) return nodeWriteDenied;
3910
4144
 
3911
4145
  // Optimistic lock BEFORE any narrowing work, so a losing writer never
3912
4146
  // has side effects.
@@ -4006,6 +4240,8 @@ return Bun.serve({
4006
4240
  if (!canRestWriteNetwork(restAuth, nodeNetId, isAdmin)) {
4007
4241
  return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
4008
4242
  }
4243
+ const nodeWriteDenied = restNodeDenied(req, restAuth, "PUT /api/nodes/:id/avatar", nodeTargetVerdict(node.node_id));
4244
+ if (nodeWriteDenied) return nodeWriteDenied;
4009
4245
 
4010
4246
  db.run(
4011
4247
  "UPDATE nodes SET avatar_url = ?1, updated_at = datetime('now') WHERE node_id = ?2",
@@ -4271,7 +4507,7 @@ return Bun.serve({
4271
4507
  config_path, channels, server, hostname,
4272
4508
  network_id, created_at, updated_at,
4273
4509
  config_snapshot, lifecycle_state, avatar_url,
4274
- display_name, team, tags, attrs_revision
4510
+ display_name, team, tags, attrs_revision, permission_mode, owner_user_id
4275
4511
  FROM nodes WHERE 1=1`;
4276
4512
  const params: any[] = [];
4277
4513
  sql = addAgentNetworkScope(sql, params, restScope, { alias: "alias", nodeId: "node_id" });
@@ -4312,6 +4548,17 @@ return Bun.serve({
4312
4548
  }
4313
4549
  })();
4314
4550
 
4551
+ // RFC-041(#489):调用者能不能改这个节点的权限模式 —— 与 PUT /api/nodes/:id/permission-mode 同一判据
4552
+ // (节点主人、网络 owner / admin、Hub 管理员;只给用户令牌)。客户端据此决定显示「权限」一行,不自己推导规则。
4553
+ const netRoles = new Map<string, string | null>();
4554
+ const canSetPermissionMode = (r: Record<string, any>): boolean => {
4555
+ if (!restAuth || restAuth.networkId) return false;
4556
+ if (isAdmin || (r.owner_user_id && r.owner_user_id === restAuth.userId)) return true;
4557
+ if (!r.network_id) return false;
4558
+ if (!netRoles.has(r.network_id)) netRoles.set(r.network_id, getUserNetworkRole(restAuth.userId, r.network_id));
4559
+ const role = netRoles.get(r.network_id);
4560
+ return role === "owner" || role === "admin";
4561
+ };
4315
4562
  const rows = rawRows.map(r => {
4316
4563
  let role: string | null = null;
4317
4564
  const snap = r.config_snapshot;
@@ -4319,7 +4566,7 @@ return Bun.serve({
4319
4566
  try { role = (typeof snap === "string" ? JSON.parse(snap) : snap)?.role ?? null; }
4320
4567
  catch { /* malformed snapshot — leave role null */ }
4321
4568
  }
4322
- const { config_snapshot, ...rest } = r;
4569
+ const { config_snapshot, owner_user_id, ...rest } = r;
4323
4570
  // `tags` is stored as JSON text; hand the dashboard a real array so
4324
4571
  // it can map() without a guard, and normalise legacy/absent values
4325
4572
  // to [] rather than null. attrs_revision is normalised to a number
@@ -4335,6 +4582,7 @@ return Bun.serve({
4335
4582
  role,
4336
4583
  lifecycle_controllable,
4337
4584
  lifecycle_daemon_node_id: controllable.get(r.node_id) ?? null,
4585
+ viewer_can: { permission_mode: canSetPermissionMode(r) },
4338
4586
  };
4339
4587
  });
4340
4588
  return withCors(req, Response.json({ ok: true, nodes: rows, count: rows.length }));
@@ -99,7 +99,7 @@ export function listProjectGrants(networkId: string, userId: string): ProjectGra
99
99
  const likeEscape = (s: string) => s.replace(/[\\%_]/g, (c) => `\\${c}`);
100
100
  export const userRefPattern = (userId: string) => `%${likeEscape(`"kind":"user","id":"${userId}"`)}%`;
101
101
 
102
- type CardRow = { network_id: string; owner_json: string | null; participants_json: string | null; created_by: string | null; created_by_json: string | null; project_id: string | null; agent_owner_json?: string | null };
102
+ type CardRow = { network_id: string; owner_json: string | null; participants_json: string | null; created_by: string | null; created_by_json: string | null; project_id: string | null; agent_owner_json?: string | null; parent_id?: string | null };
103
103
  const refIs = (json: string | null, userId: string) => !!json && json.includes(`"kind":"user","id":"${userId}"`);
104
104
  const ownsCard = (row: CardRow, userId: string) => refIs(row.owner_json, userId);
105
105
  const createdCard = (row: CardRow, userId: string) => refIs(row.created_by_json, userId) || (!row.created_by_json && row.created_by === userId);
@@ -126,10 +126,33 @@ function headOf(userId: string, row: CardRow): boolean {
126
126
  return !!cards && isDepartmentCard(cards, row);
127
127
  }
128
128
 
129
- export type TaskCaller = { userId: string } | null;
129
+ /**
130
+ * 调用者。userId = 按谁的任务权限判;node(RFC-041,#487)= 节点令牌按**主人**的权限判时带上节点:
131
+ * only=false(正常模式、执行开关打开)—— 主人能改的能改,另外「派给这个节点的」在主人看得见时也能改;
132
+ * only=true (受限模式)—— 只看、只改派给这个节点的卡(负责 Agent / 参与人 / 它建的,及这些卡的子任务)。
133
+ */
134
+ export type TaskCaller = { userId: string; node?: { id: string; only: boolean } } | null;
135
+
136
+ export const nodeRefPattern = (nodeId: string) => `%${likeEscape(`"kind":"node","id":"${nodeId}"`)}%`;
137
+ const nodeRefIs = (json: string | null | undefined, nodeId: string) => !!json && json.includes(`"kind":"node","id":"${nodeId}"`);
138
+ const directlyAssigned = (row: Pick<CardRow, "agent_owner_json" | "participants_json" | "created_by_json">, nodeId: string) =>
139
+ nodeRefIs(row.agent_owner_json, nodeId) || nodeRefIs(row.participants_json, nodeId) || nodeRefIs(row.created_by_json, nodeId);
140
+
141
+ /** 这张卡派给了这个节点吗:负责 Agent / 参与人有它 / 它建的,或者父卡是。 */
142
+ export function cardAssignedToNode(row: CardRow, nodeId: string): boolean {
143
+ if (directlyAssigned(row, nodeId)) return true;
144
+ if (!row.parent_id) return false;
145
+ const parent = db.get<{ agent_owner_json: string | null; participants_json: string | null; created_by_json: string | null }>(
146
+ "SELECT agent_owner_json, participants_json, created_by_json FROM requirements WHERE requirement_id = ?1", row.parent_id);
147
+ return !!parent && directlyAssigned(parent, nodeId);
148
+ }
130
149
 
131
150
  /** 看得见这张卡吗。caller=null(节点令牌 / 管理员 / 旧的全局令牌)恒为 true。 */
132
151
  export function canSeeTask(caller: TaskCaller, row: CardRow): boolean {
152
+ if (caller?.node?.only && !cardAssignedToNode(row, caller.node.id)) return false;
153
+ return canSeeTaskAsUser(caller, row);
154
+ }
155
+ function canSeeTaskAsUser(caller: TaskCaller, row: CardRow): boolean {
133
156
  if (!caller) return true;
134
157
  const m = memberRow(caller.userId, row.network_id);
135
158
  if (!scopedRow(m)) return true;
@@ -141,6 +164,13 @@ export function canSeeTask(caller: TaskCaller, row: CardRow): boolean {
141
164
 
142
165
  /** 能改这张卡吗(PATCH / 勾子任务)。只对看得见的卡调用。 */
143
166
  export function canEditTask(caller: TaskCaller, row: CardRow): boolean {
167
+ if (caller?.node) {
168
+ const assigned = cardAssignedToNode(row, caller.node.id) && canSeeTaskAsUser(caller, row);
169
+ return caller.node.only ? assigned : assigned || canEditTaskAsUser(caller, row);
170
+ }
171
+ return canEditTaskAsUser(caller, row);
172
+ }
173
+ function canEditTaskAsUser(caller: TaskCaller, row: CardRow): boolean {
144
174
  if (!caller) return true;
145
175
  const m = memberRow(caller.userId, row.network_id);
146
176
  if (!scopedRow(m)) return true;
@@ -174,7 +204,7 @@ export const PARTICIPANT_EDIT_FIELDS: readonly string[] = ["column", "checklist"
174
204
 
175
205
  /** 这个调用者是不是「只能以参与人身份改」这张卡(canEditTask 为 false、但他是卡上的参与人)。viewer 不算。 */
176
206
  export function canParticipantEditTask(caller: TaskCaller, row: CardRow): boolean {
177
- if (!caller) return false;
207
+ if (!caller || caller.node) return false; // 参与人身份是人的;节点按 canEditTask 判
178
208
  const m = memberRow(caller.userId, row.network_id);
179
209
  if (!scopedRow(m) || m?.member_role === "viewer") return false;
180
210
  return participates(row, caller.userId);
@@ -258,6 +288,14 @@ export function canUseProject(caller: TaskCaller, networkId: string, projectId:
258
288
  * 追加在已经按网络过滤过的 `FROM requirements WHERE …` 后面。
259
289
  */
260
290
  export function addTaskVisibilityScope(sql: string, params: unknown[], caller: TaskCaller): string {
291
+ const scoped = addTaskVisibilityScopeAsUser(sql, params, caller);
292
+ if (!caller?.node?.only) return scoped;
293
+ // 受限节点:再只留派给它的卡(及其子任务)。
294
+ const pat = params.push(nodeRefPattern(caller.node.id));
295
+ const mine = (t: string) => `${t}agent_owner_json LIKE ?${pat} ESCAPE '\\' OR ${t}participants_json LIKE ?${pat} ESCAPE '\\' OR ${t}created_by_json LIKE ?${pat} ESCAPE '\\'`;
296
+ return `${scoped} AND (${mine("")} OR parent_id IN (SELECT p.requirement_id FROM requirements p WHERE ${mine("p.")}))`;
297
+ }
298
+ function addTaskVisibilityScopeAsUser(sql: string, params: unknown[], caller: TaskCaller): string {
261
299
  if (!caller) return sql;
262
300
  const scoped = taskScopedNetworks(caller.userId);
263
301
  if (!scoped.length) return sql;