@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/package.json +1 -1
- package/src/auth.ts +2 -2
- package/src/department-groups-http.test.ts +209 -0
- package/src/department-groups-sync-http.test.ts +328 -0
- package/src/department-groups.ts +358 -0
- package/src/departments.ts +30 -9
- package/src/group-messages-http.test.ts +482 -0
- package/src/group-messages.ts +297 -0
- package/src/human-dm.ts +2 -2
- package/src/mcp-context-cost-http.test.ts +4 -2
- package/src/node-permissions-http.test.ts +276 -0
- package/src/node-permissions.ts +242 -0
- package/src/requirements-status-participants-http.test.ts +5 -1
- package/src/requirements.ts +76 -4
- package/src/server.ts +263 -15
- package/src/task-access.ts +41 -3
- package/src/tool-audience-http.test.ts +195 -0
- package/src/tool-audience.ts +109 -0
- package/src/tools-list-json-schema.test.ts +2 -1
- package/src/tools.ts +68 -2
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
|
-
|
|
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
|
-
|
|
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
|
|
1011
|
-
//
|
|
1012
|
-
//
|
|
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
|
-
|
|
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 }));
|
package/src/task-access.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|