@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/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
- return Response.json({ error: "unauthorized" }, { status: 401 });
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
- const networks = resolved.networkId ? allNetworks.filter((n: any) => n.network_id === resolved.networkId) : allNetworks;
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
- if (deptMatch || memberDeptMatch) {
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
- return withCors(req, Response.json({ ok: true, network_id: netId, ...listDepartments(netId) }));
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) auditDepartment(resolved.user, "member_department_set", netId, `${targetUid} → ${r.department_id ?? "(unassigned)"}`);
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) auditDepartment(resolved.user, "department_created", netId, JSON.stringify({ id: r.department.id, name: r.department.name, parent_id: r.department.parent_id }));
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) auditDepartment(resolved.user, "department_updated", netId, JSON.stringify({ id: deptId, ...body }));
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) auditDepartment(resolved.user, "department_deleted", netId, deptId);
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);
@@ -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
- return projectGrant(row.network_id, caller.userId, row.project_id)?.can_edit === true;
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
- cache.set(networkId, c = { scoped, viewer: m?.member_role === "viewer", grants });
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 mine = ownsCard(row, caller.userId) || createdCard(row, caller.userId);
189
- const edit = mine || (row.project_id ? c.grants.get(row.project_id) === true : false);
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
- return `${sql} AND project_id IN (SELECT project_id FROM network_member_project_grants WHERE user_id = ?${uid} AND network_id = ?${net})`;
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
  // 被拒的写入审计限频:同一 (用户, 卡) 每小时最多记一条,别让重试刷爆审计表。