@sleep2agi/commhub-server 0.9.0-preview.73 → 0.9.0-preview.74

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.73",
3
+ "version": "0.9.0-preview.74",
4
4
  "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and MCP tools (17 collaboration-core + node/provider ops tools; authoritative list: docs-site/docs/api/mcp-tools.md).",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -344,6 +344,8 @@ describe("granted member sees and messages ONLY what was granted", () => {
344
344
  describe("requirements: agent references alice cannot see are hidden and protected", () => {
345
345
  let reqId = "";
346
346
  test("a card owned by Z shows agent_owner=null to alice, Z to admin", async () => {
347
+ // 这一组测的是「看不见的 Agent 引用被隐去、不能被改」,不是任务范围:alice 按 RFC-038 §9 之前的语义能改全网的卡。
348
+ db.run("UPDATE network_members SET task_access = 'all' WHERE network_id = ?1 AND user_id = ?2", [NET, aliceId]);
347
349
  const c = await send(adminToken, "POST", "/api/requirements", { name: "card", network_id: NET, agent_owner: { kind: "node", id: Z.node }, participants: [{ kind: "node", id: Z.node }, { kind: "user", id: aliceId }] });
348
350
  expect(c.status).toBe(201);
349
351
  reqId = c.body.requirement.id;
package/src/auth.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  import { db, generateId, hashPassword, verifyPassword, hashToken, generateToken, generateUserToken, generateNetworkToken, uuidv4 } from "./db.js";
5
5
  import { WEAK_PASSWORDS } from "./password-dict.js";
6
6
  import { NETWORK_REST_COLUMNS, NETWORK_REST_SELECT, sqlColumns } from "./rest-projections.js";
7
- import { deleteAgentGrants, isAgentRestricted } from "./agent-access.js";
7
+ import { deleteAgentGrants, isAgentRestricted } from "./agent-access.js"; import { deleteTaskGrantsForMember, NEW_MEMBER_TASK_ACCESS, type TaskAccessMode } from "./task-access.js"; // 一行两个 import:文档钉着 auth.ts 的行号
8
8
 
9
9
  // Round-6 A1 hardening — dummy hash for username-enumeration timing
10
10
  // close. We compute ONE scrypt hash of a throwaway password and reuse
@@ -586,6 +586,8 @@ export function getNetworkMembers(networkId: string) {
586
586
  `SELECT nm.user_id, nm.role, nm.joined_at, nm.invited_by, u.username, u.display_name,
587
587
  CASE WHEN u.role = 'admin' OR nm.role IN ('owner', 'admin') OR nm.agent_access = 'all' THEN 'all' ELSE 'granted' END AS agent_access,
588
588
  (SELECT COUNT(*) FROM network_member_agent_grants g WHERE g.network_id = nm.network_id AND g.user_id = nm.user_id) AS agent_grant_count,
589
+ CASE WHEN u.role = 'admin' OR nm.role IN ('owner', 'admin') OR nm.task_access = 'all' THEN 'all' ELSE 'scoped' END AS task_access,
590
+ (SELECT COUNT(*) FROM network_member_project_grants pg WHERE pg.network_id = nm.network_id AND pg.user_id = nm.user_id) AS task_project_count,
589
591
  (SELECT COUNT(*) FROM network_member_group_grants gg WHERE gg.network_id = nm.network_id AND gg.user_id = nm.user_id) AS agent_group_count
590
592
  FROM network_members nm JOIN users u ON nm.user_id = u.user_id
591
593
  WHERE nm.network_id = ?1 ORDER BY nm.joined_at`,
@@ -624,15 +626,17 @@ export function usernameCollidesWithAgent(networkId: string, userId: string): bo
624
626
  }
625
627
 
626
628
  // 新成员默认 agent_access='granted'(零 Agent 权限),owner/admin 角色不看这一列。
627
- export function addNetworkMember(networkId: string, userId: string, role: string, invitedBy?: string, opts: { agentAccess?: "all" | "granted" } = {}): { ok: boolean; error?: string } {
629
+ export function addNetworkMember(networkId: string, userId: string, role: string, invitedBy?: string, opts: { agentAccess?: "all" | "granted"; taskAccess?: TaskAccessMode } = {}): { ok: boolean; error?: string } {
628
630
  if (!MEMBER_ROLES.has(role)) return { ok: false, error: "invalid role" };
629
631
  const agentAccess = opts.agentAccess === "all" ? "all" : "granted";
632
+ // 任务范围:新成员默认 NEW_MEMBER_TASK_ACCESS(task-access.ts 一处定义)。
633
+ const taskAccess: TaskAccessMode = opts.taskAccess === "all" || opts.taskAccess === "scoped" ? opts.taskAccess : NEW_MEMBER_TASK_ACCESS;
630
634
  if (!db.get("SELECT 1 FROM users WHERE user_id = ?1", userId)) return { ok: false, error: "user not found" };
631
635
  const existing = db.get<any>("SELECT 1 FROM network_members WHERE network_id = ?1 AND user_id = ?2", networkId, userId);
632
636
  if (existing) return { ok: false, error: "user already a member" };
633
637
  if (usernameCollidesWithAgent(networkId, userId)) return { ok: false, error: "username_collides_with_agent_alias" };
634
- db.run("INSERT INTO network_members (network_id, user_id, role, invited_by, agent_access) VALUES (?1, ?2, ?3, ?4, ?5)",
635
- [networkId, userId, role, invitedBy || null, agentAccess]);
638
+ db.run("INSERT INTO network_members (network_id, user_id, role, invited_by, agent_access, task_access) VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
639
+ [networkId, userId, role, invitedBy || null, agentAccess, taskAccess]);
636
640
  return { ok: true };
637
641
  }
638
642
 
@@ -649,6 +653,7 @@ export function removeNetworkMember(networkId: string, userId: string): { ok: bo
649
653
  if (member.role === "owner") return { ok: false, error: "cannot remove owner" };
650
654
  db.run("DELETE FROM network_members WHERE network_id = ?1 AND user_id = ?2", [networkId, userId]);
651
655
  deleteAgentGrants(networkId, userId);
656
+ deleteTaskGrantsForMember(networkId, userId);
652
657
  return { ok: true };
653
658
  }
654
659
 
@@ -686,8 +691,8 @@ export function joinByInvite(inviteCode: string, userId: string): { ok: boolean;
686
691
  if (existing) return { ok: false, error: "already a member of this network" };
687
692
  if (usernameCollidesWithAgent(invite.network_id, userId)) return { ok: false, error: "username_collides_with_agent_alias" };
688
693
  // Add member + increment used count
689
- db.run("INSERT INTO network_members (network_id, user_id, role, invited_by, agent_access) VALUES (?1, ?2, ?3, ?4, 'granted')",
690
- [invite.network_id, userId, invite.role, invite.created_by]);
694
+ db.run("INSERT INTO network_members (network_id, user_id, role, invited_by, agent_access, task_access) VALUES (?1, ?2, ?3, ?4, 'granted', ?5)",
695
+ [invite.network_id, userId, invite.role, invite.created_by, NEW_MEMBER_TASK_ACCESS]);
691
696
  db.run("UPDATE network_invites SET used_count = used_count + 1 WHERE invite_code = ?1", [inviteCode]);
692
697
  // 受限成员不能持有网络令牌(resolveToken 会拒),别签一个用不了的。
693
698
  if (isAgentRestricted(userId, invite.network_id)) return { ok: true, network_id: invite.network_id, role: invite.role };
@@ -704,7 +709,9 @@ export function getUserAllNetworks(userId: string) {
704
709
  return db.all<any>(
705
710
  `SELECT ${sqlColumns(NETWORK_REST_COLUMNS, "n")}, nm.role as member_role,
706
711
  -- 多用户 Agent 权限:客户端据此显示「还没有被分配任何 Agent,请联系管理员」。
707
- CASE WHEN u.role = 'admin' OR nm.role IN ('owner', 'admin') OR nm.agent_access = 'all' THEN 'all' ELSE 'granted' END AS agent_access
712
+ CASE WHEN u.role = 'admin' OR nm.role IN ('owner', 'admin') OR nm.agent_access = 'all' THEN 'all' ELSE 'granted' END AS agent_access,
713
+ -- 任务权限(RFC-038 §9):客户端据此显示只读提示与「还没有与你相关的任务」。
714
+ CASE WHEN u.role = 'admin' OR nm.role IN ('owner', 'admin') OR nm.task_access = 'all' THEN 'all' ELSE 'scoped' END AS task_access
708
715
  FROM networks n JOIN network_members nm ON n.network_id = nm.network_id
709
716
  JOIN users u ON u.user_id = nm.user_id
710
717
  WHERE nm.user_id = ?1 ORDER BY nm.role = 'owner' DESC, n.created_at`,
@@ -827,6 +834,8 @@ const NETWORK_CLEANUP_TABLES = [
827
834
  "node_create_requests", "node_start_requests", "node_stop_requests", "node_rules_requests",
828
835
  "node_config_updates", "rename_txn", "probe_results", "agent_telemetry", "completions",
829
836
  "task_events", "task_terminal_events", "scheduled_task_runs", "external_schedule_edits",
837
+ // 标签颜色:网络本身的元数据(标签本体在 requirements 行里,那张表是阻断项)。
838
+ "network_tags",
830
839
  ];
831
840
 
832
841
  function tableExists(name: string): boolean {
@@ -0,0 +1,178 @@
1
+ // 人与人私信的文件只给私信里的人看(owner 2026-09-30「给人好像发不了图片」的后续)。
2
+ //
3
+ // 规则:文件上传时带 `?purpose=dm`(App 私信输入框这么传)= 它第一次用途就是私信 → 私信文件(index scope=dm)。
4
+ // 私信文件只有上传者、带着它的私信的收发双方、管理员能下载;同网络的其他成员和节点令牌(Agent)都 404
5
+ // (与其它拒绝同一个形状,不泄露文件存在)。不带 purpose 上传的文件 —— 例如先在 agent 会话里发过 —— 永远不会
6
+ // 变成私信文件,之后转进私信也照旧全网成员可见。私信文件只能由看得见它的人再转发。
7
+
8
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
9
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "fs";
10
+ import { join } from "path";
11
+ import { tmpdir } from "os";
12
+ import { register, addNetworkMember } from "./auth.js";
13
+ import { db } from "./db.js";
14
+
15
+ const DIR = mkdtempSync(join(tmpdir(), "anet-dm-file-scope-"));
16
+ const PW = "DmFileScopePassw0rd!";
17
+ let BASE = "";
18
+ let hub: any = null;
19
+ let NET = "";
20
+ type U = { token: string; id: string };
21
+ let admin: U, alice: U, bob: U, carol: U, dave: U;
22
+ let adminNtok = "";
23
+
24
+ const auth = (t: string) => ({ Authorization: `Bearer ${t}` });
25
+ async function upload(token: string, body: string, query: string) {
26
+ const form = new FormData();
27
+ form.append("file", new Blob([new TextEncoder().encode(body)], { type: "image/png" }), "shot.png");
28
+ const res = await fetch(`${BASE}/api/upload?network_id=${NET}${query}`, { method: "POST", body: form, headers: auth(token) });
29
+ return { status: res.status, body: await res.json().catch(() => null) as any };
30
+ }
31
+ async function uploadId(token: string, body: string, query = "") {
32
+ const r = await upload(token, body, query);
33
+ expect(r.status).toBe(200);
34
+ return r.body.file_id as string;
35
+ }
36
+ async function dm(from: U, to: U, fileId: string, message = "") {
37
+ const res = await fetch(`${BASE}/api/dm`, {
38
+ method: "POST",
39
+ headers: { ...auth(from.token), "Content-Type": "application/json" },
40
+ body: JSON.stringify({ network_id: NET, to_user_id: to.id, message, attachments: [{ type: "file", file_id: fileId, name: "shot.png", mime: "image/png" }] }),
41
+ });
42
+ return { status: res.status, body: await res.json().catch(() => null) as any };
43
+ }
44
+ const fileStatus = async (token: string, fileId: string, method = "GET") =>
45
+ (await fetch(`${BASE}/api/files/${fileId}`, { method, headers: auth(token) })).status;
46
+ const entry = (fileId: string) => JSON.parse(readFileSync(join(DIR, "uploads", ".index", `${fileId}.json`), "utf8"));
47
+
48
+ beforeAll(async () => {
49
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
50
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
51
+ process.env.HOST = "127.0.0.1";
52
+ const stamp = Date.now();
53
+ const a = register(`dmfs_admin_${stamp}`, PW);
54
+ db.run("UPDATE users SET role = 'admin' WHERE user_id = ?1", [a.user!.user_id]);
55
+ admin = { token: a.token!, id: a.user!.user_id };
56
+ adminNtok = a.network_token!;
57
+ NET = a.network_id!;
58
+ // alice / bob / carol see every agent (agent_access=all) — the members for whom a network-scoped file was readable
59
+ // before, i.e. where the gap was. dave is a restricted member (only granted agents).
60
+ const mk = (name: string, agentAccess: "all" | "granted"): U => {
61
+ const r = register(`${name}_${stamp}`, PW);
62
+ expect(addNetworkMember(NET, r.user!.user_id, "member", admin.id, { agentAccess }).ok).toBe(true);
63
+ return { token: r.token!, id: r.user!.user_id };
64
+ };
65
+ alice = mk("dmfs_alice", "all"); bob = mk("dmfs_bob", "all"); carol = mk("dmfs_carol", "all"); dave = mk("dmfs_dave", "granted");
66
+ const mod: any = await import("./server.js");
67
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
68
+ BASE = `http://127.0.0.1:${hub.port}`;
69
+ }, 30_000);
70
+
71
+ afterAll(() => {
72
+ try { hub?.stop?.(true); } catch {}
73
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
74
+ });
75
+
76
+ describe("upload ?purpose=dm", () => {
77
+ test("marks the index entry; absent purpose writes no scope key (old shape)", async () => {
78
+ expect(entry(await uploadId(alice.token, "p1", "&purpose=dm")).scope).toBe("dm");
79
+ expect("scope" in entry(await uploadId(alice.token, "p2"))).toBe(false);
80
+ });
81
+ test("an unknown purpose is refused, not silently treated as network scope", async () => {
82
+ const r = await upload(alice.token, "p3", "&purpose=DM");
83
+ expect(r.status).toBe(400);
84
+ expect(r.body.error).toBe("bad_purpose");
85
+ });
86
+ });
87
+
88
+ describe("a DM file is readable only by the people in the DM", () => {
89
+ test("alice → bob: alice and bob read it; carol (same network, not in the DM) and agents get 404; admin reads", async () => {
90
+ const fid = await uploadId(alice.token, "alice-to-bob", "&purpose=dm");
91
+ expect((await dm(alice, bob, fid)).status).toBe(200);
92
+ expect(await fileStatus(alice.token, fid)).toBe(200);
93
+ expect(await fileStatus(bob.token, fid)).toBe(200);
94
+ expect(await fileStatus(carol.token, fid)).toBe(404);
95
+ expect(await fileStatus(carol.token, fid, "HEAD")).toBe(404);
96
+ expect(await fileStatus(adminNtok, fid)).toBe(404);
97
+ expect(await fileStatus(admin.token, fid)).toBe(200);
98
+ });
99
+ test("bob → alice (other direction): same rule", async () => {
100
+ const fid = await uploadId(bob.token, "bob-to-alice", "&purpose=dm");
101
+ expect((await dm(bob, alice, fid)).status).toBe(200);
102
+ expect(await fileStatus(bob.token, fid)).toBe(200);
103
+ expect(await fileStatus(alice.token, fid)).toBe(200);
104
+ expect(await fileStatus(carol.token, fid)).toBe(404);
105
+ });
106
+ test("a restricted member in the DM reads it both ways; out of it, not", async () => {
107
+ const toDave = await uploadId(alice.token, "alice-to-dave", "&purpose=dm");
108
+ expect((await dm(alice, dave, toDave)).status).toBe(200);
109
+ expect(await fileStatus(dave.token, toDave)).toBe(200);
110
+ const fromDave = await uploadId(dave.token, "dave-to-alice", "&purpose=dm");
111
+ expect((await dm(dave, alice, fromDave)).status).toBe(200);
112
+ expect(await fileStatus(alice.token, fromDave)).toBe(200);
113
+ expect(await fileStatus(bob.token, fromDave)).toBe(404);
114
+ const other = await uploadId(alice.token, "alice-to-bob-2", "&purpose=dm");
115
+ await dm(alice, bob, other);
116
+ expect(await fileStatus(dave.token, other)).toBe(404);
117
+ });
118
+ test("before it is sent, only the uploader reads it", async () => {
119
+ const fid = await uploadId(alice.token, "draft", "&purpose=dm");
120
+ expect(await fileStatus(alice.token, fid)).toBe(200);
121
+ expect(await fileStatus(bob.token, fid)).toBe(404);
122
+ });
123
+ test("the DM thread still returns the attachment to both sides", async () => {
124
+ const fid = await uploadId(alice.token, "thread", "&purpose=dm");
125
+ await dm(alice, bob, fid, "看图");
126
+ const t = await (await fetch(`${BASE}/api/dm?network_id=${NET}&with=${alice.id}`, { headers: auth(bob.token) })).json() as any;
127
+ expect(JSON.parse(t.messages[0].meta_json).attachments[0].file_id).toBe(fid);
128
+ });
129
+ });
130
+
131
+ describe("nobody unlocks a DM file by putting its id into their own DM", () => {
132
+ test("carol DMs alice's DM file to bob → 403, and she still can't read it", async () => {
133
+ const fid = await uploadId(alice.token, "secret", "&purpose=dm");
134
+ expect((await dm(alice, bob, fid)).status).toBe(200);
135
+ const r = await dm(carol, bob, fid);
136
+ expect(r.status).toBe(403);
137
+ expect(r.body.error).toBe("attachment_not_accessible");
138
+ expect(await fileStatus(carol.token, fid)).toBe(404);
139
+ });
140
+ test("bob (who received it) may forward it to carol, and then carol reads it", async () => {
141
+ const fid = await uploadId(alice.token, "forwardable", "&purpose=dm");
142
+ expect((await dm(alice, bob, fid)).status).toBe(200);
143
+ expect((await dm(bob, carol, fid)).status).toBe(200);
144
+ expect(await fileStatus(carol.token, fid)).toBe(200);
145
+ });
146
+ });
147
+
148
+ describe("files whose first use was not a DM keep their scope", () => {
149
+ test("uploaded in an agent chat, then forwarded into a DM → still readable by every member", async () => {
150
+ const fid = await uploadId(alice.token, "agent-chat-first");
151
+ // first use: an agent task (what ChatScreen does: upload without purpose, send_task with the file id)
152
+ db.run(
153
+ `INSERT INTO tasks (task_id, from_name, to_name, status, content, network_id, meta_json) VALUES (?1, ?2, 'dmfs-agent', 'delivered', 'see image', ?3, ?4)`,
154
+ [`t_dmfs_${Date.now()}`, "dmfs_alice", NET, JSON.stringify({ attachments: [{ type: "file", file_id: fid }] })],
155
+ );
156
+ expect(await fileStatus(carol.token, fid)).toBe(200);
157
+ expect((await dm(alice, bob, fid)).status).toBe(200);
158
+ expect("scope" in entry(fid)).toBe(false);
159
+ expect(await fileStatus(carol.token, fid)).toBe(200);
160
+ expect(await fileStatus(bob.token, fid)).toBe(200);
161
+ expect(await fileStatus(adminNtok, fid)).toBe(200);
162
+ });
163
+ test("an old app's DM upload (no purpose) behaves exactly as before", async () => {
164
+ const fid = await uploadId(alice.token, "old-app");
165
+ expect((await dm(alice, bob, fid)).status).toBe(200);
166
+ expect(await fileStatus(carol.token, fid)).toBe(200);
167
+ });
168
+ });
169
+
170
+ describe("index entry integrity", () => {
171
+ test("a hand-edited unknown scope makes the entry invalid (404), never network-wide", async () => {
172
+ const fid = await uploadId(alice.token, "tamper", "&purpose=dm");
173
+ const path = join(DIR, "uploads", ".index", `${fid}.json`);
174
+ writeFileSync(path, JSON.stringify({ ...entry(fid), scope: "public" }));
175
+ expect(await fileStatus(alice.token, fid)).toBe(404);
176
+ expect(await fileStatus(carol.token, fid)).toBe(404);
177
+ });
178
+ });
package/src/human-dm.ts CHANGED
@@ -11,7 +11,7 @@ import { getUserNetworkRole } from "./auth.js";
11
11
  import { isAgentRestricted } from "./agent-access.js";
12
12
  import { pushUserEvent, hasUserSubscribers } from "./push.js";
13
13
  import { existsSync, readFileSync } from "fs";
14
- import { FILE_ID_REGEX, indexEntryPath, validateAttachments, validateIndexEntry } from "./uploads.js";
14
+ import { DM_FILE_SCOPE, FILE_ID_REGEX, indexEntryPath, validateAttachments, validateIndexEntry, type UploadIndexEntry } from "./uploads.js";
15
15
  import { restrictedMemberCanUseFile } from "./restricted-files.js";
16
16
  import { redactMessageRow } from "./redact-tokens.js";
17
17
 
@@ -57,10 +57,16 @@ export function sendHumanDm(input: HumanDmSendInput): HumanDmSendResult {
57
57
  for (const a of attachments.attachments) {
58
58
  const fileId = (a as { file_id?: string }).file_id;
59
59
  if (!fileId) continue;
60
+ const entry = networkFileEntry(networkId, fileId);
60
61
  const usable = restricted
61
62
  ? restrictedMemberCanUseFile(sender.userId, sender.username, networkId, fileId)
62
- : fileInNetwork(networkId, fileId);
63
+ : !!entry;
63
64
  if (!usable) return { ok: false, error: "attachment_not_accessible", status: 403 };
65
+ // 私信文件只能由看得见它的人转发:否则任何成员把别人私信里的 file_id 塞进一条自己发出的私信,
66
+ // 就成了「私信参与者」而解锁它。上传者本人、以及已经在某段私信里收到/发出过它的人可以转。
67
+ if (entry?.scope === DM_FILE_SCOPE && entry.owner_id !== sender.userId && !dmParticipantSeesFile(sender.userId, networkId, fileId)) {
68
+ return { ok: false, error: "attachment_not_accessible", status: 403 };
69
+ }
64
70
  }
65
71
 
66
72
  const clientRequestId = typeof input.clientRequestId === "string" && /^[A-Za-z0-9._-]{1,80}$/.test(input.clientRequestId)
@@ -107,15 +113,36 @@ export function sendHumanDm(input: HumanDmSendInput): HumanDmSendResult {
107
113
  return { ok: true, message: redactMessageRow(row), delivered };
108
114
  }
109
115
 
110
- /** 不受限的成员带附件:文件必须属于这个网络(防把别的网络的 file_id 塞进来)。 */
111
- function fileInNetwork(networkId: string, fileId: string): boolean {
112
- if (!FILE_ID_REGEX.test(fileId)) return false;
116
+ /** 带附件:文件必须属于这个网络(防把别的网络的 file_id 塞进来)。返回它的索引条目,不属于 → null。 */
117
+ function networkFileEntry(networkId: string, fileId: string): UploadIndexEntry | null {
118
+ if (!FILE_ID_REGEX.test(fileId)) return null;
113
119
  const path = indexEntryPath(fileId);
114
- if (!path || !existsSync(path)) return false;
120
+ if (!path || !existsSync(path)) return null;
115
121
  try {
116
122
  const entry = JSON.parse(readFileSync(path, "utf8"));
117
- return validateIndexEntry(entry) && entry.file_id === fileId && entry.network_id === networkId;
118
- } catch { return false; }
123
+ return validateIndexEntry(entry) && entry.file_id === fileId && entry.network_id === networkId ? entry : null;
124
+ } catch { return null; }
125
+ }
126
+
127
+ /**
128
+ * 这个用户是不是某段带着这个文件的私信里的一方(收到的或发出的)。私信文件(scope=dm)的下载只认它
129
+ * 和上传者本人(server.ts authorizeFileDownload)。LIKE 只做预筛,真正的判定按 attachments 键解析。
130
+ */
131
+ export function dmParticipantSeesFile(userId: string, networkId: string, fileId: string): boolean {
132
+ if (!FILE_ID_REGEX.test(fileId)) return false;
133
+ const needle = `%"${fileId.replace(/_/g, "\\_")}"%`;
134
+ const rows = db.all<{ meta_json: string | null }>(
135
+ `SELECT meta_json FROM user_inbox
136
+ WHERE network_id = ?1 AND kind = '${HUMAN_DM_KIND}' AND (user_id = ?2 OR sender_user_id = ?2)
137
+ AND meta_json LIKE ?3 ESCAPE '\\' LIMIT 50`,
138
+ networkId, userId, needle,
139
+ );
140
+ return rows.some((row) => {
141
+ try {
142
+ const list = JSON.parse(row.meta_json ?? "null")?.attachments;
143
+ return Array.isArray(list) && list.some((a: any) => a && typeof a === "object" && a.file_id === fileId);
144
+ } catch { return false; }
145
+ });
119
146
  }
120
147
 
121
148
  /** 我和某人的私信记录(双向),新的在前。before = 上一页最后一条的 created_at。 */
@@ -0,0 +1,93 @@
1
+ // 成员增 / 改角色 / 移出 的审计行必须带 network_id —— HTTP 集成测试(真实 Bun.serve,私有端口,临时库)。
2
+ //
3
+ // 之前这三条 logAudit 没传 networkId,audit_log.network_id 恒为 NULL:
4
+ // 按网络归属审计时它们整批丢失,而同一组路由里的 member_agent_grants_changed 一直带着。
5
+
6
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
7
+ import { mkdtempSync, rmSync } from "fs";
8
+ import { join } from "path";
9
+ import { tmpdir } from "os";
10
+ import { register } from "./auth.js";
11
+ import { db } from "./db.js";
12
+
13
+ const DIR = mkdtempSync(join(tmpdir(), "anet-member-audit-"));
14
+ let BASE = "";
15
+ let hub: any = null;
16
+
17
+ const PW = "AuditTestPassw0rd!x";
18
+ let ownerToken = "";
19
+ let NET = "";
20
+ let bobId = "";
21
+
22
+ async function send(token: string, method: string, path: string, payload?: unknown): Promise<{ status: number; body: any }> {
23
+ const res = await fetch(`${BASE}${path}`, {
24
+ method,
25
+ headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/json" },
26
+ body: payload === undefined ? undefined : JSON.stringify(payload),
27
+ });
28
+ const text = await res.text();
29
+ let body: any = null;
30
+ try { body = JSON.parse(text); } catch {}
31
+ return { status: res.status, body };
32
+ }
33
+
34
+ const auditRow = (action: string) =>
35
+ db.get("SELECT network_id, target_id FROM audit_log WHERE action = ?1 ORDER BY id DESC LIMIT 1", action) as
36
+ { network_id: string | null; target_id: string | null } | null;
37
+
38
+ beforeAll(async () => {
39
+ process.env.COMMHUB_DB ||= join(DIR, "hub.db");
40
+ process.env.COMMHUB_UPLOADS_DIR = join(DIR, "uploads");
41
+ process.env.HOST = "127.0.0.1";
42
+
43
+ const owner = register(`audit_owner_${Date.now()}`, PW, undefined, "Owner");
44
+ expect(owner.ok).toBe(true);
45
+ ownerToken = owner.token!;
46
+ NET = owner.network_id!;
47
+ const bob = register(`audit_bob_${Date.now()}`, PW, undefined, "Bob");
48
+ expect(bob.ok).toBe(true);
49
+ bobId = bob.user!.user_id;
50
+
51
+ const mod: any = await import("./server.js");
52
+ hub = mod.bootServer({ port: 0, hostname: "127.0.0.1" });
53
+ BASE = `http://127.0.0.1:${hub.port}`;
54
+ }, 30_000);
55
+
56
+ afterAll(() => {
57
+ try { hub?.stop?.(true); } catch {}
58
+ try { rmSync(DIR, { recursive: true, force: true }); } catch {}
59
+ });
60
+
61
+ describe("成员审计行带 network_id", () => {
62
+ test("member_added", async () => {
63
+ const r = await send(ownerToken, "POST", `/api/networks/${NET}/members`, { user_id: bobId, role: "member" });
64
+ expect(r.status).toBe(200);
65
+ const row = auditRow("member_added");
66
+ expect(row).toBeTruthy();
67
+ expect(row!.network_id).toBe(NET);
68
+ });
69
+
70
+ test("member_role_changed", async () => {
71
+ const r = await send(ownerToken, "PUT", `/api/networks/${NET}/members/${bobId}`, { role: "viewer" });
72
+ expect(r.status).toBe(200);
73
+ const row = auditRow("member_role_changed");
74
+ expect(row).toBeTruthy();
75
+ expect(row!.network_id).toBe(NET);
76
+ });
77
+
78
+ test("member_removed", async () => {
79
+ const r = await send(ownerToken, "DELETE", `/api/networks/${NET}/members/${bobId}`);
80
+ expect(r.status).toBe(200);
81
+ const row = auditRow("member_removed");
82
+ expect(row).toBeTruthy();
83
+ expect(row!.network_id).toBe(NET);
84
+ });
85
+
86
+ test("GET /api/audit-log(本人的审计)里这三条的 network_id 都是该网络", async () => {
87
+ const r = await send(ownerToken, "GET", "/api/audit-log?limit=200");
88
+ expect(r.status).toBe(200);
89
+ const mine = (r.body.logs as any[]).filter(e => ["member_added", "member_role_changed", "member_removed"].includes(e.action));
90
+ expect(mine.map(e => e.action).sort()).toEqual(["member_added", "member_removed", "member_role_changed"]);
91
+ for (const e of mine) expect(e.network_id).toBe(NET);
92
+ });
93
+ });