@sleep2agi/commhub-server 0.9.0-preview.67 → 0.9.0-preview.68
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 +30 -0
- 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 +3 -0
- package/src/requirements.ts +103 -33
- 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 +98 -31
|
@@ -373,7 +373,8 @@ describe("f28a6c1b CR3 — network-scoped filter matrix (M1-M7)", () => {
|
|
|
373
373
|
mAliceNet = rAl.network_id;
|
|
374
374
|
db.run("UPDATE users SET role = 'user' WHERE username = ?1", [mAliceName]);
|
|
375
375
|
mAliceId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", [mAliceName])!.user_id;
|
|
376
|
-
|
|
376
|
+
// 多用户 Agent 权限:新成员默认只看授权 Agent;本测试测的是「完全信任的成员」旧语义,显式 agent_access=all。
|
|
377
|
+
const addAlice = addNetworkMember(mUserANet, mAliceId, "member", mUserAId, { agentAccess: "all" });
|
|
377
378
|
if (!addAlice.ok) throw new Error("addNetworkMember(alice→N_A) failed: " + addAlice.error);
|
|
378
379
|
|
|
379
380
|
// carol — own network + will also be added to N_A, then revoked
|
|
@@ -385,7 +386,7 @@ describe("f28a6c1b CR3 — network-scoped filter matrix (M1-M7)", () => {
|
|
|
385
386
|
mCarolNet = rC.network_id;
|
|
386
387
|
db.run("UPDATE users SET role = 'user' WHERE username = ?1", [mCarolName]);
|
|
387
388
|
mCarolId = db.get<{ user_id: string }>("SELECT user_id FROM users WHERE username = ?1", [mCarolName])!.user_id;
|
|
388
|
-
const addCarol = addNetworkMember(mUserANet, mCarolId, "member", mUserAId);
|
|
389
|
+
const addCarol = addNetworkMember(mUserANet, mCarolId, "member", mUserAId, { agentAccess: "all" });
|
|
389
390
|
if (!addCarol.ok) throw new Error("addNetworkMember(carol→N_A) failed: " + addCarol.error);
|
|
390
391
|
|
|
391
392
|
// Live SSE sessions — one per network — so the filter has real
|
package/src/human-dm.ts
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// 多用户账号:人与人私信(同一网络里的两个用户之间)。
|
|
2
|
+
//
|
|
3
|
+
// 投递复用 user_inbox(与 send_desktop_message 同一张表、同一条 /events/users/me 推送、同一套
|
|
4
|
+
// /api/messages?scope=user 未读与 ack),另外记下 sender_user_id —— 由 Hub 按发信人的用户令牌写入,
|
|
5
|
+
// 不是客户端声称的 from_session。会话记录按 (收件人, sender_user_id) 双向取,所以发信人看得到自己发的。
|
|
6
|
+
//
|
|
7
|
+
// 与 Agent 权限无关:受限成员(看不到任何 Agent 的成员)照样能和网络里的其他人私信。
|
|
8
|
+
|
|
9
|
+
import { db, uuidv4 } from "./db.js";
|
|
10
|
+
import { getUserNetworkRole } from "./auth.js";
|
|
11
|
+
import { isAgentRestricted } from "./agent-access.js";
|
|
12
|
+
import { pushUserEvent, hasUserSubscribers } from "./push.js";
|
|
13
|
+
import { existsSync, readFileSync } from "fs";
|
|
14
|
+
import { FILE_ID_REGEX, indexEntryPath, validateAttachments, validateIndexEntry } from "./uploads.js";
|
|
15
|
+
import { restrictedMemberCanUseFile } from "./restricted-files.js";
|
|
16
|
+
import { redactMessageRow } from "./redact-tokens.js";
|
|
17
|
+
|
|
18
|
+
export const HUMAN_DM_KIND = "human_dm";
|
|
19
|
+
const MAX_DM_CHARS = 10_000;
|
|
20
|
+
|
|
21
|
+
export type HumanDmSendInput = {
|
|
22
|
+
networkId: string;
|
|
23
|
+
sender: { userId: string; username: string };
|
|
24
|
+
toUserId?: unknown;
|
|
25
|
+
toUsername?: unknown;
|
|
26
|
+
message?: unknown;
|
|
27
|
+
attachments?: unknown;
|
|
28
|
+
clientRequestId?: unknown;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type HumanDmSendResult =
|
|
32
|
+
| { ok: true; message: Record<string, unknown>; delivered: boolean }
|
|
33
|
+
| { ok: false; error: string; status: number };
|
|
34
|
+
|
|
35
|
+
export function sendHumanDm(input: HumanDmSendInput): HumanDmSendResult {
|
|
36
|
+
const { networkId, sender } = input;
|
|
37
|
+
if (!getUserNetworkRole(sender.userId, networkId)) return { ok: false, error: "not a member of this network", status: 403 };
|
|
38
|
+
const text = typeof input.message === "string" ? input.message : "";
|
|
39
|
+
if (text.length > MAX_DM_CHARS) return { ok: false, error: "message_too_long", status: 400 };
|
|
40
|
+
|
|
41
|
+
const byId = typeof input.toUserId === "string" && input.toUserId
|
|
42
|
+
? db.get<{ user_id: string; username: string }>("SELECT user_id, username FROM users WHERE user_id = ?1", input.toUserId)
|
|
43
|
+
: null;
|
|
44
|
+
const byName = typeof input.toUsername === "string" && input.toUsername
|
|
45
|
+
? db.get<{ user_id: string; username: string }>("SELECT user_id, username FROM users WHERE username = ?1", input.toUsername)
|
|
46
|
+
: null;
|
|
47
|
+
if (byId && byName && byId.user_id !== byName.user_id) return { ok: false, error: "dm_target_mismatch", status: 400 };
|
|
48
|
+
const target = byId ?? byName;
|
|
49
|
+
// 不存在与不在本网络同一个错误,不给用户名探测留差异。
|
|
50
|
+
if (!target || !getUserNetworkRole(target.user_id, networkId)) return { ok: false, error: "dm_target_not_in_network", status: 404 };
|
|
51
|
+
if (target.user_id === sender.userId) return { ok: false, error: "dm_to_self", status: 400 };
|
|
52
|
+
|
|
53
|
+
const attachments = validateAttachments(input.attachments);
|
|
54
|
+
if (!attachments.ok) return { ok: false, error: "bad_attachments", status: 400 };
|
|
55
|
+
if (!text.trim() && attachments.attachments.length === 0) return { ok: false, error: "message_required", status: 400 };
|
|
56
|
+
const restricted = isAgentRestricted(sender.userId, networkId);
|
|
57
|
+
for (const a of attachments.attachments) {
|
|
58
|
+
const fileId = (a as { file_id?: string }).file_id;
|
|
59
|
+
if (!fileId) continue;
|
|
60
|
+
const usable = restricted
|
|
61
|
+
? restrictedMemberCanUseFile(sender.userId, sender.username, networkId, fileId)
|
|
62
|
+
: fileInNetwork(networkId, fileId);
|
|
63
|
+
if (!usable) return { ok: false, error: "attachment_not_accessible", status: 403 };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const clientRequestId = typeof input.clientRequestId === "string" && /^[A-Za-z0-9._-]{1,80}$/.test(input.clientRequestId)
|
|
67
|
+
? input.clientRequestId : null;
|
|
68
|
+
// 同一个气泡重试:按 (发信人, client_request_id) 定出同一个 message_id,重投不产生第二条。
|
|
69
|
+
const messageId = clientRequestId
|
|
70
|
+
? `dm_${sender.userId}_${clientRequestId}`.slice(0, 120)
|
|
71
|
+
: `dm_${uuidv4()}`;
|
|
72
|
+
const metaJson = attachments.attachments.length ? JSON.stringify({ attachments: attachments.attachments }) : null;
|
|
73
|
+
|
|
74
|
+
db.transaction(() => {
|
|
75
|
+
db.run(
|
|
76
|
+
// created_at 带毫秒:同一秒里一来一回的两条私信要能排出先后(秒级的 datetime('now') 排不出)。
|
|
77
|
+
`INSERT INTO user_inbox (message_id, network_id, user_id, from_session, kind, title, content, severity, meta_json, sender_user_id, created_at)
|
|
78
|
+
VALUES (?1, ?2, ?3, ?4, ?5, NULL, ?6, 'info', ?7, ?8, strftime('%Y-%m-%d %H:%M:%f', 'now'))
|
|
79
|
+
ON CONFLICT(message_id) DO NOTHING`,
|
|
80
|
+
[messageId, networkId, target.user_id, sender.username, HUMAN_DM_KIND, text, metaJson, sender.userId],
|
|
81
|
+
);
|
|
82
|
+
db.run(
|
|
83
|
+
`INSERT INTO audit_log (user_id, username, action, target_type, target_id, detail, network_id)
|
|
84
|
+
VALUES (?1, ?2, 'human_dm_sent', 'user', ?3, ?4, ?5)`,
|
|
85
|
+
[sender.userId, sender.username, target.user_id, JSON.stringify({ message_id: messageId, attachments: attachments.attachments.length }), networkId],
|
|
86
|
+
);
|
|
87
|
+
});
|
|
88
|
+
const row = db.get<Record<string, unknown>>(
|
|
89
|
+
`SELECT message_id, network_id, user_id, sender_user_id, from_session, kind, content, meta_json, acked, created_at
|
|
90
|
+
FROM user_inbox WHERE message_id = ?1`,
|
|
91
|
+
messageId,
|
|
92
|
+
)!;
|
|
93
|
+
// 推送在提交之后(与 send_desktop_message 同一条规矩:回滚的消息不能先被看见)。
|
|
94
|
+
const delivered = hasUserSubscribers(networkId, target.user_id);
|
|
95
|
+
pushUserEvent(networkId, target.user_id, {
|
|
96
|
+
type: "desktop_message",
|
|
97
|
+
message_id: messageId,
|
|
98
|
+
kind: HUMAN_DM_KIND,
|
|
99
|
+
from: sender.username,
|
|
100
|
+
from_user_id: sender.userId,
|
|
101
|
+
title: null,
|
|
102
|
+
message: text,
|
|
103
|
+
severity: "info",
|
|
104
|
+
created_at: new Date().toISOString(),
|
|
105
|
+
...(metaJson ? { meta: JSON.parse(metaJson) } : {}),
|
|
106
|
+
});
|
|
107
|
+
return { ok: true, message: redactMessageRow(row), delivered };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** 不受限的成员带附件:文件必须属于这个网络(防把别的网络的 file_id 塞进来)。 */
|
|
111
|
+
function fileInNetwork(networkId: string, fileId: string): boolean {
|
|
112
|
+
if (!FILE_ID_REGEX.test(fileId)) return false;
|
|
113
|
+
const path = indexEntryPath(fileId);
|
|
114
|
+
if (!path || !existsSync(path)) return false;
|
|
115
|
+
try {
|
|
116
|
+
const entry = JSON.parse(readFileSync(path, "utf8"));
|
|
117
|
+
return validateIndexEntry(entry) && entry.file_id === fileId && entry.network_id === networkId;
|
|
118
|
+
} catch { return false; }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** 我和某人的私信记录(双向),新的在前。before = 上一页最后一条的 created_at。 */
|
|
122
|
+
export function listDmThread(networkId: string, meUserId: string, otherUserId: string, limit = 50, before?: string | null) {
|
|
123
|
+
const params: unknown[] = [networkId, meUserId, otherUserId];
|
|
124
|
+
let sql = `SELECT message_id, network_id, user_id, sender_user_id, from_session, kind, content, meta_json, acked, created_at
|
|
125
|
+
FROM user_inbox
|
|
126
|
+
WHERE network_id = ?1 AND kind = '${HUMAN_DM_KIND}'
|
|
127
|
+
AND ((user_id = ?2 AND sender_user_id = ?3) OR (user_id = ?3 AND sender_user_id = ?2))`;
|
|
128
|
+
if (before) { params.push(before); sql += ` AND created_at < ?${params.length}`; }
|
|
129
|
+
params.push(Math.max(1, Math.min(limit, 200)));
|
|
130
|
+
sql += ` ORDER BY created_at DESC, rowid DESC LIMIT ?${params.length}`;
|
|
131
|
+
return db.all<Record<string, unknown>>(sql, ...params).map((row) => ({
|
|
132
|
+
...redactMessageRow(row),
|
|
133
|
+
direction: row.sender_user_id === meUserId ? "out" : "in",
|
|
134
|
+
}));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** 我在这个网络里的私信会话:每个对方一行,最后一条时间 + 我这边的未读数。 */
|
|
138
|
+
export function listDmThreads(networkId: string, meUserId: string) {
|
|
139
|
+
return db.all<{ other_user_id: string; last_at: string; unread: number }>(
|
|
140
|
+
`SELECT other_user_id, MAX(created_at) AS last_at, SUM(unread) AS unread FROM (
|
|
141
|
+
SELECT sender_user_id AS other_user_id, created_at, CASE WHEN acked = 0 THEN 1 ELSE 0 END AS unread
|
|
142
|
+
FROM user_inbox WHERE network_id = ?1 AND user_id = ?2 AND kind = '${HUMAN_DM_KIND}' AND sender_user_id IS NOT NULL
|
|
143
|
+
UNION ALL
|
|
144
|
+
SELECT user_id AS other_user_id, created_at, 0 AS unread
|
|
145
|
+
FROM user_inbox WHERE network_id = ?1 AND sender_user_id = ?2 AND kind = '${HUMAN_DM_KIND}'
|
|
146
|
+
) GROUP BY other_user_id ORDER BY last_at DESC`,
|
|
147
|
+
networkId, meUserId,
|
|
148
|
+
).map((r) => ({ other_user_id: r.other_user_id, last_at: r.last_at, unread: Number(r.unread ?? 0) }));
|
|
149
|
+
}
|
package/src/network-scope.ts
CHANGED
|
@@ -11,13 +11,33 @@
|
|
|
11
11
|
|
|
12
12
|
import { db } from "./db.js";
|
|
13
13
|
import { getUserNetworkRole } from "./auth.js";
|
|
14
|
+
import { isAgentRestricted, restrictedNetworkIds, usernameIsAgentAlias, visibleAgents } from "./agent-access.js";
|
|
14
15
|
|
|
15
16
|
export type RestNetworkScope = {
|
|
16
17
|
networkId: string | null;
|
|
17
18
|
networkIds: string[] | null;
|
|
18
19
|
denied?: string;
|
|
20
|
+
// 多用户 Agent 权限:scope 里哪些网络对这个调用者是「只看授权 Agent」的。
|
|
21
|
+
// addNetworkScope 对这些网络 fail-closed(整网不返回);要按授权放行的路径
|
|
22
|
+
// 显式改用 addAgentNetworkScope / addOwnTrafficScope,人类数据用 addHumanNetworkScope。
|
|
23
|
+
agentRestriction?: { userId: string; username: string; networkIds: string[] };
|
|
19
24
|
};
|
|
20
25
|
|
|
26
|
+
type ScopeAuthCtx = { userId: string; networkId: string | null; username?: string | null };
|
|
27
|
+
|
|
28
|
+
function usernameOf(userId: string, known?: string | null): string {
|
|
29
|
+
if (known) return known;
|
|
30
|
+
return db.get<{ username: string }>("SELECT username FROM users WHERE user_id = ?1", userId)?.username ?? "";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function withAgentRestriction(scope: RestNetworkScope, authCtx: ScopeAuthCtx): RestNetworkScope {
|
|
34
|
+
const inScope = scope.networkId ? [scope.networkId] : (scope.networkIds ?? []);
|
|
35
|
+
if (inScope.length === 0) return scope;
|
|
36
|
+
const restricted = restrictedNetworkIds(authCtx.userId).filter((id) => inScope.includes(id));
|
|
37
|
+
if (restricted.length === 0) return scope;
|
|
38
|
+
return { ...scope, agentRestriction: { userId: authCtx.userId, username: usernameOf(authCtx.userId, authCtx.username), networkIds: restricted } };
|
|
39
|
+
}
|
|
40
|
+
|
|
21
41
|
export function getUserNetworkIds(userId: string): string[] {
|
|
22
42
|
// JOIN networks: deleteNetwork historically left network_members rows
|
|
23
43
|
// behind (fixed alongside PR #519, but legacy DBs still carry strays).
|
|
@@ -31,7 +51,7 @@ export function getUserNetworkIds(userId: string): string[] {
|
|
|
31
51
|
).map((row) => row.network_id);
|
|
32
52
|
}
|
|
33
53
|
|
|
34
|
-
export function resolveRestNetworkScope(requested: string | null, authCtx:
|
|
54
|
+
export function resolveRestNetworkScope(requested: string | null, authCtx: ScopeAuthCtx | null, isAdmin: boolean): RestNetworkScope {
|
|
35
55
|
// Legacy global token or open dev mode keeps the old global behavior.
|
|
36
56
|
if (!authCtx) return { networkId: requested || null, networkIds: null };
|
|
37
57
|
|
|
@@ -44,13 +64,41 @@ export function resolveRestNetworkScope(requested: string | null, authCtx: { use
|
|
|
44
64
|
if (requested) {
|
|
45
65
|
const role = getUserNetworkRole(authCtx.userId, requested);
|
|
46
66
|
if (!role) return { networkId: null, networkIds: [], denied: "access denied to requested network" };
|
|
47
|
-
return { networkId: requested, networkIds: null };
|
|
67
|
+
return withAgentRestriction({ networkId: requested, networkIds: null }, authCtx);
|
|
48
68
|
}
|
|
49
69
|
|
|
50
|
-
return { networkId: null, networkIds: getUserNetworkIds(authCtx.userId) };
|
|
70
|
+
return withAgentRestriction({ networkId: null, networkIds: getUserNetworkIds(authCtx.userId) }, authCtx);
|
|
51
71
|
}
|
|
52
72
|
|
|
73
|
+
function unrestrictedNetworks(scope: RestNetworkScope): string[] | null {
|
|
74
|
+
const excluded = scope.agentRestriction?.networkIds ?? [];
|
|
75
|
+
if (scope.networkId) return excluded.includes(scope.networkId) ? [] : [scope.networkId];
|
|
76
|
+
if (scope.networkIds) return scope.networkIds.filter((id) => !excluded.includes(id));
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function placeholders(params: any[], values: string[]): string {
|
|
81
|
+
const start = params.length;
|
|
82
|
+
params.push(...values);
|
|
83
|
+
return values.map((_, i) => `?${start + i + 1}`).join(", ");
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* 默认的网络作用域 —— 对受限成员的受限网络 **fail-closed**:整网不返回。
|
|
88
|
+
* 没被逐条审过、不知道按授权怎么放行的查询,默认走这里,受限成员什么都看不到。
|
|
89
|
+
*/
|
|
53
90
|
export function addNetworkScope(sql: string, params: any[], scope: RestNetworkScope, column = "network_id"): string {
|
|
91
|
+
if (!scope.agentRestriction) return addHumanNetworkScope(sql, params, scope, column);
|
|
92
|
+
const allowed = unrestrictedNetworks(scope) ?? [];
|
|
93
|
+
if (allowed.length === 0) return `${sql} AND 1=0`;
|
|
94
|
+
return `${sql} AND ${column} IN (${placeholders(params, allowed)})`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 人类数据(自己的私信收件箱、需求看板、项目、网络成员)的作用域:受限成员照常看得到
|
|
99
|
+
* 自己所在网络。只给已确认不含 Agent 内容的查询用。
|
|
100
|
+
*/
|
|
101
|
+
export function addHumanNetworkScope(sql: string, params: any[], scope: RestNetworkScope, column = "network_id"): string {
|
|
54
102
|
if (scope.networkId) {
|
|
55
103
|
sql += ` AND ${column} = ?${params.length + 1}`;
|
|
56
104
|
params.push(scope.networkId);
|
|
@@ -66,6 +114,69 @@ export function addNetworkScope(sql: string, params: any[], scope: RestNetworkSc
|
|
|
66
114
|
return sql;
|
|
67
115
|
}
|
|
68
116
|
|
|
117
|
+
type AgentColumns = { network?: string; alias: string; nodeId?: string };
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Agent 行(sessions / nodes)的作用域:不受限的网络整网放行;受限网络只放行授权的 Agent。
|
|
121
|
+
* mode="message" 时只放行 can_message 的授权。
|
|
122
|
+
*/
|
|
123
|
+
export function addAgentNetworkScope(sql: string, params: any[], scope: RestNetworkScope, cols: AgentColumns, mode: "see" | "message" = "see"): string {
|
|
124
|
+
const restriction = scope.agentRestriction;
|
|
125
|
+
if (!restriction) return addHumanNetworkScope(sql, params, scope, cols.network ?? "network_id");
|
|
126
|
+
const net = cols.network ?? "network_id";
|
|
127
|
+
const parts: string[] = [];
|
|
128
|
+
const allowed = unrestrictedNetworks(scope) ?? [];
|
|
129
|
+
if (allowed.length) parts.push(`${net} IN (${placeholders(params, allowed)})`);
|
|
130
|
+
for (const networkId of restriction.networkIds) {
|
|
131
|
+
const visible = visibleAgents(restriction.userId, networkId);
|
|
132
|
+
const aliases = mode === "message" ? visible.messageAliases : visible.aliases;
|
|
133
|
+
const nodeIds = mode === "message" ? visible.messageNodeIds : visible.nodeIds;
|
|
134
|
+
const conds: string[] = [];
|
|
135
|
+
if (aliases.length) conds.push(`${cols.alias} IN (${placeholders(params, aliases)})`);
|
|
136
|
+
if (cols.nodeId && nodeIds.length) conds.push(`${cols.nodeId} IN (${placeholders(params, nodeIds)})`);
|
|
137
|
+
if (conds.length === 0) continue;
|
|
138
|
+
parts.push(`(${net} = ${placeholders(params, [networkId])} AND (${conds.join(" OR ")}))`);
|
|
139
|
+
}
|
|
140
|
+
return parts.length ? `${sql} AND (${parts.join(" OR ")})` : `${sql} AND 1=0`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
type TrafficColumns = { network?: string; from: string; to: string; fromNodeId?: string; toNodeId?: string };
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* 任务 / inbox 这类「谁发给谁」的行:受限网络里只放行**自己**与**授权 Agent**之间的往来
|
|
147
|
+
* (from=自己 且 to=授权 Agent,或反过来)。别人(包括 owner)和同一个 Agent 的对话看不到。
|
|
148
|
+
*/
|
|
149
|
+
export function addOwnTrafficScope(sql: string, params: any[], scope: RestNetworkScope, cols: TrafficColumns): string {
|
|
150
|
+
const restriction = scope.agentRestriction;
|
|
151
|
+
if (!restriction) return addHumanNetworkScope(sql, params, scope, cols.network ?? "network_id");
|
|
152
|
+
const net = cols.network ?? "network_id";
|
|
153
|
+
const parts: string[] = [];
|
|
154
|
+
const allowed = unrestrictedNetworks(scope) ?? [];
|
|
155
|
+
if (allowed.length) parts.push(`${net} IN (${placeholders(params, allowed)})`);
|
|
156
|
+
if (restriction.username) {
|
|
157
|
+
for (const networkId of restriction.networkIds) {
|
|
158
|
+
// 用户名与本网络某个 Agent 的 alias 撞名时(入网时拦过,但 Agent 之后注册的 alias 拦不住),
|
|
159
|
+
// 「发给我的」无法和「发给那个 Agent 的」区分开 —— 这个网络的往来一律不放行。
|
|
160
|
+
if (usernameIsAgentAlias(networkId, restriction.username)) continue;
|
|
161
|
+
const visible = visibleAgents(restriction.userId, networkId);
|
|
162
|
+
// 先判空再生成占位符:side() 会往 params 里推值,推了又丢弃会让参数个数对不上。
|
|
163
|
+
const useNodeIds = !!(cols.fromNodeId && cols.toNodeId) && visible.nodeIds.length > 0;
|
|
164
|
+
if (visible.aliases.length === 0 && !useNodeIds) continue;
|
|
165
|
+
const side = (aliasCol: string, nodeCol?: string): string | null => {
|
|
166
|
+
const conds: string[] = [];
|
|
167
|
+
if (visible.aliases.length) conds.push(`${aliasCol} IN (${placeholders(params, visible.aliases)})`);
|
|
168
|
+
if (nodeCol && useNodeIds) conds.push(`${nodeCol} IN (${placeholders(params, visible.nodeIds)})`);
|
|
169
|
+
return conds.length ? `(${conds.join(" OR ")})` : null;
|
|
170
|
+
};
|
|
171
|
+
const toAgent = side(cols.to, cols.toNodeId)!;
|
|
172
|
+
const fromAgent = side(cols.from, cols.fromNodeId)!;
|
|
173
|
+
const me = placeholders(params, [restriction.username]);
|
|
174
|
+
parts.push(`(${net} = ${placeholders(params, [networkId])} AND ((${cols.from} = ${me} AND ${toAgent}) OR (${cols.to} = ${me} AND ${fromAgent})))`);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return parts.length ? `${sql} AND (${parts.join(" OR ")})` : `${sql} AND 1=0`;
|
|
178
|
+
}
|
|
179
|
+
|
|
69
180
|
export function singleNetworkId(scope: RestNetworkScope): string | null {
|
|
70
181
|
if (scope.networkId) return scope.networkId;
|
|
71
182
|
if (scope.networkIds?.length === 1) return scope.networkIds[0];
|
|
@@ -92,7 +203,19 @@ export function resolveRestWriteNetworkId(
|
|
|
92
203
|
return memberships.length === 1 ? memberships[0] : null;
|
|
93
204
|
}
|
|
94
205
|
|
|
206
|
+
/**
|
|
207
|
+
* 能不能往这个网络写**面向 Agent 的东西**(派任务、改节点、排程……)。
|
|
208
|
+
* 受限成员在受限网络里恒为 false(fail-closed);给授权 Agent 发任务的路径另用
|
|
209
|
+
* canMessageAgent 单独放行,人类侧的写入(私信、需求、上传)用 canRestWriteNetworkAsHuman。
|
|
210
|
+
*/
|
|
95
211
|
export function canRestWriteNetwork(authCtx: { userId: string; networkId: string | null } | null, networkId: string | null, isAdmin: boolean): boolean {
|
|
212
|
+
if (!canRestWriteNetworkAsHuman(authCtx, networkId, isAdmin)) return false;
|
|
213
|
+
if (!authCtx || isAdmin || !networkId) return true;
|
|
214
|
+
return !isAgentRestricted(authCtx.userId, networkId);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** 旧的「成员且不是 viewer」判据,不看 Agent 授权。只给人类侧写入用。 */
|
|
218
|
+
export function canRestWriteNetworkAsHuman(authCtx: { userId: string; networkId: string | null } | null, networkId: string | null, isAdmin: boolean): boolean {
|
|
96
219
|
if (!authCtx) return true; // legacy global token or open dev mode
|
|
97
220
|
if (isAdmin) return true;
|
|
98
221
|
if (!networkId) return false;
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// 节点令牌不继承签发者的账号权限与 Hub 管理员身份。
|
|
2
|
+
//
|
|
3
|
+
// 生产上几乎所有节点令牌(ntok_)都由管理员签发,resolveToken 把它解析成那个管理员用户。
|
|
4
|
+
// 以前一部分端点直接看 `resolved.user.role === "admin"` 或把令牌当成用户本人,于是任何一个节点
|
|
5
|
+
// 都能:列全部用户、读服务端日志与审计日志、给签发者再签用户令牌 / 节点令牌、改签发者资料与密码、
|
|
6
|
+
// 建 / 改 / 删网络、管成员与邀请、读别的网络详情、看全部网络的 SSE 拓扑。
|
|
7
|
+
// 这里每一条在修复前(main)都是红的;管理员自己的用户令牌作为正控,照常可用。
|
|
8
|
+
|
|
9
|
+
import { describe, expect, test, beforeAll, afterAll } from "bun:test";
|
|
10
|
+
import { mkdtempSync, rmSync } from "fs";
|
|
11
|
+
import { join } from "path";
|
|
12
|
+
import { tmpdir } from "os";
|
|
13
|
+
import { register, createNetwork, createNetworkTokenForNode } from "./auth.js";
|
|
14
|
+
import { db } from "./db.js";
|
|
15
|
+
|
|
16
|
+
const DIR = mkdtempSync(join(tmpdir(), "anet-ntok-priv-"));
|
|
17
|
+
let BASE = "";
|
|
18
|
+
let hub: any = null;
|
|
19
|
+
const PW = "NtokPrivPassw0rd!";
|
|
20
|
+
let adminToken = "", adminId = "", NET = "", OTHER_NET = "", ntok = "";
|
|
21
|
+
|
|
22
|
+
const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
|
|
23
|
+
async function call(token: string, method: string, path: string, body?: unknown) {
|
|
24
|
+
const res = await fetch(`${BASE}${path}`, {
|
|
25
|
+
method,
|
|
26
|
+
headers: { ...auth(token), ...(body === undefined ? {} : { "Content-Type": "application/json" }) },
|
|
27
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
28
|
+
});
|
|
29
|
+
return { status: res.status, body: await res.json().catch(() => null) as any };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
beforeAll(async () => {
|
|
33
|
+
process.env.COMMHUB_DB ||= join(DIR, "hub.db");
|
|
34
|
+
process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
|
|
35
|
+
process.env.HOST = "127.0.0.1";
|
|
36
|
+
const admin = register(`ntok_admin_${Date.now()}`, PW);
|
|
37
|
+
adminToken = admin.token!; adminId = admin.user!.user_id; NET = admin.network_id!;
|
|
38
|
+
db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [adminId]);
|
|
39
|
+
const other = createNetwork(adminId, "ntok-other");
|
|
40
|
+
OTHER_NET = (other as any).network_id;
|
|
41
|
+
const minted = createNetworkTokenForNode(adminId, NET, "ntok-node", "node_ntok_priv");
|
|
42
|
+
expect(minted.ok).toBe(true);
|
|
43
|
+
ntok = minted.token!;
|
|
44
|
+
register(`ntok_someone_${Date.now()}`, PW);
|
|
45
|
+
const mod: any = await import("./server.js");
|
|
46
|
+
hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
|
|
47
|
+
BASE = `http://127.0.0.1:${hub.port}`;
|
|
48
|
+
}, 30_000);
|
|
49
|
+
|
|
50
|
+
afterAll(() => {
|
|
51
|
+
try { hub?.stop?.(true); } catch {}
|
|
52
|
+
try { rmSync(DIR, { recursive: true, force: true }); } catch {}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("hub-admin powers need a user token", () => {
|
|
56
|
+
test("GET /api/users", async () => {
|
|
57
|
+
expect((await call(ntok, "GET", "/api/users")).status).toBe(403);
|
|
58
|
+
expect((await call(adminToken, "GET", "/api/users")).status).toBe(200);
|
|
59
|
+
});
|
|
60
|
+
test("GET /api/server-logs", async () => {
|
|
61
|
+
expect((await call(ntok, "GET", "/api/server-logs")).status).toBe(403);
|
|
62
|
+
expect((await call(adminToken, "GET", "/api/server-logs")).status).toBe(200);
|
|
63
|
+
});
|
|
64
|
+
test("GET /api/audit-log", async () => {
|
|
65
|
+
expect((await call(ntok, "GET", "/api/audit-log")).status).toBe(403);
|
|
66
|
+
expect((await call(adminToken, "GET", "/api/audit-log")).status).toBe(200);
|
|
67
|
+
});
|
|
68
|
+
test("GET /api/networks/:id of a network the token is not bound to", async () => {
|
|
69
|
+
expect((await call(ntok, "GET", `/api/networks/${OTHER_NET}`)).status).toBe(403);
|
|
70
|
+
expect((await call(ntok, "GET", `/api/networks/${NET}`)).status).toBe(200);
|
|
71
|
+
});
|
|
72
|
+
test("GET /api/stats/sse is limited to the bound network", async () => {
|
|
73
|
+
const ctrl = new AbortController();
|
|
74
|
+
const sub = await fetch(`${BASE}/events/some-agent?network_id=${OTHER_NET}`, { headers: auth(adminToken), signal: ctrl.signal }).catch(() => null);
|
|
75
|
+
const r = await call(ntok, "GET", "/api/stats/sse");
|
|
76
|
+
ctrl.abort();
|
|
77
|
+
expect(r.status).toBe(200);
|
|
78
|
+
expect(Object.keys(r.body.sessions).every((k) => k.split(":").includes(NET))).toBe(true);
|
|
79
|
+
expect(JSON.stringify(r.body)).not.toContain(OTHER_NET);
|
|
80
|
+
void sub;
|
|
81
|
+
});
|
|
82
|
+
test("POST /api/admin/users and agent-grants", async () => {
|
|
83
|
+
expect((await call(ntok, "POST", "/api/admin/users", { username: "ntok_made", password: PW })).status).toBe(403);
|
|
84
|
+
expect((await call(ntok, "GET", `/api/networks/${NET}/members/${adminId}/agent-grants`)).status).toBe(403);
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
describe("account and network management need a user token", () => {
|
|
89
|
+
test("token management: cannot list, mint user tokens, mint node tokens or revoke", async () => {
|
|
90
|
+
expect((await call(ntok, "GET", "/api/auth/tokens")).status).toBe(403);
|
|
91
|
+
const minted = await call(ntok, "POST", "/api/auth/tokens", { name: "escalate" });
|
|
92
|
+
expect(minted.status).toBe(403);
|
|
93
|
+
expect(minted.body.token).toBeUndefined();
|
|
94
|
+
expect((await call(ntok, "POST", "/api/auth/node-token", { network_id: OTHER_NET, node_name: "x" })).status).toBe(403);
|
|
95
|
+
expect((await call(ntok, "DELETE", "/api/auth/tokens/tok_whatever")).status).toBe(403);
|
|
96
|
+
expect((await call(adminToken, "GET", "/api/auth/tokens")).status).toBe(200);
|
|
97
|
+
});
|
|
98
|
+
test("cannot edit the owner's profile or password", async () => {
|
|
99
|
+
expect((await call(ntok, "PUT", "/api/auth/me", { display_name: "pwned" })).status).toBe(403);
|
|
100
|
+
expect(db.get<{ display_name: string }>("SELECT display_name FROM users WHERE user_id = ?1", adminId)?.display_name).not.toBe("pwned");
|
|
101
|
+
expect((await call(ntok, "POST", "/api/auth/password", { old_password: PW, new_password: "AnotherPassw0rd!" })).status).toBe(403);
|
|
102
|
+
});
|
|
103
|
+
test("cannot create, rename or delete networks", async () => {
|
|
104
|
+
expect((await call(ntok, "POST", "/api/networks", { name: "ntok-made" })).status).toBe(403);
|
|
105
|
+
expect((await call(ntok, "PUT", `/api/networks/${OTHER_NET}`, { name: "renamed" })).status).toBe(403);
|
|
106
|
+
expect((await call(ntok, "DELETE", `/api/networks/${OTHER_NET}`)).status).toBe(403);
|
|
107
|
+
expect(db.get("SELECT 1 FROM networks WHERE network_id = ?1", OTHER_NET)).toBeTruthy();
|
|
108
|
+
});
|
|
109
|
+
test("cannot manage members, invites or join", async () => {
|
|
110
|
+
expect((await call(ntok, "GET", `/api/networks/${NET}/members`)).status).toBe(403);
|
|
111
|
+
expect((await call(ntok, "POST", `/api/networks/${NET}/members`, { user_id: "u_x" })).status).toBe(403);
|
|
112
|
+
expect((await call(ntok, "POST", `/api/networks/${NET}/invite`, { role: "admin" })).status).toBe(403);
|
|
113
|
+
expect((await call(ntok, "POST", "/api/networks/join", { invite_code: "inv_x" })).status).toBe(403);
|
|
114
|
+
expect((await call(adminToken, "GET", `/api/networks/${NET}/members`)).status).toBe(200);
|
|
115
|
+
});
|
|
116
|
+
test("renames: a node token may only rename ITSELF in its bound network", async () => {
|
|
117
|
+
// 别的网络 / 别的节点 → user_token_required
|
|
118
|
+
expect((await call(ntok, "POST", "/api/node-rename/prepare", { network_id: OTHER_NET, old_alias: "ntok-node", new_alias: "b" })).status).toBe(403);
|
|
119
|
+
db.run(`INSERT INTO sessions (resume_id, alias, status, network_id) VALUES ('r_other_node', 'other-node', 'idle', ?1)`, [NET]);
|
|
120
|
+
expect((await call(ntok, "POST", "/api/node-rename/prepare", { network_id: NET, old_alias: "other-node", new_alias: "b" })).status).toBe(403);
|
|
121
|
+
// 另一个节点(管理员用户令牌)开的事务,本节点不能提交
|
|
122
|
+
const foreign = await call(adminToken, "POST", "/api/node-rename/prepare", { network_id: NET, old_alias: "other-node", new_alias: "other-node-2" });
|
|
123
|
+
expect(foreign.body.ok).toBe(true);
|
|
124
|
+
expect((await call(ntok, "POST", "/api/node-rename/commit", { txn_id: foreign.body.txn_id })).status).toBe(403);
|
|
125
|
+
expect((await call(adminToken, "POST", "/api/node-rename/abort", { txn_id: foreign.body.txn_id })).body.ok).toBe(true);
|
|
126
|
+
// 自己:照常(anet node rename 就是这么用的)
|
|
127
|
+
db.run(`INSERT INTO sessions (resume_id, alias, status, network_id) VALUES ('r_self_node', 'ntok-node', 'idle', ?1)`, [NET]);
|
|
128
|
+
const mine = await call(ntok, "POST", "/api/node-rename/prepare", { network_id: NET, old_alias: "ntok-node", new_alias: "ntok-node-2" });
|
|
129
|
+
expect(mine.body.ok).toBe(true);
|
|
130
|
+
expect((await call(ntok, "POST", "/api/node-rename/abort", { txn_id: mine.body.txn_id })).body.ok).toBe(true);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("/api/auth/me says what the credential is", () => {
|
|
135
|
+
test("node token: owner user for compatibility, but credential.kind=node and only the bound network", async () => {
|
|
136
|
+
const r = await call(ntok, "GET", "/api/auth/me");
|
|
137
|
+
expect(r.status).toBe(200);
|
|
138
|
+
expect(r.body.credential).toMatchObject({ kind: "node", network_id: NET, node_alias: "ntok-node", acts_as_owner: false });
|
|
139
|
+
expect(r.body.current_network).toBe(NET);
|
|
140
|
+
expect(r.body.networks.map((n: any) => n.network_id)).toEqual([NET]);
|
|
141
|
+
const u = await call(adminToken, "GET", "/api/auth/me");
|
|
142
|
+
expect(u.body.credential).toEqual({ kind: "user" });
|
|
143
|
+
expect(u.body.networks.map((n: any) => n.network_id).sort()).toEqual([NET, OTHER_NET].sort());
|
|
144
|
+
});
|
|
145
|
+
test("node token keeps its own network powers (still a member): status and tasks read fine", async () => {
|
|
146
|
+
expect((await call(ntok, "GET", "/api/status")).status).toBe(200);
|
|
147
|
+
expect((await call(ntok, "GET", "/api/tasks")).status).toBe(200);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -166,7 +166,8 @@ describe("#461 GET /events/network/:id — auth", () => {
|
|
|
166
166
|
// not catch this: its authCtx.networkId is null, so only ntok
|
|
167
167
|
// exercises the vulnerable branch — verified by stubbing the OR
|
|
168
168
|
// back in: THIS test goes red, the utok variants stay green).
|
|
169
|
-
|
|
169
|
+
// 多用户 Agent 权限:新成员默认只看授权 Agent;本测试测的是「完全信任的成员」旧语义,显式 agent_access=all。
|
|
170
|
+
const added = addNetworkMember(memberNetworkId, outsiderUserId, "member", undefined, { agentAccess: "all" });
|
|
170
171
|
expect(added.ok).toBe(true);
|
|
171
172
|
const minted = createNetworkTokenForNode(outsiderUserId, memberNetworkId, "revocation-probe");
|
|
172
173
|
expect(minted.ok).toBe(true);
|
package/src/push.ts
CHANGED
|
@@ -84,6 +84,10 @@ type SSEClient = {
|
|
|
84
84
|
keepaliveTimer?: ReturnType<typeof setInterval>;
|
|
85
85
|
/** Stable key for log/diagnostic context. */
|
|
86
86
|
key: string;
|
|
87
|
+
/** 多用户 Agent 权限:订阅者的 user_id(有的话),权限变化时据此断开重连重新鉴权。 */
|
|
88
|
+
subscriberUserId?: string;
|
|
89
|
+
/** 只推满足条件的事件(受限成员的网络观察流用)。缺省 = 全推。 */
|
|
90
|
+
eventFilter?: (event: Record<string, unknown>) => boolean;
|
|
87
91
|
};
|
|
88
92
|
|
|
89
93
|
// 一个 session 可能有多个 SSE 连接(重连时短暂并存)
|
|
@@ -263,10 +267,14 @@ export function createSSEStream(sessionName: string, networkId?: string | null):
|
|
|
263
267
|
/** #461 — 创建网络级观察者 SSE Response(dashboard 观察第三方流量)。
|
|
264
268
|
* Shares the exact same registration / backpressure / liveness
|
|
265
269
|
* machinery as session streams; only the key scheme differs. */
|
|
266
|
-
export function createNetworkObserverStream(
|
|
270
|
+
export function createNetworkObserverStream(
|
|
271
|
+
networkId: string,
|
|
272
|
+
opts: { subscriberUserId?: string; eventFilter?: (event: Record<string, unknown>) => boolean } = {},
|
|
273
|
+
): Response {
|
|
267
274
|
return createStreamForKey(
|
|
268
275
|
observerKey(networkId),
|
|
269
276
|
{ type: "connected", observer: true, network_id: networkId },
|
|
277
|
+
opts,
|
|
270
278
|
);
|
|
271
279
|
}
|
|
272
280
|
|
|
@@ -276,10 +284,15 @@ export function createUserEventStream(networkId: string, userId: string): Respon
|
|
|
276
284
|
return createStreamForKey(
|
|
277
285
|
userKey(networkId, userId),
|
|
278
286
|
{ type: "connected", user: true, network_id: networkId, user_id: userId },
|
|
287
|
+
{ subscriberUserId: userId },
|
|
279
288
|
);
|
|
280
289
|
}
|
|
281
290
|
|
|
282
|
-
function createStreamForKey(
|
|
291
|
+
function createStreamForKey(
|
|
292
|
+
key: string,
|
|
293
|
+
initialEvent: Record<string, unknown>,
|
|
294
|
+
opts: { subscriberUserId?: string; eventFilter?: (event: Record<string, unknown>) => boolean } = {},
|
|
295
|
+
): Response {
|
|
283
296
|
const encoder = new TextEncoder();
|
|
284
297
|
let client: SSEClient;
|
|
285
298
|
|
|
@@ -291,6 +304,8 @@ function createStreamForKey(key: string, initialEvent: Record<string, unknown>):
|
|
|
291
304
|
stuckSince: null,
|
|
292
305
|
closed: false,
|
|
293
306
|
key,
|
|
307
|
+
...(opts.subscriberUserId ? { subscriberUserId: opts.subscriberUserId } : {}),
|
|
308
|
+
...(opts.eventFilter ? { eventFilter: opts.eventFilter } : {}),
|
|
294
309
|
};
|
|
295
310
|
|
|
296
311
|
if (!clients.has(key)) clients.set(key, []);
|
|
@@ -454,6 +469,11 @@ export function pushNetworkObserverEvent(
|
|
|
454
469
|
needPrune = true;
|
|
455
470
|
continue;
|
|
456
471
|
}
|
|
472
|
+
if (c.eventFilter) {
|
|
473
|
+
let pass = false;
|
|
474
|
+
try { pass = c.eventFilter(event); } catch { pass = false; }
|
|
475
|
+
if (!pass) continue;
|
|
476
|
+
}
|
|
457
477
|
const result = tryEnqueueBytes(c, c.encoder.encode(data));
|
|
458
478
|
if (result === "dead") needPrune = true;
|
|
459
479
|
}
|
|
@@ -503,6 +523,27 @@ export function pushUserEvent(
|
|
|
503
523
|
if (needPrune) pruneClosed(key);
|
|
504
524
|
}
|
|
505
525
|
|
|
526
|
+
/**
|
|
527
|
+
* 多用户 Agent 权限:某用户在某网络的授权 / 成员资格变了,断开他在这个网络里**订阅时带了身份**
|
|
528
|
+
* 的流(网络观察流、用户流),客户端重连时按新权限重新鉴权。返回断开的条数。
|
|
529
|
+
* Agent 频道(/events/<alias>)受限成员本来就订阅不了,不需要这里处理。
|
|
530
|
+
*/
|
|
531
|
+
export function closeUserStreamsInNetwork(networkId: string, userId: string): number {
|
|
532
|
+
let closed = 0;
|
|
533
|
+
for (const key of [observerKey(networkId), userKey(networkId, userId)]) {
|
|
534
|
+
const arr = clients.get(key);
|
|
535
|
+
if (!arr) continue;
|
|
536
|
+
for (const c of arr) {
|
|
537
|
+
if (!c.closed && c.subscriberUserId === userId) {
|
|
538
|
+
closeClient(c, "access-changed");
|
|
539
|
+
closed++;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
pruneClosed(key);
|
|
543
|
+
}
|
|
544
|
+
return closed;
|
|
545
|
+
}
|
|
546
|
+
|
|
506
547
|
export function __resetSSEClientsForTest(): void {
|
|
507
548
|
for (const arr of clients.values()) {
|
|
508
549
|
for (const c of arr) closeClient(c, "reset-for-test");
|
package/src/rename.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import { randomUUID } from "crypto";
|
|
16
16
|
import { db } from "./db";
|
|
17
17
|
import { getUserNetworkRole, getNetworkMembers } from "./auth";
|
|
18
|
+
import { canSeeAgent, isAgentRestricted } from "./agent-access";
|
|
18
19
|
import { pushEvent } from "./push";
|
|
19
20
|
import { eventBus } from "./event_bus";
|
|
20
21
|
|
|
@@ -35,7 +36,8 @@ export interface CanonicalAlias {
|
|
|
35
36
|
|
|
36
37
|
function hasWriteAccess(userId: string, networkId: string): boolean {
|
|
37
38
|
const role = getUserNetworkRole(userId, networkId);
|
|
38
|
-
|
|
39
|
+
// 改名是对 Agent 的写操作;受限成员(只看授权 Agent 的成员)不能改。
|
|
40
|
+
return !!role && role !== "viewer" && !isAgentRestricted(userId, networkId);
|
|
39
41
|
}
|
|
40
42
|
|
|
41
43
|
// Resolve committed alias renames (old -> new), following short chains such
|
|
@@ -214,8 +216,11 @@ export function commitRename(userId: string, txnId: string): RenameResult {
|
|
|
214
216
|
// without this it would never receive node.renamed (#84 SSE channel fix:
|
|
215
217
|
// N站马 confirmed the dashboard listens on the user channel). All members get
|
|
216
218
|
// it, not just the owner, since any member's dashboard should reflect the rename.
|
|
219
|
+
// 多用户 Agent 权限:受限成员只收到他被授权的那个 Agent 的改名(否则等于把 alias 广播给他)。
|
|
217
220
|
for (const member of getNetworkMembers(txn.network_id)) {
|
|
218
|
-
if (member.username)
|
|
221
|
+
if (!member.username) continue;
|
|
222
|
+
if (!canSeeAgent(member.user_id, txn.network_id, { alias: txn.new_alias, nodeId: renamedNode?.node_id ?? null })) continue;
|
|
223
|
+
pushEvent(member.username, renamedEvent, txn.network_id);
|
|
219
224
|
}
|
|
220
225
|
|
|
221
226
|
return { ok: true, txn_id: txnId };
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Small, network-local labels; count Unicode code points rather than UTF-16 units. */
|
|
2
|
+
export function normalizeTags(value: unknown): string[] | null {
|
|
3
|
+
if (!Array.isArray(value) || value.length > 10) return null;
|
|
4
|
+
const tags: string[] = [];
|
|
5
|
+
for (const valueTag of value) {
|
|
6
|
+
if (typeof valueTag !== "string") return null;
|
|
7
|
+
const tag = valueTag.trim();
|
|
8
|
+
if (!tag || [...tag].length > 20 || /[\u0000-\u001f\u007f]/u.test(tag)) return null;
|
|
9
|
+
if (!tags.includes(tag)) tags.push(tag);
|
|
10
|
+
}
|
|
11
|
+
return tags;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function storedTags(value: string | null): string[] {
|
|
15
|
+
try { return normalizeTags(JSON.parse(value || "[]")) ?? []; } catch { return []; }
|
|
16
|
+
}
|