@sleep2agi/commhub-server 0.9.0-preview.67 → 0.9.0-preview.69
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/agent-access.ts +267 -0
- package/src/agent-acl-http.test.ts +510 -0
- package/src/agent-acl-slice3-http.test.ts +267 -0
- package/src/auth.ts +152 -7
- package/src/create-node-workdir.test.ts +2 -1
- package/src/db.ts +31 -1
- package/src/external-schedule-edits-http.test.ts +3 -2
- package/src/file-download-authz.test.ts +2 -1
- package/src/file-network-scope.test.ts +6 -5
- package/src/health-redaction.test.ts +3 -2
- package/src/human-dm.ts +149 -0
- package/src/network-scope.ts +126 -3
- package/src/node-token-privilege-http.test.ts +149 -0
- package/src/observer-avatar-http.test.ts +2 -1
- package/src/push.ts +43 -2
- package/src/rename.ts +7 -2
- package/src/requirement-tags.ts +16 -0
- package/src/requirements-http.test.ts +79 -1
- package/src/requirements-mcp.test.ts +10 -0
- package/src/requirements-migrate.ts +32 -0
- package/src/requirements-priority-lowest.test.ts +114 -0
- package/src/requirements.ts +107 -35
- package/src/rest-explicit-columns-http.test.ts +1 -1
- package/src/restricted-files.ts +73 -0
- package/src/scheduled-tasks-http.test.ts +2 -1
- package/src/scheduled-tasks.ts +12 -0
- package/src/server.ts +345 -41
- package/src/tools.ts +99 -32
package/src/server.ts
CHANGED
|
@@ -10,14 +10,17 @@ import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/
|
|
|
10
10
|
import { z } from "zod/v4";
|
|
11
11
|
import { registerTools } from "./tools.js";
|
|
12
12
|
import { db, logTaskEvent, logAudit, syncScheduledRunForTask } from "./db.js";
|
|
13
|
-
import { createSSEStream, createNetworkObserverStream, createUserEventStream, pushEvent, pushNetworkObserverEvent, getSSEStats, PRINTABLE_OBSERVER_KEY_PREFIX } from "./push.js";
|
|
13
|
+
import { createSSEStream, createNetworkObserverStream, createUserEventStream, pushEvent, pushNetworkObserverEvent, getSSEStats, PRINTABLE_OBSERVER_KEY_PREFIX, closeUserStreamsInNetwork } from "./push.js";
|
|
14
14
|
import { assertNodeActive } from "./lifecycle-guard.js";
|
|
15
15
|
import { pendingInboxCount } from "./inbox-count.js";
|
|
16
|
-
import { addNetworkScope, canRestWriteNetwork, getUserNetworkIds, resolveRestNetworkScope, resolveRestWriteNetworkId, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
16
|
+
import { addAgentNetworkScope, addHumanNetworkScope, addNetworkScope, addOwnTrafficScope, canRestWriteNetwork, canRestWriteNetworkAsHuman, getUserNetworkIds, resolveRestNetworkScope, resolveRestWriteNetworkId, singleNetworkId, type RestNetworkScope } from "./network-scope.js";
|
|
17
|
+
import { restrictedMemberSeesFile, restrictedMemberAttachmentsDenied } from "./restricted-files.js";
|
|
18
|
+
import { listDmThread, listDmThreads, sendHumanDm } from "./human-dm.js";
|
|
19
|
+
import { canMessageAgent, isAgentRestricted, listAgentGrants, getAgentAccessMode, replaceAgentGrants, restrictedNetworkIds, usernameIsAgentAlias } from "./agent-access.js";
|
|
17
20
|
import { validateAvatarUrl } from "./avatar-validate.js";
|
|
18
21
|
import { narrowTags, parseStoredTags, validateScalarAttr } from "./node-attrs-validate.js";
|
|
19
22
|
import { daemonDefaultWorkdirRoot } from "./create-node-validate.js";
|
|
20
|
-
import { register, login, resolveToken, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, issueUserToken, listTokens, createToken, revokeToken, getNetworkMembers, getUserNetworkRole, addNetworkMember, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
|
|
23
|
+
import { register, login, resolveToken, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, issueUserToken, listTokens, createToken, revokeToken, getNetworkMembers, getNetworkHumans, getUserNetworkRole, addNetworkMember, adminCreateUser, listUsersWithMemberships, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
|
|
21
24
|
import { abortRename, cleanupCommittedRenameSessions, commitRename, prepareRename, resolveCanonicalAlias } from "./rename.js";
|
|
22
25
|
import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
|
|
23
26
|
import { clientRequestIdFromMeta, idempotentTaskId, idempotentTaskMatches, type StoredIdempotentTask } from "./task-idempotency.js";
|
|
@@ -228,12 +231,45 @@ function isTmuxAllowedIP(ip: string): boolean {
|
|
|
228
231
|
return isLocalhostIP(ip) || TMUX_ALLOWLIST.has(ip);
|
|
229
232
|
}
|
|
230
233
|
|
|
234
|
+
// 节点令牌(ntok_ / 邀请码令牌,api_tokens.network_id 非空)是一个节点在一个网络里的身份。
|
|
235
|
+
// resolveToken 把它解析成「签发它的用户」,但它**不继承**那个用户的账号权限与 Hub 管理员身份:
|
|
236
|
+
// 账号 / 令牌 / 网络与成员管理只接受用户令牌,管理员判定一律要求用户令牌。
|
|
237
|
+
// 生产上几乎所有节点令牌都由管理员签发 —— 不这样做,任何一个节点都等于管理员。
|
|
238
|
+
function isNodeCredential(resolved: { networkId: string | null } | null | undefined): boolean {
|
|
239
|
+
return !!resolved?.networkId;
|
|
240
|
+
}
|
|
241
|
+
function isHubAdminCredential(resolved: { networkId: string | null; user: { role: string } } | null | undefined): boolean {
|
|
242
|
+
return !!resolved && !resolved.networkId && resolved.user.role === "admin";
|
|
243
|
+
}
|
|
244
|
+
// 节点改名(`anet node rename` 用节点自己的 ntok 驱动 2PC):节点令牌只能改**它自己**——
|
|
245
|
+
// 绑定网络内、old_alias 就是令牌所代表的那个节点。改别的节点、别的网络仍然要用户令牌。
|
|
246
|
+
function nodeTokenAlias(resolved: { tokenName: string | null }): string | null {
|
|
247
|
+
return resolved.tokenName?.startsWith("node:") ? resolved.tokenName.slice("node:".length) : null;
|
|
248
|
+
}
|
|
249
|
+
function nodeMayRename(resolved: { networkId: string | null; tokenName: string | null }, networkId: unknown, oldAlias: unknown): boolean {
|
|
250
|
+
if (!resolved.networkId) return true;
|
|
251
|
+
return networkId === resolved.networkId && typeof oldAlias === "string" && oldAlias === nodeTokenAlias(resolved);
|
|
252
|
+
}
|
|
253
|
+
function nodeMayRenameTxn(resolved: { networkId: string | null; tokenName: string | null }, txnId: unknown): boolean {
|
|
254
|
+
if (!resolved.networkId) return true;
|
|
255
|
+
if (typeof txnId !== "string") return false;
|
|
256
|
+
const txn = db.get<{ network_id: string; old_alias: string; new_alias: string }>("SELECT network_id, old_alias, new_alias FROM rename_txn WHERE txn_id = ?1", txnId);
|
|
257
|
+
if (!txn || txn.network_id !== resolved.networkId) return false;
|
|
258
|
+
// commit 前后令牌名可能已被改成新 alias(api_tokens.name 跟随改名),两个都算它自己。
|
|
259
|
+
const self = nodeTokenAlias(resolved);
|
|
260
|
+
return self === txn.old_alias || self === txn.new_alias;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function userTokenRequired(req: Request): Response {
|
|
264
|
+
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 }));
|
|
265
|
+
}
|
|
266
|
+
|
|
231
267
|
function requireAdminAuth(req: Request): Response | null {
|
|
232
268
|
const token = requestToken(req);
|
|
233
269
|
if (!token) return Response.json({ ok: false, error: "auth required" }, { status: 401 });
|
|
234
270
|
const resolved = resolveToken(token);
|
|
235
271
|
if (!resolved) return Response.json({ ok: false, error: "invalid token" }, { status: 401 });
|
|
236
|
-
if (resolved
|
|
272
|
+
if (!isHubAdminCredential(resolved)) return Response.json({ ok: false, error: "admin required" }, { status: 403 });
|
|
237
273
|
return null;
|
|
238
274
|
}
|
|
239
275
|
|
|
@@ -353,6 +389,7 @@ export function normalizeEntry(
|
|
|
353
389
|
export function authorizeFileDownload(
|
|
354
390
|
principal: Principal,
|
|
355
391
|
entry: { ownerId: string | null; networkId: string | null },
|
|
392
|
+
fileId?: string,
|
|
356
393
|
): boolean {
|
|
357
394
|
if (principal.kind === "legacy-master") return true;
|
|
358
395
|
if (principal.kind === "admin-utok") return true;
|
|
@@ -360,7 +397,11 @@ export function authorizeFileDownload(
|
|
|
360
397
|
if (entry.networkId !== null) {
|
|
361
398
|
if (principal.kind === "anonymous" || principal.kind === "dev-open-anon") return false;
|
|
362
399
|
if (principal.kind === "ntok") return principal.boundNetworkId === entry.networkId;
|
|
363
|
-
|
|
400
|
+
if (!getUserNetworkRole(principal.userId, entry.networkId)) return false;
|
|
401
|
+
// 多用户 Agent 权限:受限成员只能下载自己传的,或者出现在自己与授权 Agent 往来 / 自己收件箱里的文件。
|
|
402
|
+
if (!isAgentRestricted(principal.userId, entry.networkId)) return true;
|
|
403
|
+
if (entry.ownerId !== null && entry.ownerId === principal.userId) return true;
|
|
404
|
+
return !!fileId && restrictedMemberSeesFile(principal.userId, principal.username, entry.networkId, fileId);
|
|
364
405
|
}
|
|
365
406
|
|
|
366
407
|
if (principal.kind === "ntok" || principal.kind === "utok") {
|
|
@@ -892,7 +933,16 @@ return Bun.serve({
|
|
|
892
933
|
if (!observerRole) {
|
|
893
934
|
return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
|
|
894
935
|
}
|
|
895
|
-
|
|
936
|
+
// 多用户 Agent 权限:受限成员只收到自己是一端的路由事件(自己发的 / 发给自己的),
|
|
937
|
+
// 看不到网络里别人和各个 Agent 之间的流量。
|
|
938
|
+
if (isAgentRestricted(authCtx.userId, observedNetId)) {
|
|
939
|
+
const me = authCtx.username;
|
|
940
|
+
return createNetworkObserverStream(observedNetId, {
|
|
941
|
+
subscriberUserId: authCtx.userId,
|
|
942
|
+
eventFilter: (event) => event.from === me || event.to === me,
|
|
943
|
+
});
|
|
944
|
+
}
|
|
945
|
+
return createNetworkObserverStream(observedNetId, { subscriberUserId: authCtx.userId });
|
|
896
946
|
}
|
|
897
947
|
|
|
898
948
|
// GET /events/users/me?network_id=... → Desktop/user-client stream.
|
|
@@ -1000,8 +1050,25 @@ return Bun.serve({
|
|
|
1000
1050
|
return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
|
|
1001
1051
|
}
|
|
1002
1052
|
if (sessionName === authCtx.username) {
|
|
1053
|
+
// 多用户 Agent 权限:受限成员的用户名撞上本网络某个 Agent 的 alias 时,这个频道其实是那个
|
|
1054
|
+
// Agent 的任务流 —— 不给订阅(入网时已拦撞名,这里兜住 Agent 之后才注册同名 alias 的情况)。
|
|
1055
|
+
if (isAgentRestricted(authCtx.userId, scopedNetId) && usernameIsAgentAlias(scopedNetId, authCtx.username)) {
|
|
1056
|
+
return withCors(req, Response.json({
|
|
1057
|
+
ok: false,
|
|
1058
|
+
error: "channel not allowed: must be your username or an existing agent in your network"
|
|
1059
|
+
}, { status: 403 }));
|
|
1060
|
+
}
|
|
1003
1061
|
return createSSEStream(sessionName, scopedNetId);
|
|
1004
1062
|
}
|
|
1063
|
+
// 多用户 Agent 权限:Agent 频道推的是**所有人**发给这个 Agent 的任务原文,
|
|
1064
|
+
// 受限成员即使被授权了这个 Agent 也不能订阅(他只该看到自己与它的往来,走自己的用户名频道)。
|
|
1065
|
+
// 与「频道不存在」同一个 403 文案,不给 alias 探测留差异。
|
|
1066
|
+
if (isAgentRestricted(authCtx.userId, scopedNetId)) {
|
|
1067
|
+
return withCors(req, Response.json({
|
|
1068
|
+
ok: false,
|
|
1069
|
+
error: "channel not allowed: must be your username or an existing agent in your network"
|
|
1070
|
+
}, { status: 403 }));
|
|
1071
|
+
}
|
|
1005
1072
|
const utokSession = db.get<any>(
|
|
1006
1073
|
"SELECT 1 FROM sessions WHERE alias = ?1 AND network_id = ?2",
|
|
1007
1074
|
sessionName, scopedNetId
|
|
@@ -1118,8 +1185,14 @@ return Bun.serve({
|
|
|
1118
1185
|
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
1119
1186
|
const resolved = resolveToken(token);
|
|
1120
1187
|
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1121
|
-
|
|
1122
|
-
|
|
1188
|
+
// 节点令牌:user 仍是签发它的用户(旧 agent-node 靠它打日志),但只列它绑定的那个网络,
|
|
1189
|
+
// 并用 credential 说清楚这是一个节点身份 —— user.role 不是这个调用者的权限。
|
|
1190
|
+
const allNetworks = getUserAllNetworks(resolved.user.user_id);
|
|
1191
|
+
const networks = resolved.networkId ? allNetworks.filter((n: any) => n.network_id === resolved.networkId) : allNetworks;
|
|
1192
|
+
const credential = resolved.networkId
|
|
1193
|
+
? { kind: "node", network_id: resolved.networkId, node_alias: resolved.tokenName?.startsWith("node:") ? resolved.tokenName.slice(5) : null, acts_as_owner: false }
|
|
1194
|
+
: { kind: "user" };
|
|
1195
|
+
return withCors(req, Response.json({ ok: true, user: resolved.user, networks, current_network: resolved.networkId, credential }));
|
|
1123
1196
|
}
|
|
1124
1197
|
|
|
1125
1198
|
if (url.pathname === "/api/auth/me" && req.method === "PUT") {
|
|
@@ -1127,6 +1200,7 @@ return Bun.serve({
|
|
|
1127
1200
|
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
1128
1201
|
const resolved = resolveToken(token);
|
|
1129
1202
|
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1203
|
+
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1130
1204
|
try {
|
|
1131
1205
|
const body = await req.json() as any;
|
|
1132
1206
|
const updates: string[] = [];
|
|
@@ -1151,6 +1225,7 @@ return Bun.serve({
|
|
|
1151
1225
|
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
1152
1226
|
const resolved = resolveToken(token);
|
|
1153
1227
|
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1228
|
+
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1154
1229
|
try {
|
|
1155
1230
|
const body = await req.json() as any;
|
|
1156
1231
|
const result = changePassword(resolved.user.user_id, body.old_password, body.new_password, resolved.tokenId);
|
|
@@ -1172,6 +1247,7 @@ return Bun.serve({
|
|
|
1172
1247
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1173
1248
|
const resolved = resolveToken(token);
|
|
1174
1249
|
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1250
|
+
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1175
1251
|
try {
|
|
1176
1252
|
const body = await req.json() as any;
|
|
1177
1253
|
if (!body.network_id || !body.node_name) return withCors(req, Response.json({ ok: false, error: "network_id and node_name required" }, { status: 400 }));
|
|
@@ -1196,6 +1272,7 @@ return Bun.serve({
|
|
|
1196
1272
|
if (!body.network_id || !body.old_alias || !body.new_alias) {
|
|
1197
1273
|
return withCors(req, Response.json({ ok: false, error: "network_id, old_alias, new_alias required" }, { status: 400 }));
|
|
1198
1274
|
}
|
|
1275
|
+
if (!nodeMayRename(resolved, body.network_id, body.old_alias)) return userTokenRequired(req);
|
|
1199
1276
|
const result = prepareRename(resolved.user.user_id, body.network_id, body.old_alias, body.new_alias);
|
|
1200
1277
|
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "node_rename_prepared", "node", body.old_alias, body.new_alias);
|
|
1201
1278
|
return withCors(req, Response.json(result, { status: result.ok || result.code === "node_local_only" ? 200 : 400 }));
|
|
@@ -1212,6 +1289,7 @@ return Bun.serve({
|
|
|
1212
1289
|
try {
|
|
1213
1290
|
const body = await req.json() as any;
|
|
1214
1291
|
if (!body.txn_id) return withCors(req, Response.json({ ok: false, error: "txn_id required" }, { status: 400 }));
|
|
1292
|
+
if (!nodeMayRenameTxn(resolved, body.txn_id)) return userTokenRequired(req);
|
|
1215
1293
|
const result = commitRename(resolved.user.user_id, body.txn_id);
|
|
1216
1294
|
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "node_rename_committed", "node", body.txn_id);
|
|
1217
1295
|
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
@@ -1228,6 +1306,7 @@ return Bun.serve({
|
|
|
1228
1306
|
try {
|
|
1229
1307
|
const body = await req.json() as any;
|
|
1230
1308
|
if (!body.txn_id) return withCors(req, Response.json({ ok: false, error: "txn_id required" }, { status: 400 }));
|
|
1309
|
+
if (!nodeMayRenameTxn(resolved, body.txn_id)) return userTokenRequired(req);
|
|
1231
1310
|
const result = abortRename(resolved.user.user_id, body.txn_id);
|
|
1232
1311
|
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "node_rename_aborted", "node", body.txn_id);
|
|
1233
1312
|
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
@@ -1242,6 +1321,7 @@ return Bun.serve({
|
|
|
1242
1321
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1243
1322
|
const resolved = resolveToken(token);
|
|
1244
1323
|
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1324
|
+
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1245
1325
|
const tokens = listTokens(resolved.user.user_id);
|
|
1246
1326
|
return withCors(req, Response.json({ ok: true, tokens }));
|
|
1247
1327
|
}
|
|
@@ -1251,6 +1331,7 @@ return Bun.serve({
|
|
|
1251
1331
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1252
1332
|
const resolved = resolveToken(token);
|
|
1253
1333
|
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1334
|
+
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1254
1335
|
try {
|
|
1255
1336
|
const body = await req.json() as any;
|
|
1256
1337
|
const result = createToken(resolved.user.user_id, body.name || "api-token", body.network_id);
|
|
@@ -1267,6 +1348,7 @@ return Bun.serve({
|
|
|
1267
1348
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1268
1349
|
const resolved = resolveToken(token);
|
|
1269
1350
|
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1351
|
+
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1270
1352
|
const result = revokeToken(resolved.user.user_id, tokenDeleteMatch[1]);
|
|
1271
1353
|
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "token_revoked", "token", tokenDeleteMatch[1]);
|
|
1272
1354
|
return withCors(req, Response.json(result, { status: result.ok ? 200 : 404 }));
|
|
@@ -1308,6 +1390,7 @@ return Bun.serve({
|
|
|
1308
1390
|
if (!token) return withCors(req, Response.json({ ok: false, error: "token required" }, { status: 401 }));
|
|
1309
1391
|
const resolved = resolveToken(token);
|
|
1310
1392
|
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1393
|
+
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1311
1394
|
try {
|
|
1312
1395
|
const body = await req.json() as any;
|
|
1313
1396
|
const result = createNetwork(resolved.user.user_id, body.name, body.description);
|
|
@@ -1317,6 +1400,78 @@ return Bun.serve({
|
|
|
1317
1400
|
}
|
|
1318
1401
|
}
|
|
1319
1402
|
|
|
1403
|
+
// ── 多用户 Agent 权限:成员可访问的 Agent(owner/admin 管理) ──
|
|
1404
|
+
// GET → { agent_access: 'all'|'granted', grants: [{node_id, alias, can_message}] }
|
|
1405
|
+
// PUT { grants?: [{node_id | alias, can_message?}] | string[], agent_access?: 'all'|'granted' } → 整体替换
|
|
1406
|
+
const grantsMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/members\/([^/]+)\/agent-grants$/);
|
|
1407
|
+
if (grantsMatch && (req.method === "GET" || req.method === "PUT")) {
|
|
1408
|
+
const token = requestToken(req, { allowQueryToken: false });
|
|
1409
|
+
const resolved = token ? resolveToken(token) : null;
|
|
1410
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1411
|
+
// 授权是用户层面的管理动作,网络令牌(节点身份)不能改人的权限。
|
|
1412
|
+
if (resolved.networkId) return withCors(req, Response.json({ ok: false, error: "user_token_required" }, { status: 403 }));
|
|
1413
|
+
const netId = decodeURIComponent(grantsMatch[1]);
|
|
1414
|
+
const targetUid = decodeURIComponent(grantsMatch[2]);
|
|
1415
|
+
const hubAdmin = resolved.user.role === "admin";
|
|
1416
|
+
const callerRole = getUserNetworkRole(resolved.user.user_id, netId);
|
|
1417
|
+
if (!hubAdmin && callerRole !== "owner" && callerRole !== "admin") {
|
|
1418
|
+
return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
|
|
1419
|
+
}
|
|
1420
|
+
const targetRole = getUserNetworkRole(targetUid, netId);
|
|
1421
|
+
if (!targetRole) return withCors(req, Response.json({ ok: false, error: "member_not_found" }, { status: 404 }));
|
|
1422
|
+
if (req.method === "GET") {
|
|
1423
|
+
return withCors(req, Response.json({
|
|
1424
|
+
ok: true,
|
|
1425
|
+
network_id: netId,
|
|
1426
|
+
user_id: targetUid,
|
|
1427
|
+
role: targetRole,
|
|
1428
|
+
agent_access: getAgentAccessMode(netId, targetUid),
|
|
1429
|
+
restricted: isAgentRestricted(targetUid, netId),
|
|
1430
|
+
grants: listAgentGrants(netId, targetUid),
|
|
1431
|
+
}));
|
|
1432
|
+
}
|
|
1433
|
+
// 网络 admin 不能改 owner / 其他 admin 的授权(对他们本来也不生效,但别让 admin 去碰平级)。
|
|
1434
|
+
if (!hubAdmin && callerRole !== "owner" && (targetRole === "owner" || targetRole === "admin")) {
|
|
1435
|
+
return withCors(req, Response.json({ ok: false, error: "owner required" }, { status: 403 }));
|
|
1436
|
+
}
|
|
1437
|
+
let body: any;
|
|
1438
|
+
try { body = await req.json(); } catch { return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 })); }
|
|
1439
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 }));
|
|
1440
|
+
if (body.grants === undefined && body.node_ids === undefined && body.agent_access === undefined) {
|
|
1441
|
+
return withCors(req, Response.json({ ok: false, error: "grants or agent_access required" }, { status: 400 }));
|
|
1442
|
+
}
|
|
1443
|
+
const result = replaceAgentGrants({
|
|
1444
|
+
networkId: netId,
|
|
1445
|
+
userId: targetUid,
|
|
1446
|
+
grants: body.grants ?? body.node_ids,
|
|
1447
|
+
agentAccess: body.agent_access,
|
|
1448
|
+
actorUserId: resolved.user.user_id,
|
|
1449
|
+
});
|
|
1450
|
+
if (!result.ok) {
|
|
1451
|
+
return withCors(req, Response.json({ ok: false, error: result.error, ...(result.detail ? { detail: result.detail } : {}) }, { status: result.status }));
|
|
1452
|
+
}
|
|
1453
|
+
logAudit(resolved.user.user_id, resolved.user.username, "member_agent_grants_changed", "network", netId,
|
|
1454
|
+
JSON.stringify({ user_id: targetUid, agent_access: result.agent_access, grants: result.grants.map((g) => ({ node_id: g.node_id, alias: g.alias, can_message: g.can_message })) }).slice(0, 4000),
|
|
1455
|
+
undefined, netId);
|
|
1456
|
+
// 已连着的观察流 / 用户流按旧权限鉴权过,断开让它按新权限重连。
|
|
1457
|
+
closeUserStreamsInNetwork(netId, targetUid);
|
|
1458
|
+
return withCors(req, Response.json({ ok: true, network_id: netId, user_id: targetUid, agent_access: result.agent_access, restricted: isAgentRestricted(targetUid, netId), grants: result.grants }));
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
// ── 多用户:网络里的人类成员通讯录 —— 任何成员(含受限成员)都能看,只有身份字段 ──
|
|
1462
|
+
const humansMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/humans$/);
|
|
1463
|
+
if (humansMatch && req.method === "GET") {
|
|
1464
|
+
const token = requestToken(req, { allowQueryToken: false });
|
|
1465
|
+
const resolved = token ? resolveToken(token) : null;
|
|
1466
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1467
|
+
const netId = decodeURIComponent(humansMatch[1]);
|
|
1468
|
+
if (resolved.networkId && resolved.networkId !== netId) return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
|
|
1469
|
+
if (resolved.user.role !== "admin" && !getUserNetworkRole(resolved.user.user_id, netId)) {
|
|
1470
|
+
return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
|
|
1471
|
+
}
|
|
1472
|
+
return withCors(req, Response.json({ ok: true, network_id: netId, humans: getNetworkHumans(netId) }));
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1320
1475
|
// ── V3.13: Network members + invites ──
|
|
1321
1476
|
const membersMatch = url.pathname.match(/^\/api\/networks\/([^/]+)\/members(?:\/([^/]+))?$/);
|
|
1322
1477
|
if (membersMatch) {
|
|
@@ -1324,6 +1479,7 @@ return Bun.serve({
|
|
|
1324
1479
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1325
1480
|
const resolved = resolveToken(token);
|
|
1326
1481
|
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1482
|
+
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1327
1483
|
const netId = membersMatch[1];
|
|
1328
1484
|
const targetUid = membersMatch[2];
|
|
1329
1485
|
const callerRole = getUserNetworkRole(resolved.user.user_id, netId);
|
|
@@ -1337,21 +1493,31 @@ return Bun.serve({
|
|
|
1337
1493
|
if (req.method === "POST") {
|
|
1338
1494
|
if (!["owner", "admin"].includes(callerRole)) return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
|
|
1339
1495
|
const body = await req.json() as any;
|
|
1340
|
-
|
|
1341
|
-
if (
|
|
1496
|
+
// agent_access 缺省 'granted'(新成员看不到任何 Agent);owner/admin 可显式传 'all' 加一个完全信任的成员。
|
|
1497
|
+
if (body.agent_access !== undefined && body.agent_access !== "all" && body.agent_access !== "granted") {
|
|
1498
|
+
return withCors(req, Response.json({ ok: false, error: "invalid_agent_access" }, { status: 400 }));
|
|
1499
|
+
}
|
|
1500
|
+
const result = addNetworkMember(netId, body.user_id, body.role || "member", resolved.user.user_id, { agentAccess: body.agent_access });
|
|
1501
|
+
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "member_added", "network", netId, `${body.user_id} as ${body.role || "member"} agent_access=${body.agent_access === "all" ? "all" : "granted"}`);
|
|
1342
1502
|
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
1343
1503
|
}
|
|
1344
1504
|
if (req.method === "PUT" && targetUid) {
|
|
1345
1505
|
if (callerRole !== "owner") return withCors(req, Response.json({ ok: false, error: "owner required" }, { status: 403 }));
|
|
1346
1506
|
const body = await req.json() as any;
|
|
1347
1507
|
const result = updateMemberRole(netId, targetUid, body.role);
|
|
1348
|
-
if (result.ok)
|
|
1508
|
+
if (result.ok) {
|
|
1509
|
+
logAudit(resolved.user.user_id, resolved.user.username, "member_role_changed", "network", netId, `${targetUid} → ${body.role}`);
|
|
1510
|
+
closeUserStreamsInNetwork(netId, targetUid);
|
|
1511
|
+
}
|
|
1349
1512
|
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
1350
1513
|
}
|
|
1351
1514
|
if (req.method === "DELETE" && targetUid) {
|
|
1352
1515
|
if (!["owner", "admin"].includes(callerRole)) return withCors(req, Response.json({ ok: false, error: "owner/admin required" }, { status: 403 }));
|
|
1353
1516
|
const result = removeNetworkMember(netId, targetUid);
|
|
1354
|
-
if (result.ok)
|
|
1517
|
+
if (result.ok) {
|
|
1518
|
+
logAudit(resolved.user.user_id, resolved.user.username, "member_removed", "network", netId, targetUid);
|
|
1519
|
+
closeUserStreamsInNetwork(netId, targetUid);
|
|
1520
|
+
}
|
|
1355
1521
|
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
1356
1522
|
}
|
|
1357
1523
|
}
|
|
@@ -1361,6 +1527,7 @@ return Bun.serve({
|
|
|
1361
1527
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1362
1528
|
const resolved = resolveToken(token);
|
|
1363
1529
|
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1530
|
+
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1364
1531
|
const netId = url.pathname.split("/")[3];
|
|
1365
1532
|
const callerRole = getUserNetworkRole(resolved.user.user_id, netId);
|
|
1366
1533
|
if (!callerRole || !["owner", "admin"].includes(callerRole)) {
|
|
@@ -1377,18 +1544,52 @@ return Bun.serve({
|
|
|
1377
1544
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1378
1545
|
const resolved = resolveToken(token);
|
|
1379
1546
|
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1547
|
+
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1380
1548
|
const body = await req.json() as any;
|
|
1381
1549
|
const result = joinByInvite(body.invite_code, resolved.user.user_id);
|
|
1382
1550
|
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "network_joined", "network", result.network_id, `via invite, role=${result.role}`);
|
|
1383
1551
|
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
1384
1552
|
}
|
|
1385
1553
|
|
|
1554
|
+
// ── 多用户账号:管理员建号 / 列用户 ──
|
|
1555
|
+
// POST {username, password, display_name?, email?, network_id?, role?}
|
|
1556
|
+
// Hub 管理员:任意;某网络 owner/admin:只能带上自己管的 network_id(新用户进这个网络)。
|
|
1557
|
+
// 新成员默认 agent_access='granted' —— 看不到任何 Agent,等管理员在 agent-grants 里授权。
|
|
1558
|
+
// GET → Hub 管理员:全部用户 + 每人所在网络 / 角色 / agent_access。
|
|
1559
|
+
if (url.pathname === "/api/admin/users" && (req.method === "GET" || req.method === "POST")) {
|
|
1560
|
+
const token = requestToken(req, { allowQueryToken: false });
|
|
1561
|
+
const resolved = token ? resolveToken(token) : null;
|
|
1562
|
+
if (!resolved) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1563
|
+
if (resolved.networkId) return withCors(req, Response.json({ ok: false, error: "user_token_required" }, { status: 403 }));
|
|
1564
|
+
const hubAdmin = resolved.user.role === "admin";
|
|
1565
|
+
if (req.method === "GET") {
|
|
1566
|
+
if (!hubAdmin) return withCors(req, Response.json({ ok: false, error: "admin required" }, { status: 403 }));
|
|
1567
|
+
return withCors(req, Response.json({ ok: true, users: listUsersWithMemberships() }));
|
|
1568
|
+
}
|
|
1569
|
+
const clientIP = getClientIP(req, server);
|
|
1570
|
+
if (!checkRateLimit(`admin-users:${clientIP}`, 30)) {
|
|
1571
|
+
return withCors(req, Response.json({ ok: false, error: "too many requests, try again later" }, { status: 429 }));
|
|
1572
|
+
}
|
|
1573
|
+
let body: any;
|
|
1574
|
+
try { body = await req.json(); } catch { return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 })); }
|
|
1575
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 }));
|
|
1576
|
+
const result = adminCreateUser(body, { userId: resolved.user.user_id, isHubAdmin: hubAdmin });
|
|
1577
|
+
if (!result.ok) {
|
|
1578
|
+
logAudit(resolved.user.user_id, resolved.user.username, "admin_user_create_denied", "user", undefined,
|
|
1579
|
+
JSON.stringify({ username: String(body.username ?? "").slice(0, 50), network_id: body.network_id ?? null, error: result.error }), clientIP);
|
|
1580
|
+
return withCors(req, Response.json({ ok: false, error: result.error }, { status: result.status }));
|
|
1581
|
+
}
|
|
1582
|
+
logAudit(resolved.user.user_id, resolved.user.username, "admin_user_created", "user", result.user.user_id,
|
|
1583
|
+
JSON.stringify({ username: result.user.username, membership: result.membership }), clientIP, result.membership?.network_id);
|
|
1584
|
+
return withCors(req, Response.json({ ok: true, user: result.user, personal_network_id: result.network_id, membership: result.membership }));
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1386
1587
|
// ── V3: Admin APIs (require auth) ──
|
|
1387
1588
|
if (url.pathname === "/api/users" && req.method === "GET") {
|
|
1388
1589
|
const token = req.headers.get("Authorization")?.replace("Bearer ", "");
|
|
1389
1590
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1390
1591
|
const resolved = resolveToken(token);
|
|
1391
|
-
if (!resolved
|
|
1592
|
+
if (!isHubAdminCredential(resolved)) {
|
|
1392
1593
|
return withCors(req, Response.json({ ok: false, error: "admin required" }, { status: 403 }));
|
|
1393
1594
|
}
|
|
1394
1595
|
const users = db.all("SELECT user_id, username, display_name, email, role, created_at FROM users ORDER BY created_at");
|
|
@@ -1406,13 +1607,22 @@ return Bun.serve({
|
|
|
1406
1607
|
if (!network) return withCors(req, Response.json({ ok: false, error: "network not found" }, { status: 404 }));
|
|
1407
1608
|
// Membership check: must be a member or system admin
|
|
1408
1609
|
const viewerRole = getUserNetworkRole(resolved.user.user_id, networkId);
|
|
1409
|
-
if (
|
|
1610
|
+
if (resolved.networkId && resolved.networkId !== networkId) {
|
|
1611
|
+
return withCors(req, Response.json({ ok: false, error: "access denied" }, { status: 403 }));
|
|
1612
|
+
}
|
|
1613
|
+
if (!viewerRole && !isHubAdminCredential(resolved)) {
|
|
1410
1614
|
return withCors(req, Response.json({ ok: false, error: "access denied" }, { status: 403 }));
|
|
1411
1615
|
}
|
|
1412
|
-
// Get network stats
|
|
1413
|
-
const
|
|
1414
|
-
|
|
1415
|
-
|
|
1616
|
+
// Get network stats —— 受限成员只数得到授权给他的 Agent 与自己的往来。
|
|
1617
|
+
const detailScope: RestNetworkScope = resolved.user.role === "admin" || resolved.networkId
|
|
1618
|
+
? { networkId, networkIds: null }
|
|
1619
|
+
: resolveRestNetworkScope(networkId, { userId: resolved.user.user_id, networkId: null, username: resolved.user.username }, false);
|
|
1620
|
+
const nodeParams: unknown[] = [];
|
|
1621
|
+
const nodeCount = db.get<{ cnt: number }>(addAgentNetworkScope("SELECT COUNT(*) as cnt FROM nodes WHERE 1=1", nodeParams, detailScope, { alias: "alias", nodeId: "node_id" }), ...nodeParams);
|
|
1622
|
+
const sessionParams: unknown[] = [];
|
|
1623
|
+
const sessionCount = db.get<{ cnt: number }>(addAgentNetworkScope("SELECT COUNT(*) as cnt FROM sessions WHERE 1=1", sessionParams, detailScope, { alias: "alias", nodeId: "node_id" }), ...sessionParams);
|
|
1624
|
+
const taskParams: unknown[] = [];
|
|
1625
|
+
const taskStats = db.all<any>(addOwnTrafficScope("SELECT status, COUNT(*) as count FROM tasks WHERE 1=1", taskParams, detailScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" }) + " GROUP BY status", ...taskParams);
|
|
1416
1626
|
return withCors(req, Response.json({
|
|
1417
1627
|
ok: true, network,
|
|
1418
1628
|
stats: { nodes: nodeCount?.cnt || 0, sessions: sessionCount?.cnt || 0, tasks: taskStats },
|
|
@@ -1424,6 +1634,7 @@ return Bun.serve({
|
|
|
1424
1634
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1425
1635
|
const resolved = resolveToken(token);
|
|
1426
1636
|
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1637
|
+
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1427
1638
|
const result = deleteNetwork(resolved.user.user_id, netDetailMatch[1]);
|
|
1428
1639
|
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "network_deleted", "network", netDetailMatch[1]);
|
|
1429
1640
|
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
@@ -1434,6 +1645,7 @@ return Bun.serve({
|
|
|
1434
1645
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
1435
1646
|
const resolved = resolveToken(token);
|
|
1436
1647
|
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
1648
|
+
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
1437
1649
|
try {
|
|
1438
1650
|
const body = await req.json() as any;
|
|
1439
1651
|
if (body.name) {
|
|
@@ -1514,6 +1726,10 @@ return Bun.serve({
|
|
|
1514
1726
|
healthAuth.userId,
|
|
1515
1727
|
).map((r) => r.network_id),
|
|
1516
1728
|
);
|
|
1729
|
+
// 多用户 Agent 权限:键里带着 alias,受限网络整网不出现在这里(与 addNetworkScope 同一 fail-closed 口径)。
|
|
1730
|
+
if (!healthAuth.networkId) {
|
|
1731
|
+
for (const restricted of restrictedNetworkIds(healthAuth.userId)) memberNets.delete(restricted);
|
|
1732
|
+
}
|
|
1517
1733
|
// If member has no networks at all, keep field present but empty
|
|
1518
1734
|
// so the dashboard can distinguish "authenticated + none active"
|
|
1519
1735
|
// from "not authenticated at all".
|
|
@@ -1573,8 +1789,12 @@ return Bun.serve({
|
|
|
1573
1789
|
// Resolve network scope for REST queries — enforce isolation
|
|
1574
1790
|
// Token-bound networkId takes precedence (ntok_ → forced), then query param
|
|
1575
1791
|
const restAuth = resolveRequestAuth(req);
|
|
1576
|
-
|
|
1792
|
+
// Hub 管理员身份只随用户令牌;节点令牌即使由管理员签发也不是管理员(见 isNodeCredential)。
|
|
1793
|
+
const isAdmin = !!(restAuth?.username && !restAuth.networkId && db.get<any>("SELECT role FROM users WHERE username = ?1", restAuth.username)?.role === "admin");
|
|
1577
1794
|
const restScope = resolveRestNetworkScope(url.searchParams.get("network_id"), restAuth, isAdmin);
|
|
1795
|
+
// 多用户 Agent 权限:下面凡是读 Agent 数据的查询,受限成员在受限网络里默认拿不到任何行
|
|
1796
|
+
// (addNetworkScope fail-closed);逐条审过的路径改用 addAgentNetworkScope / addOwnTrafficScope
|
|
1797
|
+
// 按授权放行,人类数据用 addHumanNetworkScope。
|
|
1578
1798
|
if (restScope.denied) {
|
|
1579
1799
|
return withCors(req, Response.json({ ok: false, error: restScope.denied }, { status: 403 }));
|
|
1580
1800
|
}
|
|
@@ -1627,6 +1847,47 @@ return Bun.serve({
|
|
|
1627
1847
|
});
|
|
1628
1848
|
if (scheduledResponse) return withCors(req, scheduledResponse);
|
|
1629
1849
|
|
|
1850
|
+
// ── 多用户:人与人私信(同网络两个用户之间;受限成员也可以) ──
|
|
1851
|
+
// POST /api/dm {network_id?, to_user_id | to_username, message, attachments?, client_request_id?}
|
|
1852
|
+
// GET /api/dm?network_id=&with=<user_id>[&limit&before] → 双向会话记录(新的在前)
|
|
1853
|
+
// GET /api/dm/threads?network_id= → 每个对方一行 + 未读数
|
|
1854
|
+
// 已读沿用 POST /api/messages/ack(私信就是收件人 user_inbox 里的行)。
|
|
1855
|
+
if (url.pathname === "/api/dm" || url.pathname === "/api/dm/threads") {
|
|
1856
|
+
if (!restAuth || !requestToken(req).startsWith("utok_")) {
|
|
1857
|
+
return withCors(req, Response.json({ ok: false, error: "user_token_required" }, { status: 403 }));
|
|
1858
|
+
}
|
|
1859
|
+
if (req.method === "POST" && url.pathname === "/api/dm") {
|
|
1860
|
+
let body: any;
|
|
1861
|
+
try { body = await req.json(); } catch { return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 })); }
|
|
1862
|
+
if (!body || typeof body !== "object" || Array.isArray(body)) return withCors(req, Response.json({ ok: false, error: "invalid JSON" }, { status: 400 }));
|
|
1863
|
+
const dmNet = typeof body.network_id === "string" && body.network_id ? body.network_id : singleNetworkId(restScope);
|
|
1864
|
+
if (!dmNet) return withCors(req, Response.json({ ok: false, error: "network_id_required" }, { status: 400 }));
|
|
1865
|
+
const sent = sendHumanDm({
|
|
1866
|
+
networkId: dmNet,
|
|
1867
|
+
sender: { userId: restAuth.userId, username: restAuth.username },
|
|
1868
|
+
toUserId: body.to_user_id, toUsername: body.to_username,
|
|
1869
|
+
message: body.message, attachments: body.attachments, clientRequestId: body.client_request_id,
|
|
1870
|
+
});
|
|
1871
|
+
if (!sent.ok) return withCors(req, Response.json({ ok: false, error: sent.error }, { status: sent.status }));
|
|
1872
|
+
return withCors(req, Response.json({ ok: true, message: sent.message, delivered: sent.delivered }));
|
|
1873
|
+
}
|
|
1874
|
+
if (req.method === "GET") {
|
|
1875
|
+
const dmNet = url.searchParams.get("network_id") || singleNetworkId(restScope);
|
|
1876
|
+
if (!dmNet) return withCors(req, Response.json({ ok: false, error: "network_id_required" }, { status: 400 }));
|
|
1877
|
+
if (!getUserNetworkRole(restAuth.userId, dmNet)) {
|
|
1878
|
+
return withCors(req, Response.json({ ok: false, error: "not a member of this network" }, { status: 403 }));
|
|
1879
|
+
}
|
|
1880
|
+
if (url.pathname === "/api/dm/threads") {
|
|
1881
|
+
return withCors(req, Response.json({ ok: true, network_id: dmNet, threads: listDmThreads(dmNet, restAuth.userId) }));
|
|
1882
|
+
}
|
|
1883
|
+
const other = url.searchParams.get("with") ?? "";
|
|
1884
|
+
if (!other) return withCors(req, Response.json({ ok: false, error: "with_required" }, { status: 400 }));
|
|
1885
|
+
const limit = Number(url.searchParams.get("limit")) || 50;
|
|
1886
|
+
return withCors(req, Response.json({ ok: true, network_id: dmNet, with: other, messages: listDmThread(dmNet, restAuth.userId, other, limit, url.searchParams.get("before")) }));
|
|
1887
|
+
}
|
|
1888
|
+
return withCors(req, Response.json({ ok: false, error: "method_not_allowed" }, { status: 405 }));
|
|
1889
|
+
}
|
|
1890
|
+
|
|
1630
1891
|
const requirementsResponse = await handleRequirementsRequest({
|
|
1631
1892
|
req,
|
|
1632
1893
|
url,
|
|
@@ -1652,6 +1913,13 @@ return Bun.serve({
|
|
|
1652
1913
|
// under DEV_OPEN this endpoint is anonymously readable BY DESIGN
|
|
1653
1914
|
// (dev convenience — production never sets COMMHUB_DEV_OPEN, so the
|
|
1654
1915
|
// topology detail stays gated in every real deployment).
|
|
1916
|
+
// 节点令牌:只看它绑定网络的那部分(投递可达性探针要用);不再因签发者是管理员而看到全部网络。
|
|
1917
|
+
if (restAuth?.networkId && !isAdmin) {
|
|
1918
|
+
const bound = restAuth.networkId;
|
|
1919
|
+
const sse = getSSEStats();
|
|
1920
|
+
const sessions = Object.fromEntries(Object.entries(sse.sessions).filter(([key]) => key.split(":").includes(bound)));
|
|
1921
|
+
return withCors(req, Response.json({ ok: true, total: Object.values(sessions).reduce((a, b) => a + b, 0), sessions, scope: "network", network_id: bound }));
|
|
1922
|
+
}
|
|
1655
1923
|
if (restAuth && !isAdmin) {
|
|
1656
1924
|
return withCors(req, Response.json({ ok: false, error: "admin or master token required" }, { status: 403 }));
|
|
1657
1925
|
}
|
|
@@ -1676,7 +1944,7 @@ return Bun.serve({
|
|
|
1676
1944
|
let sql = isLight
|
|
1677
1945
|
? "SELECT alias, status, agent, task, server, updated_at, network_id FROM sessions WHERE 1=1"
|
|
1678
1946
|
: `SELECT ${SESSION_REST_SELECT} FROM sessions WHERE 1=1`;
|
|
1679
|
-
sql =
|
|
1947
|
+
sql = addAgentNetworkScope(sql, params, restScope, { alias: "alias", nodeId: "node_id" });
|
|
1680
1948
|
sql += " ORDER BY updated_at DESC";
|
|
1681
1949
|
// `model` comes straight from the explicit sessions projection; `runtime` is
|
|
1682
1950
|
// derived from the raw `agent` field. Both default to null for old nodes
|
|
@@ -2107,7 +2375,8 @@ return Bun.serve({
|
|
|
2107
2375
|
}
|
|
2108
2376
|
}
|
|
2109
2377
|
|
|
2110
|
-
|
|
2378
|
+
// 上传是人类侧写入(给私信 / 给授权 Agent 的任务挂附件),受限成员也可以;下载另有授权判定。
|
|
2379
|
+
if (!canRestWriteNetworkAsHuman(authCtx, uploadNetId, principal.kind === "admin-utok")) {
|
|
2111
2380
|
return earlyReject(Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
|
|
2112
2381
|
}
|
|
2113
2382
|
|
|
@@ -2273,7 +2542,7 @@ return Bun.serve({
|
|
|
2273
2542
|
// "no such file". Both branches return the same 404 shape
|
|
2274
2543
|
// for BOTH GET and HEAD (HEAD honours body-omission but the
|
|
2275
2544
|
// status code and headers are the authoritative signal).
|
|
2276
|
-
if (!authorizeFileDownload(resolvePrincipal(req, { allowQueryToken: false }), normalizeEntry(entry))) {
|
|
2545
|
+
if (!authorizeFileDownload(resolvePrincipal(req, { allowQueryToken: false }), normalizeEntry(entry), fileId)) {
|
|
2277
2546
|
return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
|
|
2278
2547
|
}
|
|
2279
2548
|
|
|
@@ -2399,7 +2668,27 @@ return Bun.serve({
|
|
|
2399
2668
|
message: "network_id is required when the user token has zero or multiple network memberships",
|
|
2400
2669
|
}, { status: 400 }));
|
|
2401
2670
|
}
|
|
2402
|
-
|
|
2671
|
+
// 多用户 Agent 权限:受限成员只能给授权且 can_message 的 Agent 发;
|
|
2672
|
+
// 目标不存在 / 没授权 / 只读授权 → 同一个 403,不留 alias 探测差异。发件人固定为自己的用户名。
|
|
2673
|
+
const restrictedSender = !!restAuth && !isAdmin && !restAuth.networkId && isAgentRestricted(restAuth.userId, taskNetId!);
|
|
2674
|
+
if (restrictedSender) {
|
|
2675
|
+
if (!canRestWriteNetworkAsHuman(restAuth, taskNetId, isAdmin)) {
|
|
2676
|
+
return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
|
|
2677
|
+
}
|
|
2678
|
+
const restrictedTarget = resolveCanonicalAlias(taskNetId, body.alias).alias;
|
|
2679
|
+
const restrictedSession = db.get<{ node_id: string | null }>("SELECT node_id FROM sessions WHERE alias = ?1 AND network_id = ?2", restrictedTarget, taskNetId);
|
|
2680
|
+
if (!restrictedSession || !canMessageAgent(restAuth!.userId, taskNetId, { alias: restrictedTarget, nodeId: restrictedSession.node_id })) {
|
|
2681
|
+
return withCors(req, Response.json({ ok: false, error: "agent_not_granted", message: "this agent has not been granted to you; ask a network admin" }, { status: 403 }));
|
|
2682
|
+
}
|
|
2683
|
+
if (typeof body.from === "string" && body.from.trim() && body.from.trim() !== restAuth!.username) {
|
|
2684
|
+
return withCors(req, Response.json({ ok: false, error: "from_session_identity_mismatch", message: "restricted members always send as their own username" }, { status: 403 }));
|
|
2685
|
+
}
|
|
2686
|
+
for (const raw of [(body as any).attachments, (body as any).meta?.attachments]) {
|
|
2687
|
+
if (restrictedMemberAttachmentsDenied(restAuth!.userId, restAuth!.username, taskNetId!, raw)) {
|
|
2688
|
+
return withCors(req, Response.json({ ok: false, error: "attachment_not_accessible" }, { status: 403 }));
|
|
2689
|
+
}
|
|
2690
|
+
}
|
|
2691
|
+
} else if (!canRestWriteNetwork(restAuth, taskNetId, isAdmin)) {
|
|
2403
2692
|
return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
|
|
2404
2693
|
}
|
|
2405
2694
|
const canonical = resolveCanonicalAlias(taskNetId, body.alias);
|
|
@@ -2722,7 +3011,8 @@ return Bun.serve({
|
|
|
2722
3011
|
const userInboxAliasCollides = (username: string, scope: RestNetworkScope): boolean => {
|
|
2723
3012
|
const p: any[] = [username];
|
|
2724
3013
|
let q = "SELECT 1 AS hit FROM nodes WHERE alias = ?1";
|
|
2725
|
-
|
|
3014
|
+
// 撞名检测要看见网络里**所有**节点(受限成员看不见的也算),否则撞名的节点待办会被当成用户未读。
|
|
3015
|
+
q = addHumanNetworkScope(q, p, scope);
|
|
2726
3016
|
return !!db.get<{ hit: number }>(q + " LIMIT 1", ...p);
|
|
2727
3017
|
};
|
|
2728
3018
|
|
|
@@ -2745,7 +3035,7 @@ return Bun.serve({
|
|
|
2745
3035
|
FROM user_inbox WHERE user_id = ?1`;
|
|
2746
3036
|
if (unackedOnly) sql += " AND acked = 0";
|
|
2747
3037
|
// 复用 alias 分支同一个 scope 助手:新读路径漏 scope 是已被审计证实的真风险。
|
|
2748
|
-
sql =
|
|
3038
|
+
sql = addHumanNetworkScope(sql, params, restScope);
|
|
2749
3039
|
sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
|
|
2750
3040
|
params.push(limit);
|
|
2751
3041
|
const rows = db.all<any>(sql, ...params);
|
|
@@ -2753,7 +3043,7 @@ return Bun.serve({
|
|
|
2753
3043
|
// 未读数与列表用同一个 user_id + 同一个 scope 助手算,避免两处口径漂开。
|
|
2754
3044
|
const countParams: any[] = [callerUserId];
|
|
2755
3045
|
let countSql = "SELECT COUNT(*) AS cnt FROM user_inbox WHERE user_id = ?1 AND acked = 0";
|
|
2756
|
-
countSql =
|
|
3046
|
+
countSql = addHumanNetworkScope(countSql, countParams, restScope);
|
|
2757
3047
|
const unread = db.get<{ cnt: number }>(countSql, ...countParams)?.cnt ?? 0;
|
|
2758
3048
|
|
|
2759
3049
|
// #1828 —— 按 agent 分的未读:user_inbox(agent 主动发给用户)+ inbox 里发给**这个用户名**的
|
|
@@ -2765,14 +3055,16 @@ return Bun.serve({
|
|
|
2765
3055
|
for (const r of agentRows) byAgent[r.agent || "hub"] = (byAgent[r.agent || "hub"] ?? 0) + Number(r.n ?? 0);
|
|
2766
3056
|
};
|
|
2767
3057
|
const uiAgentParams: any[] = [callerUserId];
|
|
2768
|
-
|
|
2769
|
-
|
|
3058
|
+
// 人与人私信(kind=human_dm)的 from_session 是发信人的用户名,不是 Agent —— 不进按 Agent 的未读,
|
|
3059
|
+
// 否则客户端会把发私信的人画成一个幽灵 Agent。私信未读走 /api/dm/threads。
|
|
3060
|
+
let uiAgentSql = "SELECT from_session AS agent, COUNT(*) AS n FROM user_inbox WHERE user_id = ?1 AND acked = 0 AND kind != 'human_dm'";
|
|
3061
|
+
uiAgentSql = addHumanNetworkScope(uiAgentSql, uiAgentParams, restScope);
|
|
2770
3062
|
addByAgent(db.all<{ agent: string | null; n: number }>(uiAgentSql + " GROUP BY from_session", ...uiAgentParams));
|
|
2771
3063
|
const callerUsername = restAuth?.username ?? "";
|
|
2772
3064
|
if (callerUsername && !userInboxAliasCollides(callerUsername, restScope)) {
|
|
2773
3065
|
const ibParams: any[] = [callerUsername];
|
|
2774
3066
|
let ibSql = "SELECT from_session AS agent, COUNT(*) AS n FROM inbox WHERE session_name = ?1 AND acked = 0 AND type IN ('reply', 'task', 'message')";
|
|
2775
|
-
ibSql =
|
|
3067
|
+
ibSql = addHumanNetworkScope(ibSql, ibParams, restScope);
|
|
2776
3068
|
addByAgent(db.all<{ agent: string | null; n: number }>(ibSql + " GROUP BY from_session", ...ibParams));
|
|
2777
3069
|
}
|
|
2778
3070
|
const unreadTotal = Object.values(byAgent).reduce((a, b) => a + b, 0);
|
|
@@ -2804,7 +3096,7 @@ return Bun.serve({
|
|
|
2804
3096
|
params.push(alias);
|
|
2805
3097
|
sql += ` AND session_name = ?${params.length}`;
|
|
2806
3098
|
}
|
|
2807
|
-
sql =
|
|
3099
|
+
sql = addOwnTrafficScope(sql, params, restScope, { from: "from_session", to: "session_name" });
|
|
2808
3100
|
sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
|
|
2809
3101
|
params.push(limit);
|
|
2810
3102
|
const rows = db.all(sql, ...params);
|
|
@@ -2813,7 +3105,7 @@ return Bun.serve({
|
|
|
2813
3105
|
if (alias) {
|
|
2814
3106
|
const countParams: any[] = [alias];
|
|
2815
3107
|
let countSql = "SELECT COUNT(*) AS cnt FROM inbox WHERE session_name = ?1 AND acked = 0";
|
|
2816
|
-
countSql =
|
|
3108
|
+
countSql = addOwnTrafficScope(countSql, countParams, restScope, { from: "from_session", to: "session_name" });
|
|
2817
3109
|
pendingCount = db.get<{ cnt: number }>(countSql, ...countParams)?.cnt ?? 0;
|
|
2818
3110
|
}
|
|
2819
3111
|
|
|
@@ -2843,7 +3135,7 @@ return Bun.serve({
|
|
|
2843
3135
|
const uiParams: any[] = [callerUserId, agentRaw];
|
|
2844
3136
|
let uiSql = `UPDATE user_inbox SET acked = 1, acked_at = datetime('now')
|
|
2845
3137
|
WHERE user_id = ?1 AND from_session = ?2 AND acked = 0`;
|
|
2846
|
-
uiSql =
|
|
3138
|
+
uiSql = addHumanNetworkScope(uiSql, uiParams, restScope);
|
|
2847
3139
|
const uiAcked = db.run(uiSql, uiParams).changes ?? 0;
|
|
2848
3140
|
let agentInboxAcked = 0;
|
|
2849
3141
|
const agentCallerUsername = restAuth?.username ?? "";
|
|
@@ -2851,7 +3143,7 @@ return Bun.serve({
|
|
|
2851
3143
|
const ibAgentParams: any[] = [agentCallerUsername, agentRaw];
|
|
2852
3144
|
let ibAgentSql = `UPDATE inbox SET acked = 1
|
|
2853
3145
|
WHERE session_name = ?1 AND from_session = ?2 AND acked = 0 AND type IN ('reply', 'task', 'message')`;
|
|
2854
|
-
ibAgentSql =
|
|
3146
|
+
ibAgentSql = addHumanNetworkScope(ibAgentSql, ibAgentParams, restScope);
|
|
2855
3147
|
agentInboxAcked = db.run(ibAgentSql, ibAgentParams).changes ?? 0;
|
|
2856
3148
|
}
|
|
2857
3149
|
return withCors(req, Response.json({
|
|
@@ -2871,7 +3163,7 @@ return Bun.serve({
|
|
|
2871
3163
|
const placeholders = ids.map((_, i) => `?${i + 2}`).join(", ");
|
|
2872
3164
|
let sql = `UPDATE user_inbox SET acked = 1, acked_at = datetime('now')
|
|
2873
3165
|
WHERE user_id = ?1 AND message_id IN (${placeholders}) AND acked = 0`;
|
|
2874
|
-
sql =
|
|
3166
|
+
sql = addHumanNetworkScope(sql, params, restScope);
|
|
2875
3167
|
const res = db.run(sql, params);
|
|
2876
3168
|
// #1828 —— 同一批 id 也可能是 inbox 里发给这个用户名的 reply/task/message 行(agent 回复);
|
|
2877
3169
|
// 用户看过就 ack 掉。只动 session_name = 调用者用户名 的行,别人的 id 匹配不到;
|
|
@@ -2882,7 +3174,7 @@ return Bun.serve({
|
|
|
2882
3174
|
const ibParams: any[] = [callerUsername, ...ids];
|
|
2883
3175
|
let ibSql = `UPDATE inbox SET acked = 1
|
|
2884
3176
|
WHERE session_name = ?1 AND id IN (${placeholders}) AND acked = 0 AND type IN ('reply', 'task', 'message')`;
|
|
2885
|
-
ibSql =
|
|
3177
|
+
ibSql = addHumanNetworkScope(ibSql, ibParams, restScope);
|
|
2886
3178
|
inboxAcked = db.run(ibSql, ibParams).changes ?? 0;
|
|
2887
3179
|
}
|
|
2888
3180
|
return withCors(req, Response.json({ ok: true, scope: "ids", acked: (res.changes ?? 0) + inboxAcked, acked_user_inbox: res.changes ?? 0, acked_inbox: inboxAcked }));
|
|
@@ -2934,7 +3226,7 @@ return Bun.serve({
|
|
|
2934
3226
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
2935
3227
|
const resolved = resolveToken(token);
|
|
2936
3228
|
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
2937
|
-
if (resolved
|
|
3229
|
+
if (!isHubAdminCredential(resolved)) return withCors(req, Response.json({ ok: false, error: "admin only" }, { status: 403 }));
|
|
2938
3230
|
const limit = Math.min(Number(url.searchParams.get("limit")) || 200, LOG_RING_CAP);
|
|
2939
3231
|
const since = url.searchParams.get("since"); // ISO timestamp; only return logs newer
|
|
2940
3232
|
let entries = logRing.slice(-limit);
|
|
@@ -2950,6 +3242,8 @@ return Bun.serve({
|
|
|
2950
3242
|
if (!token) return withCors(req, Response.json({ ok: false, error: "auth required" }, { status: 401 }));
|
|
2951
3243
|
const resolved = resolveToken(token);
|
|
2952
3244
|
if (!resolved) return withCors(req, Response.json({ ok: false, error: "invalid token" }, { status: 401 }));
|
|
3245
|
+
// 审计日志是账号的记录;节点令牌不能读签发者(常是管理员)的审计日志。
|
|
3246
|
+
if (isNodeCredential(resolved)) return userTokenRequired(req);
|
|
2953
3247
|
const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 200);
|
|
2954
3248
|
const action = url.searchParams.get("action");
|
|
2955
3249
|
const userId = url.searchParams.get("user_id");
|
|
@@ -2971,7 +3265,13 @@ return Bun.serve({
|
|
|
2971
3265
|
const limit = Math.min(Number(url.searchParams.get("limit")) || 50, 500);
|
|
2972
3266
|
let sql = `SELECT ${TASK_EVENT_REST_SELECT} FROM task_events WHERE 1=1`;
|
|
2973
3267
|
const params: any[] = [];
|
|
2974
|
-
|
|
3268
|
+
if (restScope.agentRestriction) {
|
|
3269
|
+
// 受限成员:只看得到自己与授权 Agent 往来的那些任务的事件。
|
|
3270
|
+
sql += " AND task_id IN (SELECT task_id FROM tasks WHERE 1=1";
|
|
3271
|
+
sql = addOwnTrafficScope(sql, params, restScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" }) + ")";
|
|
3272
|
+
} else {
|
|
3273
|
+
sql = addNetworkScope(sql, params, restScope);
|
|
3274
|
+
}
|
|
2975
3275
|
if (taskId) { sql += ` AND task_id = ?${params.length + 1}`; params.push(taskId); }
|
|
2976
3276
|
sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
|
|
2977
3277
|
params.push(limit);
|
|
@@ -3277,6 +3577,10 @@ return Bun.serve({
|
|
|
3277
3577
|
: "missing_network_id";
|
|
3278
3578
|
return withCors(req, Response.json({ ok: false, error, memberships }, { status: 400 }));
|
|
3279
3579
|
}
|
|
3580
|
+
// 多用户 Agent 权限:daemon 也是 Agent,受限成员看不到。
|
|
3581
|
+
if (restAuth && !isAdmin && isAgentRestricted(restAuth.userId, effectiveNetId)) {
|
|
3582
|
+
return withCors(req, Response.json({ ok: true, daemons: [], count: 0 }));
|
|
3583
|
+
}
|
|
3280
3584
|
// Role for member-脱敏 (admin/owner = full host_telemetry, others = masked)
|
|
3281
3585
|
let isPrivileged = false;
|
|
3282
3586
|
if (restAuth?.userId) {
|
|
@@ -3467,7 +3771,7 @@ return Bun.serve({
|
|
|
3467
3771
|
display_name, team, tags, attrs_revision
|
|
3468
3772
|
FROM nodes WHERE 1=1`;
|
|
3469
3773
|
const params: any[] = [];
|
|
3470
|
-
sql =
|
|
3774
|
+
sql = addAgentNetworkScope(sql, params, restScope, { alias: "alias", nodeId: "node_id" });
|
|
3471
3775
|
if (nodeId) { sql += ` AND node_id = ?${params.length + 1}`; params.push(nodeId); }
|
|
3472
3776
|
if (alias) { sql += ` AND alias = ?${params.length + 1}`; params.push(alias); }
|
|
3473
3777
|
sql += " ORDER BY updated_at DESC";
|
|
@@ -3539,7 +3843,7 @@ return Bun.serve({
|
|
|
3539
3843
|
const taskId = decodeURIComponent(taskPathMatch[1] ?? "");
|
|
3540
3844
|
const params: any[] = [taskId];
|
|
3541
3845
|
let sql = `SELECT ${TASK_REST_SELECT} FROM tasks WHERE task_id = ?1`;
|
|
3542
|
-
sql =
|
|
3846
|
+
sql = addOwnTrafficScope(sql, params, restScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
|
|
3543
3847
|
sql += " LIMIT 1";
|
|
3544
3848
|
const task = db.get(sql, ...params);
|
|
3545
3849
|
if (!task) {
|
|
@@ -3628,7 +3932,7 @@ return Bun.serve({
|
|
|
3628
3932
|
|
|
3629
3933
|
let sql = `SELECT ${TASK_REST_SELECT} FROM tasks WHERE 1=1`;
|
|
3630
3934
|
const params: any[] = [];
|
|
3631
|
-
sql =
|
|
3935
|
+
sql = addOwnTrafficScope(sql, params, restScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
|
|
3632
3936
|
if (taskId) { sql += ` AND task_id = ?${params.length + 1}`; params.push(taskId); }
|
|
3633
3937
|
if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
|
|
3634
3938
|
if (toName) { sql += ` AND to_name = ?${params.length + 1}`; params.push(toName); }
|
|
@@ -3651,7 +3955,7 @@ return Bun.serve({
|
|
|
3651
3955
|
}
|
|
3652
3956
|
const statsParams: any[] = [];
|
|
3653
3957
|
let statsSql = "SELECT status, COUNT(*) as count FROM tasks WHERE 1=1";
|
|
3654
|
-
statsSql =
|
|
3958
|
+
statsSql = addOwnTrafficScope(statsSql, statsParams, restScope, { from: "from_name", to: "to_name", fromNodeId: "from_node_id", toNodeId: "to_node_id" });
|
|
3655
3959
|
statsSql += " GROUP BY status";
|
|
3656
3960
|
const stats = db.all<any>(statsSql, ...statsParams);
|
|
3657
3961
|
return withCors(req, Response.json({ ok: true, tasks: rows, count: rows.length, stats }));
|