@sleep2agi/commhub-server 0.9.0-preview.89 → 0.9.0-preview.91
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/agent-access.ts +4 -0
- package/src/auth.ts +40 -6
- package/src/department-heads-http.test.ts +329 -0
- package/src/department-heads.ts +226 -0
- package/src/departments.ts +7 -1
- package/src/mcp-context-cost-http.test.ts +117 -0
- package/src/network-token-membership-http.test.ts +139 -0
- package/src/observer-avatar-http.test.ts +7 -5
- package/src/requirement-events.ts +2 -1
- package/src/requirement-notify.ts +31 -0
- package/src/requirements-comment-http.test.ts +165 -0
- package/src/requirements-errors-mcp-http.test.ts +3 -2
- package/src/requirements-errors.ts +14 -3
- package/src/requirements-people-mcp-http.test.ts +225 -0
- package/src/requirements-people.ts +127 -0
- package/src/requirements-status-participants-http.test.ts +216 -0
- package/src/requirements.ts +125 -8
- package/src/rest-explicit-columns-http.test.ts +2 -0
- package/src/server.ts +131 -37
- package/src/task-access.ts +68 -10
- package/src/tools.ts +109 -40
package/src/server.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { statusAliasResolverRead } from "./status-alias-resolver.js";
|
|
|
5
5
|
import { readNodeHealth } from "./node-health-store.js";
|
|
6
6
|
import { assertNodeHealthy, degradedLayers } from "./node-health-guard.js";
|
|
7
7
|
import { auditDepartment, createDepartment, deleteDepartment, listDepartments, setMemberDepartment, updateDepartment } from "./departments.js";
|
|
8
|
+
import { DEPARTMENT_SCOPE_DENIED, departmentLeaders, departmentSubtree, headScope, listDepartmentProjectGrants, managedDepartmentIds, membersIn, replaceDepartmentProjectGrants } from "./department-heads.js";
|
|
8
9
|
import { redactMessageRow } from "./redact-tokens.js";
|
|
9
10
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
10
11
|
import { parseDbTimestampMs } from "./db-timestamp.js";
|
|
@@ -26,7 +27,7 @@ import { canMessageAgent, isAgentRestricted, listAgentGrants, listGroupGrants, g
|
|
|
26
27
|
import { validateAvatarUrl } from "./avatar-validate.js";
|
|
27
28
|
import { narrowTags, parseStoredTags, validateScalarAttr } from "./node-attrs-validate.js";
|
|
28
29
|
import { daemonDefaultWorkdirRoot } from "./create-node-validate.js";
|
|
29
|
-
import { register, login, resolveToken, isExpiredSessionToken, sessionIdleDays, listSessions, revokeSession, revokeOtherSessions, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, adminDeleteEmptyNetwork, renameNetwork, changePassword, issueUserToken, listTokens, createToken, revokeToken, getNetworkMembers, getNetworkHumans, getUserNetworkRole, addNetworkMember, adminCreateUser, listUsersWithMemberships, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
|
|
30
|
+
import { register, login, resolveToken, tokenRejection, isExpiredSessionToken, sessionIdleDays, listSessions, revokeSession, revokeOtherSessions, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, adminDeleteEmptyNetwork, renameNetwork, changePassword, issueUserToken, listTokens, createToken, revokeToken, getNetworkMembers, getNetworkHumans, getUserNetworkRole, addNetworkMember, adminCreateUser, listUsersWithMemberships, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
|
|
30
31
|
import { abortRename, cleanupCommittedRenameSessions, commitRename, prepareRename, resolveCanonicalAlias } from "./rename.js";
|
|
31
32
|
import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
|
|
32
33
|
import { clientRequestIdFromMeta, idempotentTaskId, idempotentTaskMatches, type StoredIdempotentTask } from "./task-idempotency.js";
|
|
@@ -224,7 +225,8 @@ function requireAuth(req: Request, options: RequestTokenOptions = {}): Response
|
|
|
224
225
|
return null;
|
|
225
226
|
}
|
|
226
227
|
|
|
227
|
-
|
|
228
|
+
// #488 —— 令牌存在但因主人的成员身份被拒:说清楚为什么(reason + hint),节点日志里一眼看懂为什么掉线。
|
|
229
|
+
return Response.json({ error: "unauthorized", ...(tokenRejection(token) ?? {}) }, { status: 401 });
|
|
228
230
|
}
|
|
229
231
|
|
|
230
232
|
function getClientIP(req: Request, server?: any): string {
|
|
@@ -324,7 +326,7 @@ function requireAdminAuth(req: Request): Response | null {
|
|
|
324
326
|
const token = requestToken(req);
|
|
325
327
|
if (!token) return Response.json({ ok: false, error: "auth required" }, { status: 401 });
|
|
326
328
|
const resolved = resolveToken(token);
|
|
327
|
-
if (!resolved) return Response.json({ ok: false, error: "invalid token" }, { status: 401 });
|
|
329
|
+
if (!resolved) return Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 });
|
|
328
330
|
if (!isHubAdminCredential(resolved)) return Response.json({ ok: false, error: "admin required" }, { status: 403 });
|
|
329
331
|
return null;
|
|
330
332
|
}
|
|
@@ -1277,11 +1279,13 @@ return Bun.serve({
|
|
|
1277
1279
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
1278
1280
|
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
1279
1281
|
const resolved = resolveToken(token);
|
|
1280
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1282
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1281
1283
|
// 节点令牌:user 仍是签发它的用户(旧 agent-node 靠它打日志),但只列它绑定的那个网络,
|
|
1282
1284
|
// 并用 credential 说清楚这是一个节点身份 —— user.role 不是这个调用者的权限。
|
|
1283
1285
|
const allNetworks = getUserAllNetworks(resolved.user.user_id);
|
|
1284
|
-
|
|
1286
|
+
// RFC-040:managed_department_ids = 我负责的部门 + 全部下级(空 = 不是负责人;viewer 恒空;节点令牌不是人,恒空)。
|
|
1287
|
+
const networks = (resolved.networkId ? allNetworks.filter((n: any) => n.network_id === resolved.networkId) : allNetworks)
|
|
1288
|
+
.map((n: any) => ({ ...n, managed_department_ids: resolved.networkId ? [] : managedDepartmentIds(n.network_id, resolved.user.user_id) }));
|
|
1285
1289
|
const credential = resolved.networkId
|
|
1286
1290
|
? { kind: "node", network_id: resolved.networkId, node_alias: resolved.tokenName?.startsWith("node:") ? resolved.tokenName.slice(5) : null, acts_as_owner: false }
|
|
1287
1291
|
: { kind: "user" };
|
|
@@ -1292,7 +1296,7 @@ return Bun.serve({
|
|
|
1292
1296
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
1293
1297
|
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
1294
1298
|
const resolved = resolveToken(token);
|
|
1295
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1299
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1296
1300
|
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1297
1301
|
try {
|
|
1298
1302
|
const body = await req.json() as any;
|
|
@@ -1317,7 +1321,7 @@ return Bun.serve({
|
|
|
1317
1321
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
1318
1322
|
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
1319
1323
|
const resolved = resolveToken(token);
|
|
1320
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1324
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1321
1325
|
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1322
1326
|
try {
|
|
1323
1327
|
const body = await req.json() as any;
|
|
@@ -1339,7 +1343,7 @@ return Bun.serve({
|
|
|
1339
1343
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
1340
1344
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1341
1345
|
const resolved = resolveToken(token);
|
|
1342
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1346
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1343
1347
|
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1344
1348
|
try {
|
|
1345
1349
|
const body = await req.json() as any;
|
|
@@ -1359,7 +1363,7 @@ return Bun.serve({
|
|
|
1359
1363
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
1360
1364
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1361
1365
|
const resolved = resolveToken(token);
|
|
1362
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1366
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1363
1367
|
try {
|
|
1364
1368
|
const body = await req.json() as any;
|
|
1365
1369
|
if (!body.network_id || !body.old_alias || !body.new_alias) {
|
|
@@ -1378,7 +1382,7 @@ return Bun.serve({
|
|
|
1378
1382
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
1379
1383
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1380
1384
|
const resolved = resolveToken(token);
|
|
1381
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1385
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1382
1386
|
try {
|
|
1383
1387
|
const body = await req.json() as any;
|
|
1384
1388
|
if (!body.txn_id) return withCors(req, Response.json({ ok: false, error: "txn_id required" }, { status: 400 }));
|
|
@@ -1395,7 +1399,7 @@ return Bun.serve({
|
|
|
1395
1399
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
1396
1400
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1397
1401
|
const resolved = resolveToken(token);
|
|
1398
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1402
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1399
1403
|
try {
|
|
1400
1404
|
const body = await req.json() as any;
|
|
1401
1405
|
if (!body.txn_id) return withCors(req, Response.json({ ok: false, error: "txn_id required" }, { status: 400 }));
|
|
@@ -1413,7 +1417,7 @@ return Bun.serve({
|
|
|
1413
1417
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
1414
1418
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1415
1419
|
const resolved = resolveToken(token);
|
|
1416
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1420
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1417
1421
|
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1418
1422
|
const tokens = listTokens(resolved.user.user_id);
|
|
1419
1423
|
return withCors(req, Response.json({ ok: true, tokens }));
|
|
@@ -1423,7 +1427,7 @@ return Bun.serve({
|
|
|
1423
1427
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
1424
1428
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1425
1429
|
const resolved = resolveToken(token);
|
|
1426
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1430
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1427
1431
|
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1428
1432
|
try {
|
|
1429
1433
|
const body = await req.json() as any;
|
|
@@ -1440,7 +1444,7 @@ return Bun.serve({
|
|
|
1440
1444
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
1441
1445
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1442
1446
|
const resolved = resolveToken(token);
|
|
1443
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1447
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1444
1448
|
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1445
1449
|
const result = revokeToken(resolved.user.user_id, tokenDeleteMatch[1]);
|
|
1446
1450
|
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "token_revoked", "token", tokenDeleteMatch[1]);
|
|
@@ -1453,7 +1457,7 @@ return Bun.serve({
|
|
|
1453
1457
|
const token = requestToken(req, { allowQueryToken: false });
|
|
1454
1458
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1455
1459
|
const resolved = resolveToken(token);
|
|
1456
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1460
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1457
1461
|
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1458
1462
|
const sessions = listSessions(resolved.user.user_id, resolved.tokenId);
|
|
1459
1463
|
return withCors(req, Response.json({ ok: true, sessions, current_token_id: resolved.tokenId, idle_timeout_days: sessionIdleDays() }));
|
|
@@ -1463,7 +1467,7 @@ return Bun.serve({
|
|
|
1463
1467
|
const token = requestToken(req, { allowQueryToken: false });
|
|
1464
1468
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1465
1469
|
const resolved = resolveToken(token);
|
|
1466
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1470
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1467
1471
|
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1468
1472
|
// 当前令牌必须是一次登录会话,否则「保留当前这个」无从说起(API 令牌调用会把所有会话都撤掉)。
|
|
1469
1473
|
const current = resolved.tokenId ? listSessions(resolved.user.user_id, resolved.tokenId).find((x) => x.is_current) : undefined;
|
|
@@ -1478,7 +1482,7 @@ return Bun.serve({
|
|
|
1478
1482
|
const token = requestToken(req, { allowQueryToken: false });
|
|
1479
1483
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1480
1484
|
const resolved = resolveToken(token);
|
|
1481
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1485
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1482
1486
|
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1483
1487
|
const targetId = decodeURIComponent(sessionDeleteMatch[1]);
|
|
1484
1488
|
const result = revokeSession(resolved.user.user_id, targetId);
|
|
@@ -1492,7 +1496,7 @@ return Bun.serve({
|
|
|
1492
1496
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
1493
1497
|
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
1494
1498
|
const resolved = resolveToken(token);
|
|
1495
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1499
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1496
1500
|
// V3.13: ntok_ can only see its bound network; utok_ sees all member networks
|
|
1497
1501
|
if (resolved.networkId) {
|
|
1498
1502
|
// ntok_ — only return the bound network
|
|
@@ -1522,7 +1526,7 @@ return Bun.serve({
|
|
|
1522
1526
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
1523
1527
|
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
1524
1528
|
const resolved = resolveToken(token);
|
|
1525
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1529
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1526
1530
|
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1527
1531
|
try {
|
|
1528
1532
|
const body = await req.json() as any;
|
|
@@ -1722,25 +1726,76 @@ return Bun.serve({
|
|
|
1722
1726
|
}
|
|
1723
1727
|
|
|
1724
1728
|
// ── 组织架构(board #419):部门 + 成员所在部门。读:网络任何成员 / 本网络的节点令牌 / Hub 管理员;
|
|
1725
|
-
// 写:网络 owner / admin、Hub 管理员(节点令牌不能写)。规则在 departments.ts。
|
|
1729
|
+
// 写:网络 owner / admin、Hub 管理员(节点令牌不能写)。规则在 departments.ts。
|
|
1730
|
+
// RFC-040(#455)部门负责人:每次请求按 leader_user_id 现算「我负责的部门 + 全部下级」(department-heads.ts),
|
|
1731
|
+
// 在这棵子树里也能写;越出子树 → 403 department_scope_denied(固定一个错误)。不是负责人的成员与以前逐字节相同。 ──
|
|
1726
1732
|
const deptMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/departments(?:\/([^/]+))?$/);
|
|
1727
1733
|
const memberDeptMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/members\/([^/]+)\/department$/);
|
|
1728
|
-
|
|
1734
|
+
const deptSubMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/departments\/([^/]+)\/(project-grants|nodes)$/);
|
|
1735
|
+
if (deptMatch || memberDeptMatch || deptSubMatch) {
|
|
1729
1736
|
const token = requestToken(req, { allowQueryToken: false });
|
|
1730
1737
|
const resolved = token ? resolveToken(token) : null;
|
|
1731
1738
|
if (!resolved) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1732
|
-
const netId = decodeURIComponent((deptMatch ?? memberDeptMatch)![1]);
|
|
1739
|
+
const netId = decodeURIComponent((deptMatch ?? memberDeptMatch ?? deptSubMatch)![1]);
|
|
1733
1740
|
if (resolved.networkId && resolved.networkId !== netId) return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
|
|
1734
1741
|
const hubAdmin = isHubAdminCredential(resolved);
|
|
1735
1742
|
const role = resolved.networkId ? null : getUserNetworkRole(resolved.user.user_id, netId);
|
|
1736
1743
|
if (!hubAdmin && !role && !resolved.networkId) return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
|
|
1737
1744
|
if (!db.get("SELECT 1 AS x FROM networks WHERE network_id = ?1", netId)) return withCors(req, Response.json({ ok: false, error: "network_not_found" }, { status: 404 }));
|
|
1745
|
+
const canManage = hubAdmin || (!resolved.networkId && (role === "owner" || role === "admin"));
|
|
1746
|
+
// 负责人范围:只算人(节点令牌不是人);owner / admin 本来全管,不必算。viewer 在 headScope 里恒为空。
|
|
1747
|
+
const head = canManage || resolved.networkId ? { managed: new Set<string>(), strict: new Set<string>() } : headScope(netId, resolved.user.user_id);
|
|
1748
|
+
const isHead = head.managed.size > 0;
|
|
1749
|
+
const scopeDenied = () => withCors(req, Response.json({ ok: false, error: DEPARTMENT_SCOPE_DENIED }, { status: 403 }));
|
|
1738
1750
|
if (deptMatch && !deptMatch[2] && req.method === "GET") {
|
|
1739
|
-
|
|
1751
|
+
const listed = listDepartments(netId);
|
|
1752
|
+
// viewer_can:manage = 能改名 / 移动 / 删除 / 换负责人;create_child = 能在它下面建子部门。
|
|
1753
|
+
// project_grants(部门项目授权)只给 owner / admin:项目 id 不对只看相关任务的成员公开。
|
|
1754
|
+
const departments = listed.departments.map((d) => ({ ...d, viewer_can: { manage: canManage || head.strict.has(d.id), create_child: canManage || head.managed.has(d.id) } }));
|
|
1755
|
+
return withCors(req, Response.json({ ok: true, network_id: netId, departments, members: listed.members, ...(canManage ? { project_grants: listDepartmentProjectGrants(netId) } : {}) }));
|
|
1756
|
+
}
|
|
1757
|
+
if (deptSubMatch) {
|
|
1758
|
+
const deptId = decodeURIComponent(deptSubMatch[2]);
|
|
1759
|
+
const subtree = departmentSubtree(netId, deptId);
|
|
1760
|
+
if (!subtree.size) return withCors(req, Response.json({ ok: false, error: "department_not_found" }, { status: 404 }));
|
|
1761
|
+
if (deptSubMatch[3] === "nodes") {
|
|
1762
|
+
// 本部门(含下级)成员拥有的节点:只读的状态与健康(RFC-040 Q3 —— 能管人 ≠ 能用 / 能管他的 Agent)。
|
|
1763
|
+
if (req.method !== "GET") return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
|
|
1764
|
+
if (!canManage && !head.managed.has(deptId)) return scopeDenied();
|
|
1765
|
+
const owners = membersIn(netId, subtree);
|
|
1766
|
+
const rows = db.all<{ node_id: string; alias: string | null; display_name: string | null; owner_user_id: string | null }>(
|
|
1767
|
+
"SELECT node_id, alias, display_name, owner_user_id FROM nodes WHERE network_id = ?1 AND owner_user_id IS NOT NULL ORDER BY alias, node_id", netId,
|
|
1768
|
+
).filter((n) => owners.has(n.owner_user_id!));
|
|
1769
|
+
const nodes = rows.map((n) => {
|
|
1770
|
+
const session = db.get<{ status: string | null; last_seen_at: string | null }>(
|
|
1771
|
+
"SELECT status, last_seen_at FROM sessions WHERE node_id = ?1 AND network_id = ?2 ORDER BY last_seen_at DESC LIMIT 1", n.node_id, netId,
|
|
1772
|
+
);
|
|
1773
|
+
const h = n.alias ? readNodeHealth(netId, n.alias) : null;
|
|
1774
|
+
return {
|
|
1775
|
+
node_id: n.node_id, alias: n.alias, display_name: n.display_name, owner_user_id: n.owner_user_id,
|
|
1776
|
+
status: session?.status ?? "offline", last_seen_at: session?.last_seen_at ?? null,
|
|
1777
|
+
health: h?.health ?? null, degraded: degradedLayers(h?.health).map((l) => ({ layer: l.layer, label: l.label, reason: l.reason })),
|
|
1778
|
+
};
|
|
1779
|
+
});
|
|
1780
|
+
return withCors(req, Response.json({ ok: true, network_id: netId, department_id: deptId, nodes }));
|
|
1781
|
+
}
|
|
1782
|
+
// 部门项目授权:只有 owner / admin(和按人授权一样)。
|
|
1783
|
+
if (!canManage) return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
|
|
1784
|
+
if (req.method === "GET") {
|
|
1785
|
+
return withCors(req, Response.json({ ok: true, network_id: netId, department_id: deptId, project_grants: listDepartmentProjectGrants(netId, deptId).map(({ project_id, can_edit }) => ({ project_id, can_edit })) }));
|
|
1786
|
+
}
|
|
1787
|
+
if (req.method !== "PUT") return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
|
|
1788
|
+
let grantBody: Record<string, unknown> = {};
|
|
1789
|
+
try { const b = await req.json(); grantBody = b && typeof b === "object" && !Array.isArray(b) ? b as Record<string, unknown> : {}; } catch { return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 })); }
|
|
1790
|
+
const r = replaceDepartmentProjectGrants(netId, deptId, grantBody.project_grants, resolved.user.user_id);
|
|
1791
|
+
if (!r.ok) return withCors(req, Response.json({ ok: false, error: r.error, ...(r.detail ? { detail: r.detail } : {}) }, { status: r.status }));
|
|
1792
|
+
auditDepartment(resolved.user, "department_project_grants_set", netId, JSON.stringify({ id: deptId, project_grants: r.project_grants }));
|
|
1793
|
+
// 子树里的人看见的卡变了:断开实时流让它按新权限重连。
|
|
1794
|
+
for (const uid of membersIn(netId, subtree)) closeUserStreamsInNetwork(netId, uid);
|
|
1795
|
+
return withCors(req, Response.json({ ok: true, network_id: netId, department_id: deptId, project_grants: r.project_grants }));
|
|
1740
1796
|
}
|
|
1741
|
-
const canManage = hubAdmin || (!resolved.networkId && (role === "owner" || role === "admin"));
|
|
1742
1797
|
if (!["POST", "PATCH", "PUT", "DELETE"].includes(req.method)) return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
|
|
1743
|
-
if (!canManage) return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
|
|
1798
|
+
if (!canManage && !isHead) return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
|
|
1744
1799
|
let body: Record<string, unknown> = {};
|
|
1745
1800
|
if (req.method !== "DELETE") {
|
|
1746
1801
|
try { const b = await req.json(); body = b && typeof b === "object" && !Array.isArray(b) ? b as Record<string, unknown> : {}; } catch { return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 })); }
|
|
@@ -1749,27 +1804,66 @@ return Bun.serve({
|
|
|
1749
1804
|
const { status, ...rest } = r;
|
|
1750
1805
|
return withCors(req, Response.json(rest, { status: r.ok ? okStatus : status ?? 400 }));
|
|
1751
1806
|
};
|
|
1807
|
+
// 负责人的写:只在本部门子树里。部门 / 上级不存在的情况交给 departments.ts 回原来的 404 / 400。
|
|
1808
|
+
const exists = (id: unknown) => typeof id === "string" && departmentSubtree(netId, id).size > 0;
|
|
1809
|
+
const blank = (v: unknown) => v === undefined || v === null || v === "";
|
|
1810
|
+
const inManaged = (id: unknown) => typeof id === "string" && head.managed.has(id);
|
|
1811
|
+
const leaderOk = (v: unknown) => blank(v) || (typeof v === "string" && membersIn(netId, head.managed).has(v));
|
|
1812
|
+
const via = canManage ? "admin" : "leader";
|
|
1813
|
+
// 组织架构变了,负责人们的「本部门」跟着变:断开他们(和被调动的人)的实时流,按新权限重连。
|
|
1814
|
+
const leadersBefore = departmentLeaders(netId);
|
|
1815
|
+
const kick = (extra: string[] = []) => { for (const uid of new Set([...leadersBefore, ...departmentLeaders(netId), ...extra])) closeUserStreamsInNetwork(netId, uid); };
|
|
1752
1816
|
if (memberDeptMatch) {
|
|
1753
1817
|
if (req.method !== "PUT") return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
|
|
1754
1818
|
const targetUid = decodeURIComponent(memberDeptMatch[2]);
|
|
1819
|
+
if (!canManage) {
|
|
1820
|
+
// 调动只在子树内:人原来在本部门(含下级),目标也在本部门。调进 / 调出(含「未分配」)归管理员。
|
|
1821
|
+
const current = db.get<{ department_id: string | null }>("SELECT department_id FROM network_members WHERE network_id = ?1 AND user_id = ?2", netId, targetUid);
|
|
1822
|
+
if (current && (!inManaged(current.department_id) || (!inManaged(body.department_id) && (blank(body.department_id) || exists(body.department_id))))) return scopeDenied();
|
|
1823
|
+
}
|
|
1755
1824
|
const r = setMemberDepartment(netId, targetUid, body.department_id);
|
|
1756
|
-
if (r.ok)
|
|
1825
|
+
if (r.ok) {
|
|
1826
|
+
auditDepartment(resolved.user, "member_department_set", netId, `${targetUid} → ${r.department_id ?? "(unassigned)"}${via === "leader" ? " via=leader" : ""}`);
|
|
1827
|
+
kick([targetUid]);
|
|
1828
|
+
}
|
|
1757
1829
|
return reply(r);
|
|
1758
1830
|
}
|
|
1759
1831
|
const deptId = deptMatch![2] ? decodeURIComponent(deptMatch![2]) : null;
|
|
1832
|
+
const tag = (detail: Record<string, unknown>) => JSON.stringify(via === "leader" ? { ...detail, via } : detail);
|
|
1760
1833
|
if (!deptId && req.method === "POST") {
|
|
1834
|
+
if (!canManage) {
|
|
1835
|
+
// 只能建在本部门(含下级)下面;建顶层部门归管理员。新部门的负责人必须是本部门成员。
|
|
1836
|
+
if (blank(body.parent_id) || (exists(body.parent_id) && !inManaged(body.parent_id))) return scopeDenied();
|
|
1837
|
+
if (!leaderOk(body.leader_user_id)) return scopeDenied();
|
|
1838
|
+
}
|
|
1761
1839
|
const r = createDepartment(netId, resolved.user.user_id, body);
|
|
1762
|
-
if (r.ok)
|
|
1840
|
+
if (r.ok) {
|
|
1841
|
+
auditDepartment(resolved.user, "department_created", netId, tag({ id: r.department.id, name: r.department.name, parent_id: r.department.parent_id }));
|
|
1842
|
+
kick();
|
|
1843
|
+
}
|
|
1763
1844
|
return reply(r, 201);
|
|
1764
1845
|
}
|
|
1765
1846
|
if (deptId && req.method === "PATCH") {
|
|
1847
|
+
if (!canManage && exists(deptId)) {
|
|
1848
|
+
// 自己负责的那个部门(名字 / 上级 / 负责人)归上一级负责人或管理员;下级部门只能在子树里挪。
|
|
1849
|
+
if (!head.strict.has(deptId)) return scopeDenied();
|
|
1850
|
+
if (body.parent_id !== undefined && (blank(body.parent_id) || (exists(body.parent_id) && !inManaged(body.parent_id)))) return scopeDenied();
|
|
1851
|
+
if (body.leader_user_id !== undefined && !leaderOk(body.leader_user_id)) return scopeDenied();
|
|
1852
|
+
}
|
|
1766
1853
|
const r = updateDepartment(netId, deptId, body);
|
|
1767
|
-
if (r.ok)
|
|
1854
|
+
if (r.ok) {
|
|
1855
|
+
auditDepartment(resolved.user, "department_updated", netId, tag({ id: deptId, ...body }));
|
|
1856
|
+
kick();
|
|
1857
|
+
}
|
|
1768
1858
|
return reply(r);
|
|
1769
1859
|
}
|
|
1770
1860
|
if (deptId && req.method === "DELETE") {
|
|
1861
|
+
if (!canManage && exists(deptId) && !head.strict.has(deptId)) return scopeDenied();
|
|
1771
1862
|
const r = deleteDepartment(netId, deptId);
|
|
1772
|
-
if (r.ok)
|
|
1863
|
+
if (r.ok) {
|
|
1864
|
+
auditDepartment(resolved.user, "department_deleted", netId, via === "leader" ? `${deptId} via=leader` : deptId);
|
|
1865
|
+
kick();
|
|
1866
|
+
}
|
|
1773
1867
|
return reply(r as { ok: boolean; status?: number } & Record<string, unknown>);
|
|
1774
1868
|
}
|
|
1775
1869
|
return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
|
|
@@ -1781,7 +1875,7 @@ return Bun.serve({
|
|
|
1781
1875
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
1782
1876
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1783
1877
|
const resolved = resolveToken(token);
|
|
1784
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1878
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1785
1879
|
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1786
1880
|
const netId = membersMatch[1];
|
|
1787
1881
|
const targetUid = membersMatch[2];
|
|
@@ -1833,7 +1927,7 @@ return Bun.serve({
|
|
|
1833
1927
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
1834
1928
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1835
1929
|
const resolved = resolveToken(token);
|
|
1836
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1930
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1837
1931
|
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1838
1932
|
const netId = url.pathname.split("/")[3];
|
|
1839
1933
|
const callerRole = getUserNetworkRole(resolved.user.user_id, netId);
|
|
@@ -1850,7 +1944,7 @@ return Bun.serve({
|
|
|
1850
1944
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
1851
1945
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1852
1946
|
const resolved = resolveToken(token);
|
|
1853
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1947
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1854
1948
|
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1855
1949
|
const body = await req.json() as any;
|
|
1856
1950
|
const result = joinByInvite(body.invite_code, resolved.user.user_id);
|
|
@@ -1908,7 +2002,7 @@ return Bun.serve({
|
|
|
1908
2002
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
1909
2003
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1910
2004
|
const resolved = resolveToken(token);
|
|
1911
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
2005
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1912
2006
|
const networkId = netDetailMatch[1];
|
|
1913
2007
|
const network = db.get<any>(`SELECT ${NETWORK_REST_SELECT} FROM networks WHERE network_id = ?1`, networkId);
|
|
1914
2008
|
if (!network) return withCors(req, Response.json({ ok: false, error: "network not found" }, { status: 404 }));
|
|
@@ -1940,7 +2034,7 @@ return Bun.serve({
|
|
|
1940
2034
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
1941
2035
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1942
2036
|
const resolved = resolveToken(token);
|
|
1943
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
2037
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1944
2038
|
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1945
2039
|
// Hub 管理员删别人的网络:同一个资源、同一个动词,只是换一条判据 —— 只允许空网络
|
|
1946
2040
|
// (计数见 auth.ts adminDeleteEmptyNetwork)。owner 自己删仍走下面原来的路径,行为不变。
|
|
@@ -1964,7 +2058,7 @@ return Bun.serve({
|
|
|
1964
2058
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
1965
2059
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1966
2060
|
const resolved = resolveToken(token);
|
|
1967
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
2061
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1968
2062
|
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1969
2063
|
try {
|
|
1970
2064
|
const body = await req.json() as any;
|
|
@@ -3634,7 +3728,7 @@ return Bun.serve({
|
|
|
3634
3728
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
3635
3729
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
3636
3730
|
const resolved = resolveToken(token);
|
|
3637
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
3731
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
3638
3732
|
if (!isHubAdminCredential(resolved)) return withCors(req, Response.json({ ok: false, error: "admin only" }, { status: 403 }));
|
|
3639
3733
|
const limit = Math.min(Number(url.searchParams.get("limit")) || 200, LOG_RING_CAP);
|
|
3640
3734
|
const since = url.searchParams.get("since"); // ISO timestamp; only return logs newer
|
|
@@ -3650,7 +3744,7 @@ return Bun.serve({
|
|
|
3650
3744
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
3651
3745
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
3652
3746
|
const resolved = resolveToken(token);
|
|
3653
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
3747
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
3654
3748
|
// 审计日志是账号的记录;节点令牌不能读签发者(常是管理员)的审计日志。
|
|
3655
3749
|
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
3656
3750
|
const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 200);
|
package/src/task-access.ts
CHANGED
|
@@ -7,10 +7,13 @@
|
|
|
7
7
|
// 能改:负责人是我 / 我建的 / 项目授权 can_edit;能删:负责人是我 / 我建的;不能管项目。
|
|
8
8
|
// viewer 只看 ④,什么都不能改。
|
|
9
9
|
// owner/admin 角色与 Hub 管理员不看这一列。节点令牌(Agent)不经过这里 —— 规则原样不动。
|
|
10
|
+
// RFC-040(#455)再加两个来源,和上面的取并集、只加不减:部门项目授权(授权给我的部门或它的上级 ⇒ 同按人授权),
|
|
11
|
+
// 负责人权力(我负责的部门子树里的卡:可看 / 可改 / 可删;viewer 当负责人不算)。见 department-heads.ts。
|
|
10
12
|
//
|
|
11
13
|
// 判定是 fail-closed 的:task_access 只有字面 'all' 才放开;role 只有 owner/admin 才豁免。
|
|
12
14
|
|
|
13
15
|
import { db } from "./db.js";
|
|
16
|
+
import { deleteDepartmentGrantsForProject, departmentGrantsForMember, headCardsFor, isDepartmentCard, type DepartmentCards } from "./department-heads.js";
|
|
14
17
|
|
|
15
18
|
// ── 表结构(放在这里而不是 db.ts:db.ts 每多一行,文档里钉着的行号就漂一次) ──
|
|
16
19
|
// ALTER 的 DEFAULT 'all' 让**升级前已有的**成员行全部落在旧语义 —— 升级不收窄任何人;新成员按下面的
|
|
@@ -96,7 +99,7 @@ export function listProjectGrants(networkId: string, userId: string): ProjectGra
|
|
|
96
99
|
const likeEscape = (s: string) => s.replace(/[\\%_]/g, (c) => `\\${c}`);
|
|
97
100
|
export const userRefPattern = (userId: string) => `%${likeEscape(`"kind":"user","id":"${userId}"`)}%`;
|
|
98
101
|
|
|
99
|
-
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 };
|
|
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 };
|
|
100
103
|
const refIs = (json: string | null, userId: string) => !!json && json.includes(`"kind":"user","id":"${userId}"`);
|
|
101
104
|
const ownsCard = (row: CardRow, userId: string) => refIs(row.owner_json, userId);
|
|
102
105
|
const createdCard = (row: CardRow, userId: string) => refIs(row.created_by_json, userId) || (!row.created_by_json && row.created_by === userId);
|
|
@@ -111,6 +114,18 @@ function projectGrant(networkId: string, userId: string, projectId: string | nul
|
|
|
111
114
|
return row ? { project_id: projectId, can_edit: row.can_edit === 1 } : null;
|
|
112
115
|
}
|
|
113
116
|
|
|
117
|
+
/** 部门项目授权(RFC-040):null = 部门没授权这个项目;否则 = can_edit。 */
|
|
118
|
+
function departmentGrant(networkId: string, userId: string, projectId: string | null): boolean | null {
|
|
119
|
+
if (!projectId) return null;
|
|
120
|
+
const grants = departmentGrantsForMember(networkId, userId);
|
|
121
|
+
return grants.has(projectId) ? grants.get(projectId)! : null;
|
|
122
|
+
}
|
|
123
|
+
/** 这张卡是不是我负责的部门(含下级)的卡。 */
|
|
124
|
+
function headOf(userId: string, row: CardRow): boolean {
|
|
125
|
+
const cards = headCardsFor(row.network_id, userId);
|
|
126
|
+
return !!cards && isDepartmentCard(cards, row);
|
|
127
|
+
}
|
|
128
|
+
|
|
114
129
|
export type TaskCaller = { userId: string } | null;
|
|
115
130
|
|
|
116
131
|
/** 看得见这张卡吗。caller=null(节点令牌 / 管理员 / 旧的全局令牌)恒为 true。 */
|
|
@@ -119,8 +134,9 @@ export function canSeeTask(caller: TaskCaller, row: CardRow): boolean {
|
|
|
119
134
|
const m = memberRow(caller.userId, row.network_id);
|
|
120
135
|
if (!scopedRow(m)) return true;
|
|
121
136
|
if (projectGrant(row.network_id, caller.userId, row.project_id)) return true;
|
|
137
|
+
if (departmentGrant(row.network_id, caller.userId, row.project_id) !== null) return true;
|
|
122
138
|
if (m?.member_role === "viewer") return false;
|
|
123
|
-
return ownsCard(row, caller.userId) || createdCard(row, caller.userId) || participates(row, caller.userId);
|
|
139
|
+
return ownsCard(row, caller.userId) || createdCard(row, caller.userId) || participates(row, caller.userId) || headOf(caller.userId, row);
|
|
124
140
|
}
|
|
125
141
|
|
|
126
142
|
/** 能改这张卡吗(PATCH / 勾子任务)。只对看得见的卡调用。 */
|
|
@@ -130,7 +146,23 @@ export function canEditTask(caller: TaskCaller, row: CardRow): boolean {
|
|
|
130
146
|
if (!scopedRow(m)) return true;
|
|
131
147
|
if (m?.member_role === "viewer") return false;
|
|
132
148
|
if (ownsCard(row, caller.userId) || createdCard(row, caller.userId)) return true;
|
|
133
|
-
|
|
149
|
+
if (projectGrant(row.network_id, caller.userId, row.project_id)?.can_edit === true) return true;
|
|
150
|
+
return departmentGrant(row.network_id, caller.userId, row.project_id) === true || headOf(caller.userId, row);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* 这次能改,是不是**只**因为负责人身份(没有自己的 / 按人或部门授权的来源)。这种时候负责人只能把卡交给本部门的人
|
|
155
|
+
* (RFC-040 §1:改负责人只能改成本部门成员 / 其 Agent / 自己),返回本部门的人和节点;否则 null(不加限制)。
|
|
156
|
+
*/
|
|
157
|
+
export function headOnlyEditScope(caller: TaskCaller, row: CardRow): DepartmentCards | null {
|
|
158
|
+
if (!caller) return null;
|
|
159
|
+
const m = memberRow(caller.userId, row.network_id);
|
|
160
|
+
if (!scopedRow(m) || m?.member_role === "viewer") return null;
|
|
161
|
+
if (ownsCard(row, caller.userId) || createdCard(row, caller.userId)) return null;
|
|
162
|
+
if (projectGrant(row.network_id, caller.userId, row.project_id)?.can_edit === true) return null;
|
|
163
|
+
if (departmentGrant(row.network_id, caller.userId, row.project_id) === true) return null;
|
|
164
|
+
const cards = headCardsFor(row.network_id, caller.userId);
|
|
165
|
+
return cards && isDepartmentCard(cards, row) ? cards : null;
|
|
134
166
|
}
|
|
135
167
|
|
|
136
168
|
/**
|
|
@@ -159,7 +191,15 @@ export function canDeleteTask(caller: TaskCaller, row: CardRow): boolean {
|
|
|
159
191
|
const m = memberRow(caller.userId, row.network_id);
|
|
160
192
|
if (!scopedRow(m)) return true;
|
|
161
193
|
if (m?.member_role === "viewer") return false;
|
|
162
|
-
return ownsCard(row, caller.userId) || createdCard(row, caller.userId);
|
|
194
|
+
return ownsCard(row, caller.userId) || createdCard(row, caller.userId) || headOf(caller.userId, row);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** 这次能删只因为负责人身份(RFC-040 Q4:记 requirement_deleted_by_leader + 私信卡的负责人)。 */
|
|
198
|
+
export function deletesAsHeadOnly(caller: TaskCaller, row: CardRow): boolean {
|
|
199
|
+
if (!caller) return false;
|
|
200
|
+
const m = memberRow(caller.userId, row.network_id);
|
|
201
|
+
if (!scopedRow(m) || m?.member_role === "viewer") return false;
|
|
202
|
+
return !ownsCard(row, caller.userId) && !createdCard(row, caller.userId) && headOf(caller.userId, row);
|
|
163
203
|
}
|
|
164
204
|
|
|
165
205
|
/**
|
|
@@ -170,14 +210,16 @@ export function canDeleteTask(caller: TaskCaller, row: CardRow): boolean {
|
|
|
170
210
|
export type TaskPermissions = { edit: boolean; delete: boolean; edit_fields?: readonly string[] };
|
|
171
211
|
export function taskPermissionsResolver(caller: TaskCaller): (row: CardRow) => TaskPermissions | null {
|
|
172
212
|
if (!caller) return () => null;
|
|
173
|
-
const cache = new Map<string, { scoped: boolean; viewer: boolean; grants: Map<string, boolean
|
|
213
|
+
const cache = new Map<string, { scoped: boolean; viewer: boolean; grants: Map<string, boolean>; deptGrants: Map<string, boolean>; head: DepartmentCards | null }>();
|
|
174
214
|
const ctxFor = (networkId: string) => {
|
|
175
215
|
let c = cache.get(networkId);
|
|
176
216
|
if (!c) {
|
|
177
217
|
const m = memberRow(caller.userId, networkId);
|
|
178
218
|
const scoped = scopedRow(m);
|
|
219
|
+
const viewer = m?.member_role === "viewer";
|
|
179
220
|
const grants = new Map<string, boolean>(scoped ? listProjectGrants(networkId, caller.userId).map((g) => [g.project_id, g.can_edit] as [string, boolean]) : []);
|
|
180
|
-
|
|
221
|
+
const deptGrants = scoped ? departmentGrantsForMember(networkId, caller.userId) : new Map<string, boolean>();
|
|
222
|
+
cache.set(networkId, c = { scoped, viewer, grants, deptGrants, head: scoped && !viewer ? headCardsFor(networkId, caller.userId) : null });
|
|
181
223
|
}
|
|
182
224
|
return c;
|
|
183
225
|
};
|
|
@@ -185,8 +227,9 @@ export function taskPermissionsResolver(caller: TaskCaller): (row: CardRow) => T
|
|
|
185
227
|
const c = ctxFor(row.network_id);
|
|
186
228
|
if (!c.scoped) return null;
|
|
187
229
|
if (c.viewer) return { edit: false, delete: false };
|
|
188
|
-
const
|
|
189
|
-
const
|
|
230
|
+
const head = !!c.head && isDepartmentCard(c.head, row);
|
|
231
|
+
const mine = ownsCard(row, caller.userId) || createdCard(row, caller.userId) || head;
|
|
232
|
+
const edit = mine || (row.project_id ? c.grants.get(row.project_id) === true || c.deptGrants.get(row.project_id) === true : false);
|
|
190
233
|
if (!edit && participates(row, caller.userId)) return { edit, delete: mine, edit_fields: PARTICIPANT_EDIT_FIELDS };
|
|
191
234
|
return { edit, delete: mine };
|
|
192
235
|
};
|
|
@@ -199,6 +242,7 @@ export function taskPermissionsResolver(caller: TaskCaller): (row: CardRow) => T
|
|
|
199
242
|
export function projectUseResolver(caller: TaskCaller, networkId: string): (projectId: string) => boolean {
|
|
200
243
|
if (!caller || !isTaskScoped(caller.userId, networkId)) return () => true;
|
|
201
244
|
const editable = new Set(listProjectGrants(networkId, caller.userId).filter((g) => g.can_edit).map((g) => g.project_id));
|
|
245
|
+
for (const [projectId, canEdit] of departmentGrantsForMember(networkId, caller.userId)) if (canEdit) editable.add(projectId);
|
|
202
246
|
return (projectId) => editable.has(projectId);
|
|
203
247
|
}
|
|
204
248
|
|
|
@@ -206,7 +250,7 @@ export function projectUseResolver(caller: TaskCaller, networkId: string): (proj
|
|
|
206
250
|
export function canUseProject(caller: TaskCaller, networkId: string, projectId: string | null): boolean {
|
|
207
251
|
if (!caller || !projectId) return true;
|
|
208
252
|
if (!isTaskScoped(caller.userId, networkId)) return true;
|
|
209
|
-
return projectGrant(networkId, caller.userId, projectId)?.can_edit === true;
|
|
253
|
+
return projectGrant(networkId, caller.userId, projectId)?.can_edit === true || departmentGrant(networkId, caller.userId, projectId) === true;
|
|
210
254
|
}
|
|
211
255
|
|
|
212
256
|
/**
|
|
@@ -232,6 +276,16 @@ export function addTaskVisibilityScope(sql: string, params: unknown[], caller: T
|
|
|
232
276
|
parts.push(`(network_id IN (${ph(members)}) AND (${mine} OR ${granted}))`);
|
|
233
277
|
}
|
|
234
278
|
if (viewers.length) parts.push(`(network_id IN (${ph(viewers)}) AND ${granted})`);
|
|
279
|
+
// RFC-040:部门项目授权(viewer 也有,和按人授权一样只给「看」)+ 负责人本部门的卡(viewer 没有)。只加析取项。
|
|
280
|
+
for (const s of scoped) {
|
|
281
|
+
const extra: string[] = [];
|
|
282
|
+
const deptProjects = [...departmentGrantsForMember(s.networkId, caller.userId).keys()];
|
|
283
|
+
if (deptProjects.length) extra.push(`project_id IN (${ph(deptProjects)})`);
|
|
284
|
+
const head = s.viewer ? null : headCardsFor(s.networkId, caller.userId);
|
|
285
|
+
if (head?.users.size) extra.push(`owner_json IN (${ph([...head.users].map((id) => JSON.stringify({ kind: "user", id })))})`);
|
|
286
|
+
if (head?.nodes.size) extra.push(`agent_owner_json IN (${ph([...head.nodes].map((id) => JSON.stringify({ kind: "node", id })))})`);
|
|
287
|
+
if (extra.length) parts.push(`(network_id = ?${params.push(s.networkId)} AND (${extra.join(" OR ")}))`);
|
|
288
|
+
}
|
|
235
289
|
return `${sql} AND (${parts.join(" OR ")})`;
|
|
236
290
|
}
|
|
237
291
|
|
|
@@ -240,7 +294,10 @@ export function addProjectVisibilityScope(sql: string, params: unknown[], caller
|
|
|
240
294
|
if (!caller || !isTaskScoped(caller.userId, networkId)) return sql;
|
|
241
295
|
const uid = params.push(caller.userId);
|
|
242
296
|
const net = params.push(networkId);
|
|
243
|
-
|
|
297
|
+
const personal = `project_id IN (SELECT project_id FROM network_member_project_grants WHERE user_id = ?${uid} AND network_id = ?${net})`;
|
|
298
|
+
const deptProjects = [...departmentGrantsForMember(networkId, caller.userId).keys()];
|
|
299
|
+
if (!deptProjects.length) return `${sql} AND ${personal}`;
|
|
300
|
+
return `${sql} AND (${personal} OR project_id IN (${deptProjects.map((id) => `?${params.push(id)}`).join(", ")}))`;
|
|
244
301
|
}
|
|
245
302
|
|
|
246
303
|
export type ReplaceTaskGrantsResult =
|
|
@@ -302,6 +359,7 @@ export function deleteTaskGrantsForMember(networkId: string, userId: string): vo
|
|
|
302
359
|
}
|
|
303
360
|
export function deleteTaskGrantsForProject(projectId: string): void {
|
|
304
361
|
db.run("DELETE FROM network_member_project_grants WHERE project_id = ?1", [projectId]);
|
|
362
|
+
deleteDepartmentGrantsForProject(projectId);
|
|
305
363
|
}
|
|
306
364
|
|
|
307
365
|
// 被拒的写入审计限频:同一 (用户, 卡) 每小时最多记一条,别让重试刷爆审计表。
|