@sleep2agi/commhub-server 0.9.0-preview.90 → 0.9.0-preview.92
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/auth.ts +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/node-permissions-http.test.ts +258 -0
- package/src/node-permissions.ts +242 -0
- package/src/observer-avatar-http.test.ts +7 -5
- package/src/requirement-notify.ts +31 -0
- package/src/requirements-errors.ts +2 -0
- package/src/requirements.ts +121 -8
- package/src/rest-explicit-columns-http.test.ts +2 -0
- package/src/server.ts +229 -43
- package/src/task-access.ts +108 -12
- package/src/tools.ts +107 -34
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";
|
|
@@ -23,10 +24,11 @@ import { restrictedMemberSeesFile, restrictedMemberAttachmentsDenied } from "./r
|
|
|
23
24
|
import { dmParticipantSeesFile, listDmThread, listDmThreads, sendHumanDm } from "./human-dm.js";
|
|
24
25
|
import { getTaskAccessMode, isTaskScoped, listProjectGrants, replaceTaskGrants, NEW_MEMBER_TASK_ACCESS } from "./task-access.js";
|
|
25
26
|
import { canMessageAgent, isAgentRestricted, listAgentGrants, listGroupGrants, getAgentAccessMode, replaceAgentGrants, restrictedNetworkIds, usernameIsAgentAlias, listAgentGroups, createAgentGroup, updateAgentGroup, replaceAgentGroupMembers, deleteAgentGroup, usersGrantedGroup } from "./agent-access.js";
|
|
27
|
+
import { broadcastVerdict, dispatchVerdict, humanOnlyVerdict, NODE_PERMISSION_MODES, nodeDecide, nodeIdentity, nodePermissionDeniedBody, nodePermissionsFlag, streamVerdict, writeVerdict, type NodeIdentity, type NodePermissionMode, type Verdict } from "./node-permissions.js";
|
|
26
28
|
import { validateAvatarUrl } from "./avatar-validate.js";
|
|
27
29
|
import { narrowTags, parseStoredTags, validateScalarAttr } from "./node-attrs-validate.js";
|
|
28
30
|
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";
|
|
31
|
+
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
32
|
import { abortRename, cleanupCommittedRenameSessions, commitRename, prepareRename, resolveCanonicalAlias } from "./rename.js";
|
|
31
33
|
import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
|
|
32
34
|
import { clientRequestIdFromMeta, idempotentTaskId, idempotentTaskMatches, type StoredIdempotentTask } from "./task-idempotency.js";
|
|
@@ -224,7 +226,8 @@ function requireAuth(req: Request, options: RequestTokenOptions = {}): Response
|
|
|
224
226
|
return null;
|
|
225
227
|
}
|
|
226
228
|
|
|
227
|
-
|
|
229
|
+
// #488 —— 令牌存在但因主人的成员身份被拒:说清楚为什么(reason + hint),节点日志里一眼看懂为什么掉线。
|
|
230
|
+
return Response.json({ error: "unauthorized", ...(tokenRejection(token) ?? {}) }, { status: 401 });
|
|
228
231
|
}
|
|
229
232
|
|
|
230
233
|
function getClientIP(req: Request, server?: any): string {
|
|
@@ -316,6 +319,18 @@ function countNetworkUploads(networkId: string): number {
|
|
|
316
319
|
return count;
|
|
317
320
|
}
|
|
318
321
|
|
|
322
|
+
// RFC-041(#487):节点令牌的一次 REST 写 / 订阅先按 node-permissions.ts 判。用户令牌 → null(不判)。
|
|
323
|
+
function restNodeDenied(req: Request, restAuth: { networkId: string | null; tokenId: string | null } | null, route: string, verdictOf: (id: NodeIdentity) => Verdict): Response | null {
|
|
324
|
+
if (!restAuth?.networkId) return null;
|
|
325
|
+
const id = nodeIdentity(restAuth.tokenId, restAuth.networkId);
|
|
326
|
+
if (!id) return null;
|
|
327
|
+
const verdict = verdictOf(id);
|
|
328
|
+
return nodeDecide(id, route, verdict) ? withCors(req, Response.json(nodePermissionDeniedBody(verdict!.reason, route), { status: 403 })) : null;
|
|
329
|
+
}
|
|
330
|
+
/** 写别的节点 = 只有人能做;写自己 = 只看模式。 */
|
|
331
|
+
const nodeTargetVerdict = (targetNodeId: string) => (id: NodeIdentity): Verdict =>
|
|
332
|
+
id.nodeId && id.nodeId === targetNodeId ? writeVerdict(id, targetNodeId) : humanOnlyVerdict(id, targetNodeId);
|
|
333
|
+
|
|
319
334
|
function userTokenRequired(req: Request): Response {
|
|
320
335
|
return withCors(req, Response.json({ ok: false, error: "user_token_required", message: "this endpoint acts on a user account; node tokens cannot use it" }, { status: 403 }));
|
|
321
336
|
}
|
|
@@ -324,7 +339,7 @@ function requireAdminAuth(req: Request): Response | null {
|
|
|
324
339
|
const token = requestToken(req);
|
|
325
340
|
if (!token) return Response.json({ ok: false, error: "auth required" }, { status: 401 });
|
|
326
341
|
const resolved = resolveToken(token);
|
|
327
|
-
if (!resolved) return Response.json({ ok: false, error: "invalid token" }, { status: 401 });
|
|
342
|
+
if (!resolved) return Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 });
|
|
328
343
|
if (!isHubAdminCredential(resolved)) return Response.json({ ok: false, error: "admin required" }, { status: 403 });
|
|
329
344
|
return null;
|
|
330
345
|
}
|
|
@@ -1005,11 +1020,9 @@ return Bun.serve({
|
|
|
1005
1020
|
// 1. legacy AUTH_TOKEN (master) → any network
|
|
1006
1021
|
// 2/3. ntok_ / utok_ → must CURRENTLY be a member of the requested
|
|
1007
1022
|
// network. Membership is checked unconditionally — there is NO
|
|
1008
|
-
// token-bound-network shortcut
|
|
1009
|
-
//
|
|
1010
|
-
//
|
|
1011
|
-
// bound to this network whose owner was removed must lose the
|
|
1012
|
-
// stream (审查修复 per 通信龙 #461 review, finding 1).
|
|
1023
|
+
// token-bound-network shortcut (审查修复 per 通信龙 #461 review,
|
|
1024
|
+
// finding 1). Since #488 removeNetworkMember also revokes the
|
|
1025
|
+
// user's tokens and resolveToken refuses a non-member's ntok.
|
|
1013
1026
|
const netEventsMatch = url.pathname.match(/^\/events\/network\/(.+)$/);
|
|
1014
1027
|
if (netEventsMatch && req.method === "GET") {
|
|
1015
1028
|
const authErr = requireAuth(req);
|
|
@@ -1026,6 +1039,8 @@ return Bun.serve({
|
|
|
1026
1039
|
if (!observerRole) {
|
|
1027
1040
|
return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
|
|
1028
1041
|
}
|
|
1042
|
+
const streamDenied = restNodeDenied(req, authCtx, "SSE /events/network/:id", (id) => streamVerdict(id, "network"));
|
|
1043
|
+
if (streamDenied) return streamDenied;
|
|
1029
1044
|
// 多用户 Agent 权限:受限成员只收到自己是一端的路由事件(自己发的 / 发给自己的),
|
|
1030
1045
|
// 看不到网络里别人和各个 Agent 之间的流量。
|
|
1031
1046
|
if (isAgentRestricted(authCtx.userId, observedNetId)) {
|
|
@@ -1101,6 +1116,11 @@ return Bun.serve({
|
|
|
1101
1116
|
if (!session && authCtx.networkId !== scopedNetId) {
|
|
1102
1117
|
return withCors(req, Response.json({ ok: false, error: "session not in requested network" }, { status: 403 }));
|
|
1103
1118
|
}
|
|
1119
|
+
const streamDenied = restNodeDenied(req, authCtx, "SSE /events/:session", (id) => streamVerdict(id, {
|
|
1120
|
+
alias: sessionName,
|
|
1121
|
+
nodeId: db.get<{ node_id: string | null }>("SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2", sessionName, scopedNetId)?.node_id ?? null,
|
|
1122
|
+
}));
|
|
1123
|
+
if (streamDenied) return streamDenied;
|
|
1104
1124
|
return createSSEStream(sessionName, scopedNetId);
|
|
1105
1125
|
}
|
|
1106
1126
|
|
|
@@ -1277,11 +1297,13 @@ return Bun.serve({
|
|
|
1277
1297
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
1278
1298
|
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
1279
1299
|
const resolved = resolveToken(token);
|
|
1280
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1300
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1281
1301
|
// 节点令牌:user 仍是签发它的用户(旧 agent-node 靠它打日志),但只列它绑定的那个网络,
|
|
1282
1302
|
// 并用 credential 说清楚这是一个节点身份 —— user.role 不是这个调用者的权限。
|
|
1283
1303
|
const allNetworks = getUserAllNetworks(resolved.user.user_id);
|
|
1284
|
-
|
|
1304
|
+
// RFC-040:managed_department_ids = 我负责的部门 + 全部下级(空 = 不是负责人;viewer 恒空;节点令牌不是人,恒空)。
|
|
1305
|
+
const networks = (resolved.networkId ? allNetworks.filter((n: any) => n.network_id === resolved.networkId) : allNetworks)
|
|
1306
|
+
.map((n: any) => ({ ...n, managed_department_ids: resolved.networkId ? [] : managedDepartmentIds(n.network_id, resolved.user.user_id) }));
|
|
1285
1307
|
const credential = resolved.networkId
|
|
1286
1308
|
? { kind: "node", network_id: resolved.networkId, node_alias: resolved.tokenName?.startsWith("node:") ? resolved.tokenName.slice(5) : null, acts_as_owner: false }
|
|
1287
1309
|
: { kind: "user" };
|
|
@@ -1292,7 +1314,7 @@ return Bun.serve({
|
|
|
1292
1314
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
1293
1315
|
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
1294
1316
|
const resolved = resolveToken(token);
|
|
1295
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1317
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1296
1318
|
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1297
1319
|
try {
|
|
1298
1320
|
const body = await req.json() as any;
|
|
@@ -1317,7 +1339,7 @@ return Bun.serve({
|
|
|
1317
1339
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
1318
1340
|
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
1319
1341
|
const resolved = resolveToken(token);
|
|
1320
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1342
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1321
1343
|
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1322
1344
|
try {
|
|
1323
1345
|
const body = await req.json() as any;
|
|
@@ -1339,7 +1361,7 @@ return Bun.serve({
|
|
|
1339
1361
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
1340
1362
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1341
1363
|
const resolved = resolveToken(token);
|
|
1342
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1364
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1343
1365
|
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1344
1366
|
try {
|
|
1345
1367
|
const body = await req.json() as any;
|
|
@@ -1359,7 +1381,7 @@ return Bun.serve({
|
|
|
1359
1381
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
1360
1382
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1361
1383
|
const resolved = resolveToken(token);
|
|
1362
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1384
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1363
1385
|
try {
|
|
1364
1386
|
const body = await req.json() as any;
|
|
1365
1387
|
if (!body.network_id || !body.old_alias || !body.new_alias) {
|
|
@@ -1378,7 +1400,7 @@ return Bun.serve({
|
|
|
1378
1400
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
1379
1401
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1380
1402
|
const resolved = resolveToken(token);
|
|
1381
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1403
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1382
1404
|
try {
|
|
1383
1405
|
const body = await req.json() as any;
|
|
1384
1406
|
if (!body.txn_id) return withCors(req, Response.json({ ok: false, error: "txn_id required" }, { status: 400 }));
|
|
@@ -1395,7 +1417,7 @@ return Bun.serve({
|
|
|
1395
1417
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
1396
1418
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1397
1419
|
const resolved = resolveToken(token);
|
|
1398
|
-
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 }));
|
|
1399
1421
|
try {
|
|
1400
1422
|
const body = await req.json() as any;
|
|
1401
1423
|
if (!body.txn_id) return withCors(req, Response.json({ ok: false, error: "txn_id required" }, { status: 400 }));
|
|
@@ -1413,7 +1435,7 @@ return Bun.serve({
|
|
|
1413
1435
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
1414
1436
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1415
1437
|
const resolved = resolveToken(token);
|
|
1416
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1438
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1417
1439
|
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1418
1440
|
const tokens = listTokens(resolved.user.user_id);
|
|
1419
1441
|
return withCors(req, Response.json({ ok: true, tokens }));
|
|
@@ -1423,7 +1445,7 @@ return Bun.serve({
|
|
|
1423
1445
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
1424
1446
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1425
1447
|
const resolved = resolveToken(token);
|
|
1426
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1448
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1427
1449
|
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1428
1450
|
try {
|
|
1429
1451
|
const body = await req.json() as any;
|
|
@@ -1440,7 +1462,7 @@ return Bun.serve({
|
|
|
1440
1462
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
1441
1463
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1442
1464
|
const resolved = resolveToken(token);
|
|
1443
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1465
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1444
1466
|
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1445
1467
|
const result = revokeToken(resolved.user.user_id, tokenDeleteMatch[1]);
|
|
1446
1468
|
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "token_revoked", "token", tokenDeleteMatch[1]);
|
|
@@ -1453,7 +1475,7 @@ return Bun.serve({
|
|
|
1453
1475
|
const token = requestToken(req, { allowQueryToken: false });
|
|
1454
1476
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1455
1477
|
const resolved = resolveToken(token);
|
|
1456
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1478
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1457
1479
|
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1458
1480
|
const sessions = listSessions(resolved.user.user_id, resolved.tokenId);
|
|
1459
1481
|
return withCors(req, Response.json({ ok: true, sessions, current_token_id: resolved.tokenId, idle_timeout_days: sessionIdleDays() }));
|
|
@@ -1463,7 +1485,7 @@ return Bun.serve({
|
|
|
1463
1485
|
const token = requestToken(req, { allowQueryToken: false });
|
|
1464
1486
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1465
1487
|
const resolved = resolveToken(token);
|
|
1466
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1488
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1467
1489
|
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1468
1490
|
// 当前令牌必须是一次登录会话,否则「保留当前这个」无从说起(API 令牌调用会把所有会话都撤掉)。
|
|
1469
1491
|
const current = resolved.tokenId ? listSessions(resolved.user.user_id, resolved.tokenId).find((x) => x.is_current) : undefined;
|
|
@@ -1478,7 +1500,7 @@ return Bun.serve({
|
|
|
1478
1500
|
const token = requestToken(req, { allowQueryToken: false });
|
|
1479
1501
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1480
1502
|
const resolved = resolveToken(token);
|
|
1481
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1503
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1482
1504
|
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1483
1505
|
const targetId = decodeURIComponent(sessionDeleteMatch[1]);
|
|
1484
1506
|
const result = revokeSession(resolved.user.user_id, targetId);
|
|
@@ -1492,7 +1514,7 @@ return Bun.serve({
|
|
|
1492
1514
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
1493
1515
|
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
1494
1516
|
const resolved = resolveToken(token);
|
|
1495
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1517
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1496
1518
|
// V3.13: ntok_ can only see its bound network; utok_ sees all member networks
|
|
1497
1519
|
if (resolved.networkId) {
|
|
1498
1520
|
// ntok_ — only return the bound network
|
|
@@ -1522,7 +1544,7 @@ return Bun.serve({
|
|
|
1522
1544
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
1523
1545
|
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
1524
1546
|
const resolved = resolveToken(token);
|
|
1525
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1547
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1526
1548
|
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1527
1549
|
try {
|
|
1528
1550
|
const body = await req.json() as any;
|
|
@@ -1722,25 +1744,76 @@ return Bun.serve({
|
|
|
1722
1744
|
}
|
|
1723
1745
|
|
|
1724
1746
|
// ── 组织架构(board #419):部门 + 成员所在部门。读:网络任何成员 / 本网络的节点令牌 / Hub 管理员;
|
|
1725
|
-
// 写:网络 owner / admin、Hub 管理员(节点令牌不能写)。规则在 departments.ts。
|
|
1747
|
+
// 写:网络 owner / admin、Hub 管理员(节点令牌不能写)。规则在 departments.ts。
|
|
1748
|
+
// RFC-040(#455)部门负责人:每次请求按 leader_user_id 现算「我负责的部门 + 全部下级」(department-heads.ts),
|
|
1749
|
+
// 在这棵子树里也能写;越出子树 → 403 department_scope_denied(固定一个错误)。不是负责人的成员与以前逐字节相同。 ──
|
|
1726
1750
|
const deptMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/departments(?:\/([^/]+))?$/);
|
|
1727
1751
|
const memberDeptMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/members\/([^/]+)\/department$/);
|
|
1728
|
-
|
|
1752
|
+
const deptSubMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/departments\/([^/]+)\/(project-grants|nodes)$/);
|
|
1753
|
+
if (deptMatch || memberDeptMatch || deptSubMatch) {
|
|
1729
1754
|
const token = requestToken(req, { allowQueryToken: false });
|
|
1730
1755
|
const resolved = token ? resolveToken(token) : null;
|
|
1731
1756
|
if (!resolved) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1732
|
-
const netId = decodeURIComponent((deptMatch ?? memberDeptMatch)![1]);
|
|
1757
|
+
const netId = decodeURIComponent((deptMatch ?? memberDeptMatch ?? deptSubMatch)![1]);
|
|
1733
1758
|
if (resolved.networkId && resolved.networkId !== netId) return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
|
|
1734
1759
|
const hubAdmin = isHubAdminCredential(resolved);
|
|
1735
1760
|
const role = resolved.networkId ? null : getUserNetworkRole(resolved.user.user_id, netId);
|
|
1736
1761
|
if (!hubAdmin && !role && !resolved.networkId) return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
|
|
1737
1762
|
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 }));
|
|
1763
|
+
const canManage = hubAdmin || (!resolved.networkId && (role === "owner" || role === "admin"));
|
|
1764
|
+
// 负责人范围:只算人(节点令牌不是人);owner / admin 本来全管,不必算。viewer 在 headScope 里恒为空。
|
|
1765
|
+
const head = canManage || resolved.networkId ? { managed: new Set<string>(), strict: new Set<string>() } : headScope(netId, resolved.user.user_id);
|
|
1766
|
+
const isHead = head.managed.size > 0;
|
|
1767
|
+
const scopeDenied = () => withCors(req, Response.json({ ok: false, error: DEPARTMENT_SCOPE_DENIED }, { status: 403 }));
|
|
1738
1768
|
if (deptMatch && !deptMatch[2] && req.method === "GET") {
|
|
1739
|
-
|
|
1769
|
+
const listed = listDepartments(netId);
|
|
1770
|
+
// viewer_can:manage = 能改名 / 移动 / 删除 / 换负责人;create_child = 能在它下面建子部门。
|
|
1771
|
+
// project_grants(部门项目授权)只给 owner / admin:项目 id 不对只看相关任务的成员公开。
|
|
1772
|
+
const departments = listed.departments.map((d) => ({ ...d, viewer_can: { manage: canManage || head.strict.has(d.id), create_child: canManage || head.managed.has(d.id) } }));
|
|
1773
|
+
return withCors(req, Response.json({ ok: true, network_id: netId, departments, members: listed.members, ...(canManage ? { project_grants: listDepartmentProjectGrants(netId) } : {}) }));
|
|
1774
|
+
}
|
|
1775
|
+
if (deptSubMatch) {
|
|
1776
|
+
const deptId = decodeURIComponent(deptSubMatch[2]);
|
|
1777
|
+
const subtree = departmentSubtree(netId, deptId);
|
|
1778
|
+
if (!subtree.size) return withCors(req, Response.json({ ok: false, error: "department_not_found" }, { status: 404 }));
|
|
1779
|
+
if (deptSubMatch[3] === "nodes") {
|
|
1780
|
+
// 本部门(含下级)成员拥有的节点:只读的状态与健康(RFC-040 Q3 —— 能管人 ≠ 能用 / 能管他的 Agent)。
|
|
1781
|
+
if (req.method !== "GET") return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
|
|
1782
|
+
if (!canManage && !head.managed.has(deptId)) return scopeDenied();
|
|
1783
|
+
const owners = membersIn(netId, subtree);
|
|
1784
|
+
const rows = db.all<{ node_id: string; alias: string | null; display_name: string | null; owner_user_id: string | null }>(
|
|
1785
|
+
"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,
|
|
1786
|
+
).filter((n) => owners.has(n.owner_user_id!));
|
|
1787
|
+
const nodes = rows.map((n) => {
|
|
1788
|
+
const session = db.get<{ status: string | null; last_seen_at: string | null }>(
|
|
1789
|
+
"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,
|
|
1790
|
+
);
|
|
1791
|
+
const h = n.alias ? readNodeHealth(netId, n.alias) : null;
|
|
1792
|
+
return {
|
|
1793
|
+
node_id: n.node_id, alias: n.alias, display_name: n.display_name, owner_user_id: n.owner_user_id,
|
|
1794
|
+
status: session?.status ?? "offline", last_seen_at: session?.last_seen_at ?? null,
|
|
1795
|
+
health: h?.health ?? null, degraded: degradedLayers(h?.health).map((l) => ({ layer: l.layer, label: l.label, reason: l.reason })),
|
|
1796
|
+
};
|
|
1797
|
+
});
|
|
1798
|
+
return withCors(req, Response.json({ ok: true, network_id: netId, department_id: deptId, nodes }));
|
|
1799
|
+
}
|
|
1800
|
+
// 部门项目授权:只有 owner / admin(和按人授权一样)。
|
|
1801
|
+
if (!canManage) return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
|
|
1802
|
+
if (req.method === "GET") {
|
|
1803
|
+
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 })) }));
|
|
1804
|
+
}
|
|
1805
|
+
if (req.method !== "PUT") return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
|
|
1806
|
+
let grantBody: Record<string, unknown> = {};
|
|
1807
|
+
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 })); }
|
|
1808
|
+
const r = replaceDepartmentProjectGrants(netId, deptId, grantBody.project_grants, resolved.user.user_id);
|
|
1809
|
+
if (!r.ok) return withCors(req, Response.json({ ok: false, error: r.error, ...(r.detail ? { detail: r.detail } : {}) }, { status: r.status }));
|
|
1810
|
+
auditDepartment(resolved.user, "department_project_grants_set", netId, JSON.stringify({ id: deptId, project_grants: r.project_grants }));
|
|
1811
|
+
// 子树里的人看见的卡变了:断开实时流让它按新权限重连。
|
|
1812
|
+
for (const uid of membersIn(netId, subtree)) closeUserStreamsInNetwork(netId, uid);
|
|
1813
|
+
return withCors(req, Response.json({ ok: true, network_id: netId, department_id: deptId, project_grants: r.project_grants }));
|
|
1740
1814
|
}
|
|
1741
|
-
const canManage = hubAdmin || (!resolved.networkId && (role === "owner" || role === "admin"));
|
|
1742
1815
|
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 }));
|
|
1816
|
+
if (!canManage && !isHead) return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
|
|
1744
1817
|
let body: Record<string, unknown> = {};
|
|
1745
1818
|
if (req.method !== "DELETE") {
|
|
1746
1819
|
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 +1822,66 @@ return Bun.serve({
|
|
|
1749
1822
|
const { status, ...rest } = r;
|
|
1750
1823
|
return withCors(req, Response.json(rest, { status: r.ok ? okStatus : status ?? 400 }));
|
|
1751
1824
|
};
|
|
1825
|
+
// 负责人的写:只在本部门子树里。部门 / 上级不存在的情况交给 departments.ts 回原来的 404 / 400。
|
|
1826
|
+
const exists = (id: unknown) => typeof id === "string" && departmentSubtree(netId, id).size > 0;
|
|
1827
|
+
const blank = (v: unknown) => v === undefined || v === null || v === "";
|
|
1828
|
+
const inManaged = (id: unknown) => typeof id === "string" && head.managed.has(id);
|
|
1829
|
+
const leaderOk = (v: unknown) => blank(v) || (typeof v === "string" && membersIn(netId, head.managed).has(v));
|
|
1830
|
+
const via = canManage ? "admin" : "leader";
|
|
1831
|
+
// 组织架构变了,负责人们的「本部门」跟着变:断开他们(和被调动的人)的实时流,按新权限重连。
|
|
1832
|
+
const leadersBefore = departmentLeaders(netId);
|
|
1833
|
+
const kick = (extra: string[] = []) => { for (const uid of new Set([...leadersBefore, ...departmentLeaders(netId), ...extra])) closeUserStreamsInNetwork(netId, uid); };
|
|
1752
1834
|
if (memberDeptMatch) {
|
|
1753
1835
|
if (req.method !== "PUT") return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
|
|
1754
1836
|
const targetUid = decodeURIComponent(memberDeptMatch[2]);
|
|
1837
|
+
if (!canManage) {
|
|
1838
|
+
// 调动只在子树内:人原来在本部门(含下级),目标也在本部门。调进 / 调出(含「未分配」)归管理员。
|
|
1839
|
+
const current = db.get<{ department_id: string | null }>("SELECT department_id FROM network_members WHERE network_id = ?1 AND user_id = ?2", netId, targetUid);
|
|
1840
|
+
if (current && (!inManaged(current.department_id) || (!inManaged(body.department_id) && (blank(body.department_id) || exists(body.department_id))))) return scopeDenied();
|
|
1841
|
+
}
|
|
1755
1842
|
const r = setMemberDepartment(netId, targetUid, body.department_id);
|
|
1756
|
-
if (r.ok)
|
|
1843
|
+
if (r.ok) {
|
|
1844
|
+
auditDepartment(resolved.user, "member_department_set", netId, `${targetUid} → ${r.department_id ?? "(unassigned)"}${via === "leader" ? " via=leader" : ""}`);
|
|
1845
|
+
kick([targetUid]);
|
|
1846
|
+
}
|
|
1757
1847
|
return reply(r);
|
|
1758
1848
|
}
|
|
1759
1849
|
const deptId = deptMatch![2] ? decodeURIComponent(deptMatch![2]) : null;
|
|
1850
|
+
const tag = (detail: Record<string, unknown>) => JSON.stringify(via === "leader" ? { ...detail, via } : detail);
|
|
1760
1851
|
if (!deptId && req.method === "POST") {
|
|
1852
|
+
if (!canManage) {
|
|
1853
|
+
// 只能建在本部门(含下级)下面;建顶层部门归管理员。新部门的负责人必须是本部门成员。
|
|
1854
|
+
if (blank(body.parent_id) || (exists(body.parent_id) && !inManaged(body.parent_id))) return scopeDenied();
|
|
1855
|
+
if (!leaderOk(body.leader_user_id)) return scopeDenied();
|
|
1856
|
+
}
|
|
1761
1857
|
const r = createDepartment(netId, resolved.user.user_id, body);
|
|
1762
|
-
if (r.ok)
|
|
1858
|
+
if (r.ok) {
|
|
1859
|
+
auditDepartment(resolved.user, "department_created", netId, tag({ id: r.department.id, name: r.department.name, parent_id: r.department.parent_id }));
|
|
1860
|
+
kick();
|
|
1861
|
+
}
|
|
1763
1862
|
return reply(r, 201);
|
|
1764
1863
|
}
|
|
1765
1864
|
if (deptId && req.method === "PATCH") {
|
|
1865
|
+
if (!canManage && exists(deptId)) {
|
|
1866
|
+
// 自己负责的那个部门(名字 / 上级 / 负责人)归上一级负责人或管理员;下级部门只能在子树里挪。
|
|
1867
|
+
if (!head.strict.has(deptId)) return scopeDenied();
|
|
1868
|
+
if (body.parent_id !== undefined && (blank(body.parent_id) || (exists(body.parent_id) && !inManaged(body.parent_id)))) return scopeDenied();
|
|
1869
|
+
if (body.leader_user_id !== undefined && !leaderOk(body.leader_user_id)) return scopeDenied();
|
|
1870
|
+
}
|
|
1766
1871
|
const r = updateDepartment(netId, deptId, body);
|
|
1767
|
-
if (r.ok)
|
|
1872
|
+
if (r.ok) {
|
|
1873
|
+
auditDepartment(resolved.user, "department_updated", netId, tag({ id: deptId, ...body }));
|
|
1874
|
+
kick();
|
|
1875
|
+
}
|
|
1768
1876
|
return reply(r);
|
|
1769
1877
|
}
|
|
1770
1878
|
if (deptId && req.method === "DELETE") {
|
|
1879
|
+
if (!canManage && exists(deptId) && !head.strict.has(deptId)) return scopeDenied();
|
|
1771
1880
|
const r = deleteDepartment(netId, deptId);
|
|
1772
|
-
if (r.ok)
|
|
1881
|
+
if (r.ok) {
|
|
1882
|
+
auditDepartment(resolved.user, "department_deleted", netId, via === "leader" ? `${deptId} via=leader` : deptId);
|
|
1883
|
+
kick();
|
|
1884
|
+
}
|
|
1773
1885
|
return reply(r as { ok: boolean; status?: number } & Record<string, unknown>);
|
|
1774
1886
|
}
|
|
1775
1887
|
return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
|
|
@@ -1781,7 +1893,7 @@ return Bun.serve({
|
|
|
1781
1893
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
1782
1894
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1783
1895
|
const resolved = resolveToken(token);
|
|
1784
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1896
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1785
1897
|
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1786
1898
|
const netId = membersMatch[1];
|
|
1787
1899
|
const targetUid = membersMatch[2];
|
|
@@ -1833,7 +1945,7 @@ return Bun.serve({
|
|
|
1833
1945
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
1834
1946
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1835
1947
|
const resolved = resolveToken(token);
|
|
1836
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1948
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1837
1949
|
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1838
1950
|
const netId = url.pathname.split("/")[3];
|
|
1839
1951
|
const callerRole = getUserNetworkRole(resolved.user.user_id, netId);
|
|
@@ -1850,7 +1962,7 @@ return Bun.serve({
|
|
|
1850
1962
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
1851
1963
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1852
1964
|
const resolved = resolveToken(token);
|
|
1853
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1965
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1854
1966
|
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1855
1967
|
const body = await req.json() as any;
|
|
1856
1968
|
const result = joinByInvite(body.invite_code, resolved.user.user_id);
|
|
@@ -1908,7 +2020,7 @@ return Bun.serve({
|
|
|
1908
2020
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
1909
2021
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1910
2022
|
const resolved = resolveToken(token);
|
|
1911
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
2023
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1912
2024
|
const networkId = netDetailMatch[1];
|
|
1913
2025
|
const network = db.get<any>(`SELECT ${NETWORK_REST_SELECT} FROM networks WHERE network_id = ?1`, networkId);
|
|
1914
2026
|
if (!network) return withCors(req, Response.json({ ok: false, error: "network not found" }, { status: 404 }));
|
|
@@ -1940,7 +2052,7 @@ return Bun.serve({
|
|
|
1940
2052
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
1941
2053
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1942
2054
|
const resolved = resolveToken(token);
|
|
1943
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
2055
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1944
2056
|
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1945
2057
|
// Hub 管理员删别人的网络:同一个资源、同一个动词,只是换一条判据 —— 只允许空网络
|
|
1946
2058
|
// (计数见 auth.ts adminDeleteEmptyNetwork)。owner 自己删仍走下面原来的路径,行为不变。
|
|
@@ -1964,7 +2076,7 @@ return Bun.serve({
|
|
|
1964
2076
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
1965
2077
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1966
2078
|
const resolved = resolveToken(token);
|
|
1967
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
2079
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
1968
2080
|
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1969
2081
|
try {
|
|
1970
2082
|
const body = await req.json() as any;
|
|
@@ -2177,6 +2289,67 @@ return Bun.serve({
|
|
|
2177
2289
|
// GET /api/dm?network_id=&with=<user_id>[&limit&before] → 双向会话记录(新的在前)
|
|
2178
2290
|
// GET /api/dm/threads?network_id= → 每个对方一行 + 未读数
|
|
2179
2291
|
// 已读沿用 POST /api/messages/ack(私信就是收件人 user_inbox 里的行)。
|
|
2292
|
+
// ── RFC-041(#487):节点自己的权限 —— 改模式、看「本来会拦」的报表。只给人(用户令牌)。 ──
|
|
2293
|
+
const permModeMatch = url.pathname.match(/^\/api\/nodes\/([^/]+)\/permission-mode$/);
|
|
2294
|
+
if (permModeMatch && req.method === "PUT") {
|
|
2295
|
+
if (!restAuth) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
2296
|
+
if (restAuth.networkId) return userTokenRequired(req);
|
|
2297
|
+
let body: any;
|
|
2298
|
+
try { body = await req.json(); } catch { return withCors(req, Response.json({ ok: false, error: "invalid_json" }, { status: 400 })); }
|
|
2299
|
+
const mode = body?.mode;
|
|
2300
|
+
if (!NODE_PERMISSION_MODES.includes(mode)) {
|
|
2301
|
+
return withCors(req, Response.json({ ok: false, error: "invalid_permission_mode", message: `mode must be one of ${NODE_PERMISSION_MODES.join(", ")}` }, { status: 400 }));
|
|
2302
|
+
}
|
|
2303
|
+
const nodeRef = decodeURIComponent(permModeMatch[1]);
|
|
2304
|
+
const node = db.get<{ node_id: string; network_id: string | null; owner_user_id: string | null; permission_mode: string | null }>(
|
|
2305
|
+
"SELECT node_id, network_id, owner_user_id, permission_mode FROM nodes WHERE node_id = ?1", nodeRef);
|
|
2306
|
+
const role = node?.network_id ? getUserNetworkRole(restAuth.userId, node.network_id) : null;
|
|
2307
|
+
// 看不见这个节点(不在它的网络里)与不存在同一个 404。
|
|
2308
|
+
if (!node || !node.network_id || (!role && !isAdmin)) return withCors(req, Response.json({ ok: false, error: "node_not_found" }, { status: 404 }));
|
|
2309
|
+
// 节点主人、网络 owner / admin、Hub 管理员能改;部门负责人和别的成员不能(RFC-041 §2.3 / §2.4)。
|
|
2310
|
+
if (!isAdmin && node.owner_user_id !== restAuth.userId && role !== "owner" && role !== "admin") {
|
|
2311
|
+
return withCors(req, Response.json({ ok: false, error: "permission_denied", message: "only the node's owner or a network owner/admin can change its permission mode" }, { status: 403 }));
|
|
2312
|
+
}
|
|
2313
|
+
const previous = (node.permission_mode || "normal") as NodePermissionMode;
|
|
2314
|
+
db.run("UPDATE nodes SET permission_mode = ?1 WHERE node_id = ?2", [mode, node.node_id]);
|
|
2315
|
+
logAudit(restAuth.userId, restAuth.username, "node_permission_mode_changed", "node", node.node_id, JSON.stringify({ from: previous, to: mode }), getClientIP(req), node.network_id);
|
|
2316
|
+
return withCors(req, Response.json({ ok: true, node_id: node.node_id, permission_mode: mode, previous }));
|
|
2317
|
+
}
|
|
2318
|
+
const permReportMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/node-permission-report$/);
|
|
2319
|
+
if (permReportMatch && req.method === "GET") {
|
|
2320
|
+
if (!restAuth) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
2321
|
+
if (restAuth.networkId) return userTokenRequired(req);
|
|
2322
|
+
const networkId = decodeURIComponent(permReportMatch[1]);
|
|
2323
|
+
const role = getUserNetworkRole(restAuth.userId, networkId);
|
|
2324
|
+
if (!isAdmin && role !== "owner" && role !== "admin") {
|
|
2325
|
+
return withCors(req, Response.json({ ok: false, error: "permission_denied", message: "network owner/admin only" }, { status: 403 }));
|
|
2326
|
+
}
|
|
2327
|
+
const sinceRaw = url.searchParams.get("since");
|
|
2328
|
+
const sinceMs = sinceRaw ? Date.parse(sinceRaw) : Date.now() - 7 * 86_400_000;
|
|
2329
|
+
if (!Number.isFinite(sinceMs)) return withCors(req, Response.json({ ok: false, error: "invalid_since" }, { status: 400 }));
|
|
2330
|
+
const sinceHour = new Date(sinceMs).toISOString().slice(0, 13);
|
|
2331
|
+
const rows = db.all<{ node_id: string; route: string; reason: string; hits: number | string; sample: string | null; last_hour: string }>(
|
|
2332
|
+
`SELECT node_id, route, reason, SUM(hits) AS hits, MAX(sample) AS sample, MAX(hour) AS last_hour
|
|
2333
|
+
FROM node_permission_log WHERE network_id = ?1 AND hour >= ?2
|
|
2334
|
+
GROUP BY node_id, route, reason ORDER BY node_id, route, reason`,
|
|
2335
|
+
networkId, sinceHour);
|
|
2336
|
+
const byNode = new Map<string, { node_id: string; alias: string | null; permission_mode: string; total: number; by_reason: Record<string, number>; routes: Array<{ route: string; reason: string; hits: number; sample: string | null; last_hour: string }> }>();
|
|
2337
|
+
for (const r of rows) {
|
|
2338
|
+
let entry = byNode.get(r.node_id);
|
|
2339
|
+
if (!entry) {
|
|
2340
|
+
const n = db.get<{ alias: string | null; permission_mode: string | null }>("SELECT alias, permission_mode FROM nodes WHERE node_id = ?1", r.node_id);
|
|
2341
|
+
entry = { node_id: r.node_id, alias: n?.alias ?? null, permission_mode: n?.permission_mode || "normal", total: 0, by_reason: {}, routes: [] };
|
|
2342
|
+
byNode.set(r.node_id, entry);
|
|
2343
|
+
}
|
|
2344
|
+
const hits = Number(r.hits);
|
|
2345
|
+
entry.total += hits;
|
|
2346
|
+
entry.by_reason[r.reason] = (entry.by_reason[r.reason] ?? 0) + hits;
|
|
2347
|
+
entry.routes.push({ route: r.route, reason: r.reason, hits, sample: r.sample, last_hour: r.last_hour });
|
|
2348
|
+
}
|
|
2349
|
+
const nodes = [...byNode.values()].sort((a, b) => b.total - a.total || a.node_id.localeCompare(b.node_id));
|
|
2350
|
+
return withCors(req, Response.json({ ok: true, network_id: networkId, since: new Date(sinceMs).toISOString(), mode: nodePermissionsFlag(), total: nodes.reduce((n, e) => n + e.total, 0), nodes }));
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2180
2353
|
if (url.pathname === "/api/dm" || url.pathname === "/api/dm/threads") {
|
|
2181
2354
|
if (!restAuth || !requestToken(req).startsWith("utok_")) {
|
|
2182
2355
|
return withCors(req, Response.json({ ok: false, error: "user_token_required" }, { status: 403 }));
|
|
@@ -3096,6 +3269,11 @@ return Bun.serve({
|
|
|
3096
3269
|
}
|
|
3097
3270
|
const canonical = resolveCanonicalAlias(taskNetId, body.alias);
|
|
3098
3271
|
const targetAlias = canonical.alias;
|
|
3272
|
+
const nodeDenied = restNodeDenied(req, restAuth, "POST /api/task", (id) => dispatchVerdict(id, {
|
|
3273
|
+
alias: targetAlias,
|
|
3274
|
+
nodeId: db.get<{ node_id: string | null }>("SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2", targetAlias, taskNetId)?.node_id ?? null,
|
|
3275
|
+
}));
|
|
3276
|
+
if (nodeDenied) return nodeDenied;
|
|
3099
3277
|
const target = resolveRestDeliveryTarget(targetAlias, taskNetId);
|
|
3100
3278
|
if (target.state === "not_found") {
|
|
3101
3279
|
return withCors(req, Response.json({
|
|
@@ -3331,6 +3509,8 @@ return Bun.serve({
|
|
|
3331
3509
|
if (!canRestWriteNetwork(restAuth, restScope.networkId, isAdmin)) {
|
|
3332
3510
|
return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
|
|
3333
3511
|
}
|
|
3512
|
+
const broadcastDenied = restNodeDenied(req, restAuth, "POST /api/broadcast", broadcastVerdict);
|
|
3513
|
+
if (broadcastDenied) return broadcastDenied;
|
|
3334
3514
|
let sql = "SELECT alias, node_id, network_id FROM sessions WHERE alias IS NOT NULL";
|
|
3335
3515
|
const params: any[] = [];
|
|
3336
3516
|
sql = addNetworkScope(sql, params, restScope);
|
|
@@ -3634,7 +3814,7 @@ return Bun.serve({
|
|
|
3634
3814
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
3635
3815
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
3636
3816
|
const resolved = resolveToken(token);
|
|
3637
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
3817
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
3638
3818
|
if (!isHubAdminCredential(resolved)) return withCors(req, Response.json({ ok: false, error: "admin only" }, { status: 403 }));
|
|
3639
3819
|
const limit = Math.min(Number(url.searchParams.get("limit")) || 200, LOG_RING_CAP);
|
|
3640
3820
|
const since = url.searchParams.get("since"); // ISO timestamp; only return logs newer
|
|
@@ -3650,7 +3830,7 @@ return Bun.serve({
|
|
|
3650
3830
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "") || url.searchParams.get("token");
|
|
3651
3831
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
3652
3832
|
const resolved = resolveToken(token);
|
|
3653
|
-
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
3833
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token", ...(tokenRejection(token) ?? {}) }, { status: 401 }));
|
|
3654
3834
|
// 审计日志是账号的记录;节点令牌不能读签发者(常是管理员)的审计日志。
|
|
3655
3835
|
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
3656
3836
|
const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 200);
|
|
@@ -3703,6 +3883,8 @@ return Bun.serve({
|
|
|
3703
3883
|
if (!canRestWriteNetwork(restAuth, nodeNetId, isAdmin)) {
|
|
3704
3884
|
return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
|
|
3705
3885
|
}
|
|
3886
|
+
const nodeWriteDenied = restNodeDenied(req, restAuth, "DELETE /api/nodes/:id", nodeTargetVerdict(node.node_id));
|
|
3887
|
+
if (nodeWriteDenied) return nodeWriteDenied;
|
|
3706
3888
|
|
|
3707
3889
|
db.transaction(() => {
|
|
3708
3890
|
db.run("DELETE FROM nodes WHERE node_id = ?1", [node.node_id]);
|
|
@@ -3813,6 +3995,8 @@ return Bun.serve({
|
|
|
3813
3995
|
if (!canRestWriteNetwork(restAuth, nodeNetId, isAdmin)) {
|
|
3814
3996
|
return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
|
|
3815
3997
|
}
|
|
3998
|
+
const nodeWriteDenied = restNodeDenied(req, restAuth, "PUT /api/nodes/:id/attrs", nodeTargetVerdict(node.node_id));
|
|
3999
|
+
if (nodeWriteDenied) return nodeWriteDenied;
|
|
3816
4000
|
|
|
3817
4001
|
// Optimistic lock BEFORE any narrowing work, so a losing writer never
|
|
3818
4002
|
// has side effects.
|
|
@@ -3912,6 +4096,8 @@ return Bun.serve({
|
|
|
3912
4096
|
if (!canRestWriteNetwork(restAuth, nodeNetId, isAdmin)) {
|
|
3913
4097
|
return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
|
|
3914
4098
|
}
|
|
4099
|
+
const nodeWriteDenied = restNodeDenied(req, restAuth, "PUT /api/nodes/:id/avatar", nodeTargetVerdict(node.node_id));
|
|
4100
|
+
if (nodeWriteDenied) return nodeWriteDenied;
|
|
3915
4101
|
|
|
3916
4102
|
db.run(
|
|
3917
4103
|
"UPDATE nodes SET avatar_url = ?1, updated_at = datetime('now') WHERE node_id = ?2",
|
|
@@ -4177,7 +4363,7 @@ return Bun.serve({
|
|
|
4177
4363
|
config_path, channels, server, hostname,
|
|
4178
4364
|
network_id, created_at, updated_at,
|
|
4179
4365
|
config_snapshot, lifecycle_state, avatar_url,
|
|
4180
|
-
display_name, team, tags, attrs_revision
|
|
4366
|
+
display_name, team, tags, attrs_revision, permission_mode
|
|
4181
4367
|
FROM nodes WHERE 1=1`;
|
|
4182
4368
|
const params: any[] = [];
|
|
4183
4369
|
sql = addAgentNetworkScope(sql, params, restScope, { alias: "alias", nodeId: "node_id" });
|