@sleep2agi/commhub-server 0.9.0-preview.66 → 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.
@@ -108,13 +108,14 @@ const N2 = nonAdminUserB.networkId;
108
108
  const N3 = seedUser.networkId;
109
109
  const N_FAKE = `net_503fake${STAMP}`;
110
110
 
111
- if (!addNetworkMember(N1, nonAdminUserB.userId, "member", nonAdminUserA.userId).ok) {
111
+ // 多用户 Agent 权限:新成员默认只看授权 Agent;本测试测的是「完全信任的成员」旧语义,显式 agent_access=all。
112
+ if (!addNetworkMember(N1, nonAdminUserB.userId, "member", nonAdminUserA.userId, { agentAccess: "all" }).ok) {
112
113
  throw new Error("fixture: userB → N1 member failed");
113
114
  }
114
115
  // The admin needs write access to N1 to be issued a node ntok_ there
115
116
  // (createNetworkTokenForNode refuses non-members and viewers). That
116
117
  // token is the D15 probe: a ntok_ ISSUED BY an admin.
117
- if (!addNetworkMember(N1, adminUser.userId, "member", nonAdminUserA.userId).ok) {
118
+ if (!addNetworkMember(N1, adminUser.userId, "member", nonAdminUserA.userId, { agentAccess: "all" }).ok) {
118
119
  throw new Error("fixture: admin → N1 member failed");
119
120
  }
120
121
  // viewerOnlyUser must hold exactly one network, with role viewer, so the
@@ -122,15 +123,15 @@ if (!addNetworkMember(N1, adminUser.userId, "member", nonAdminUserA.userId).ok)
122
123
  // reachable. register() makes everyone owner of a default network, so
123
124
  // that membership is dropped here.
124
125
  db.run("DELETE FROM network_members WHERE user_id = ?1", [viewerOnlyUser.userId]);
125
- if (!addNetworkMember(N1, viewerOnlyUser.userId, "viewer", nonAdminUserA.userId).ok) {
126
+ if (!addNetworkMember(N1, viewerOnlyUser.userId, "viewer", nonAdminUserA.userId, { agentAccess: "all" }).ok) {
126
127
  throw new Error("fixture: viewerOnly → N1 viewer failed");
127
128
  }
128
129
  // mixedUser can write in N1 but only read in N2 — U14 asserts that an
129
130
  // explicit param pointing at the viewer network is refused.
130
- if (!addNetworkMember(N1, mixedUser.userId, "member", nonAdminUserA.userId).ok) {
131
+ if (!addNetworkMember(N1, mixedUser.userId, "member", nonAdminUserA.userId, { agentAccess: "all" }).ok) {
131
132
  throw new Error("fixture: mixed → N1 member failed");
132
133
  }
133
- if (!addNetworkMember(N2, mixedUser.userId, "viewer", nonAdminUserB.userId).ok) {
134
+ if (!addNetworkMember(N2, mixedUser.userId, "viewer", nonAdminUserB.userId, { agentAccess: "all" }).ok) {
134
135
  throw new Error("fixture: mixed → N2 viewer failed");
135
136
  }
136
137
 
@@ -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
- const addAlice = addNetworkMember(mUserANet, mAliceId, "member", mUserAId);
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
@@ -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
+ }
@@ -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: { userId: string; networkId: string | null } | null, isAdmin: boolean): RestNetworkScope {
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
- const added = addNetworkMember(memberNetworkId, outsiderUserId, "member");
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(networkId: string): Response {
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(key: string, initialEvent: Record<string, unknown>): Response {
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");