@sleep2agi/commhub-server 0.9.0-preview.72 → 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/bin/commhub.ts CHANGED
@@ -12,19 +12,56 @@
12
12
 
13
13
  const args = process.argv.slice(2);
14
14
 
15
- for (let i = 0; i < args.length; i++) {
16
- if (args[i] === "--port" || args[i] === "-p") process.env.PORT = args[++i];
17
- if (args[i] === "--host") process.env.HOST = args[++i];
18
- if (args[i] === "--token" || args[i] === "-t") process.env.COMMHUB_AUTH_TOKEN = args[++i];
19
- if (args[i] === "--db") process.env.COMMHUB_DB = args[++i];
20
- if (args[i] === "--cors") process.env.COMMHUB_CORS_ORIGINS = args[++i];
21
- if (args[i] === "--dev-open") process.env.COMMHUB_DEV_OPEN = "1";
22
- if (args[i] === "--help" || args[i] === "-h") {
15
+ // RFC-039 S5 — offline SQLite → PostgreSQL copy. Handled before any server
16
+ // option parsing: it never starts a Hub and never opens the default database.
17
+ if (args[0] === "migrate-to-pg") {
18
+ const flag = (name: string) => args.includes(name);
19
+ const value = (name: string) => { const i = args.indexOf(name); return i > 0 ? args[i + 1] : undefined; };
20
+ const MIGRATE_VALUE_FLAGS = new Set(["--from", "--to"]);
21
+ const MIGRATE_FLAGS = new Set(["--dry-run", "--i-know-this-is-a-copy", "--help", "-h"]);
22
+ for (let i = 1; i < args.length; i++) {
23
+ if (MIGRATE_VALUE_FLAGS.has(args[i])) { i++; continue; }
24
+ if (!MIGRATE_FLAGS.has(args[i])) {
25
+ console.error(`commhub-server migrate-to-pg: unknown argument ${args[i]}\nRun "commhub-server migrate-to-pg --help" for usage.`);
26
+ process.exit(2);
27
+ }
28
+ }
29
+ if (flag("--help") || flag("-h") || !value("--from") || !value("--to")) {
23
30
  console.log(`
31
+ Usage:
32
+ commhub-server migrate-to-pg --from <sqlite file> --to <postgres url> [--dry-run] [--i-know-this-is-a-copy]
33
+
34
+ Copies a stopped Hub's SQLite database into an EMPTY PostgreSQL database, in one
35
+ transaction, verifying per-table row counts and content hashes before COMMIT.
36
+ Experimental (RFC-039); available from commhub-server 0.9.0-preview.73.
37
+ Files under the uploads directory are not copied.
38
+
39
+ --dry-run do everything, then roll back and leave the target empty
40
+ --i-know-this-is-a-copy allow a source under ~/.commhub (refused by default)
41
+ `);
42
+ process.exit(flag("--help") || flag("-h") ? 0 : 2);
43
+ }
44
+ const { migrateSqliteToPg, MigrateRefused } = await import("../src/migrate-sqlite-to-pg.ts");
45
+ try {
46
+ await migrateSqliteToPg({
47
+ from: value("--from")!,
48
+ to: value("--to")!,
49
+ dryRun: flag("--dry-run"),
50
+ allowCommhubCopy: flag("--i-know-this-is-a-copy"),
51
+ });
52
+ process.exit(0);
53
+ } catch (error) {
54
+ console.error(error instanceof MigrateRefused ? error.message : `[migrate-to-pg] FAILED, rolled back: ${(error as Error)?.message ?? error}`);
55
+ process.exit(error instanceof MigrateRefused ? 2 : 1);
56
+ }
57
+ }
58
+
59
+ const HELP = `
24
60
  CommHub MCP Server — AI Agent 通信中枢
25
61
 
26
62
  Usage:
27
63
  commhub-server [options]
64
+ commhub-server migrate-to-pg --help Copy a stopped Hub's SQLite database into PostgreSQL (experimental)
28
65
 
29
66
  Options:
30
67
  --port, -p <port> Port to listen on (default: 9200, env: PORT)
@@ -34,6 +71,7 @@ Options:
34
71
  --cors <origins> CORS origins, comma-separated (env: COMMHUB_CORS_ORIGINS)
35
72
  --dev-open Explicit unauthenticated local development mode
36
73
  --help, -h Show this help
74
+ --version, -v Print the version and exit
37
75
 
38
76
  Environment Variables:
39
77
  PORT Server port (default: 9200)
@@ -49,8 +87,43 @@ Examples:
49
87
  commhub-server --port 9200 --token my-secret-token
50
88
  commhub-server --port 9200 --dev-open
51
89
  PORT=9200 COMMHUB_AUTH_TOKEN=secret commhub-server
52
- `);
90
+ `;
91
+
92
+ // Anything this bin does not recognise is a usage error: exit 2 and never
93
+ // start a Hub. Before this, unknown words and flags were ignored and a Hub
94
+ // started on the defaults — `commhub-server <some-subcommand>` on a version
95
+ // without that subcommand opened ~/.commhub/commhub.db on :9200.
96
+ function usageError(message: string): never {
97
+ console.error(`commhub-server: ${message}\nRun "commhub-server --help" for usage.`);
98
+ process.exit(2);
99
+ }
100
+
101
+ const VALUE_FLAGS: Record<string, string> = {
102
+ "--port": "PORT", "-p": "PORT",
103
+ "--host": "HOST",
104
+ "--token": "COMMHUB_AUTH_TOKEN", "-t": "COMMHUB_AUTH_TOKEN",
105
+ "--db": "COMMHUB_DB",
106
+ "--cors": "COMMHUB_CORS_ORIGINS",
107
+ };
108
+
109
+ for (let i = 0; i < args.length; i++) {
110
+ const arg = args[i];
111
+ if (Object.hasOwn(VALUE_FLAGS, arg)) {
112
+ const value = args[i + 1];
113
+ if (value === undefined || value.startsWith("--")) usageError(`${arg} needs a value`);
114
+ process.env[VALUE_FLAGS[arg]] = value;
115
+ i++;
116
+ } else if (arg === "--dev-open") {
117
+ process.env.COMMHUB_DEV_OPEN = "1";
118
+ } else if (arg === "--help" || arg === "-h") {
119
+ console.log(HELP);
120
+ process.exit(0);
121
+ } else if (arg === "--version" || arg === "-v") {
122
+ const pkg = (await import("../package.json", { with: { type: "json" } })).default as { version: string };
123
+ console.log(pkg.version);
53
124
  process.exit(0);
125
+ } else {
126
+ usageError(arg.startsWith("-") ? `unknown option ${arg}` : `unknown command ${arg}`);
54
127
  }
55
128
  }
56
129
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.9.0-preview.72",
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",
@@ -255,7 +255,7 @@ describe("humans: restricted members can still see and DM other humans", () => {
255
255
  const r = await get(aliceToken, `/api/networks/${NET}/humans`);
256
256
  expect(r.status).toBe(200);
257
257
  expect(r.body.humans.map((h: any) => h.user_id).sort()).toEqual([adminId, aliceId, bobId].sort());
258
- expect(Object.keys(r.body.humans[0]).sort()).toEqual(["display_name", "user_id", "username"]);
258
+ expect(Object.keys(r.body.humans[0]).sort()).toEqual(["display_name", "last_seen_at", "online", "user_id", "username"]);
259
259
  // 非成员拿不到。
260
260
  const outsider = register(`acl_out_${Date.now()}`, PW);
261
261
  expect((await get(outsider.token!, `/api/networks/${NET}/humans`)).status).toBe(403);
@@ -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,85 @@
1
+ import { afterAll, describe, expect, test } from "bun:test";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ // commhub-server must refuse what it does not understand instead of starting a
8
+ // Hub on the defaults (the live ~/.commhub/commhub.db). Every child gets a
9
+ // throwaway HOME and a throwaway PORT, so even a regression binds nothing real.
10
+ const BIN = join(dirname(fileURLToPath(import.meta.url)), "..", "bin", "commhub.ts");
11
+ const PKG_VERSION = JSON.parse(readFileSync(join(dirname(BIN), "..", "package.json"), "utf8")).version as string;
12
+ const home = mkdtempSync(join(tmpdir(), "anet-bin-args-"));
13
+ const PORT = String(26000 + Math.floor(Math.random() * 3000));
14
+
15
+ afterAll(() => rmSync(home, { recursive: true, force: true }));
16
+
17
+ function run(args: string[], extraEnv: Record<string, string> = {}, timeoutMs = 15_000) {
18
+ const proc = Bun.spawnSync(["bun", BIN, ...args], {
19
+ env: { PATH: process.env.PATH ?? "", HOME: home, PORT, HOST: "127.0.0.1", ...extraEnv },
20
+ stdout: "pipe", stderr: "pipe", timeout: timeoutMs,
21
+ });
22
+ return { code: proc.exitCode, stdout: proc.stdout.toString(), stderr: proc.stderr.toString() };
23
+ }
24
+
25
+ async function listening(port: string): Promise<boolean> {
26
+ try { await fetch(`http://127.0.0.1:${port}/health`, { signal: AbortSignal.timeout(500) }); return true; } catch { return false; }
27
+ }
28
+
29
+ describe("commhub-server argument handling", () => {
30
+ for (const [args, message] of [
31
+ [["bogus"], "unknown command bogus"],
32
+ [["--port=29999"], "unknown option --port=29999"],
33
+ [["--bogus-flag"], "unknown option --bogus-flag"],
34
+ [["--port"], "--port needs a value"],
35
+ [["--db", "--dev-open"], "--db needs a value"],
36
+ ] as const) {
37
+ test(`${args.join(" ")} → exit 2, no database, nothing listening`, async () => {
38
+ const r = run([...args]);
39
+ expect(r.code).toBe(2);
40
+ expect(r.stderr).toContain(message);
41
+ expect(r.stderr).toContain("commhub-server --help");
42
+ expect(existsSync(join(home, ".commhub"))).toBe(false);
43
+ expect(await listening(PORT)).toBe(false);
44
+ });
45
+ }
46
+
47
+ test("migrate-to-pg with missing or unknown arguments exits 2 and starts nothing", async () => {
48
+ const missing = run(["migrate-to-pg", "--from", "/x.db"]);
49
+ expect(missing.code).toBe(2);
50
+ expect(missing.stdout).toContain("commhub-server migrate-to-pg --from <sqlite file> --to <postgres url>");
51
+ const unknown = run(["migrate-to-pg", "--from", "/x.db", "--to", "postgres://u@127.0.0.1/anet_x_test", "--bogus"]);
52
+ expect(unknown.code).toBe(2);
53
+ expect(unknown.stderr).toContain("unknown argument --bogus");
54
+ expect(existsSync(join(home, ".commhub"))).toBe(false);
55
+ expect(await listening(PORT)).toBe(false);
56
+ });
57
+
58
+ test("--help and --version exit 0 without starting", async () => {
59
+ const help = run(["--help"]);
60
+ expect(help.code).toBe(0);
61
+ expect(help.stdout).toContain("--port, -p <port>");
62
+ const version = run(["--version"]);
63
+ expect(version.code).toBe(0);
64
+ expect(version.stdout.trim()).toBe(PKG_VERSION);
65
+ expect(existsSync(join(home, ".commhub"))).toBe(false);
66
+ });
67
+
68
+ test("the known forms still start a Hub", async () => {
69
+ const db = join(home, "known.db");
70
+ const port = String(Number(PORT) + 1);
71
+ const child = Bun.spawn(["bun", BIN, "--port", port, "--host", "127.0.0.1", "--db", db, "--dev-open"], {
72
+ env: { PATH: process.env.PATH ?? "", HOME: home }, stdout: "pipe", stderr: "pipe",
73
+ });
74
+ try {
75
+ let up = false;
76
+ for (let i = 0; i < 100 && !up; i++) { up = await listening(port); if (!up) await Bun.sleep(100); }
77
+ expect(up).toBe(true);
78
+ expect(existsSync(db)).toBe(true);
79
+ expect(existsSync(join(home, ".commhub", "commhub.db"))).toBe(false);
80
+ } finally {
81
+ child.kill();
82
+ await child.exited;
83
+ }
84
+ }, 30_000);
85
+ });
@@ -38,6 +38,11 @@ describe("RFC-039 S2a sqliteToPostgres", () => {
38
38
  expect(sqliteToPostgres("CAST(x AS INTEGER)")).toBe("CAST(x AS BIGINT)");
39
39
  });
40
40
 
41
+ test("REAL → DOUBLE PRECISION (SQLite REAL is 8-byte), identifiers containing real untouched", () => {
42
+ expect(sqliteToPostgres("mem_used_gb REAL, realm TEXT")).toBe("mem_used_gb DOUBLE PRECISION, realm TEXT");
43
+ expect(sqliteToPostgres("CAST(x AS REAL)")).toBe("CAST(x AS DOUBLE PRECISION)");
44
+ });
45
+
41
46
  test("BLOB → BYTEA, identifiers containing blob untouched", () => {
42
47
  expect(sqliteToPostgres("iv BLOB NOT NULL, blob_ref TEXT")).toBe("iv BYTEA NOT NULL, blob_ref TEXT");
43
48
  });
package/src/db-adapter.ts CHANGED
@@ -90,6 +90,17 @@ export class SQLiteAdapter implements DbAdapter {
90
90
  }
91
91
 
92
92
  close(): void {
93
+ // #2151: once more than 20 distinct SQL strings have gone through query(),
94
+ // bun:sqlite's close() can no longer really close the connection (measured:
95
+ // close(true) reports "database is locked"), so SQLite never checkpoints and
96
+ // a cleanly stopped Hub left its latest writes in -wal. Checkpoint first,
97
+ // so the .db file alone is complete after a clean stop.
98
+ try {
99
+ const r = this.rawDb.query<{ busy: number; log: number; checkpointed: number }, []>("PRAGMA wal_checkpoint(TRUNCATE)").get();
100
+ if (r && r.busy !== 0) console.warn(`[commhub] WAL checkpoint on close was busy: ${JSON.stringify(r)}`);
101
+ } catch (e) {
102
+ console.warn(`[commhub] WAL checkpoint on close failed: ${(e as Error)?.message ?? e}`);
103
+ }
93
104
  this.rawDb.close();
94
105
  }
95
106
  }
@@ -140,6 +151,9 @@ export function sqliteToPostgres(sql: string): string {
140
151
  // INTEGER → BIGINT: SQLite integers are 64-bit, and the Hub stores epoch
141
152
  // milliseconds in INTEGER columns (a 32-bit PG integer overflows).
142
153
  s = s.replace(/\bINTEGER\b/gi, "BIGINT");
154
+ // REAL → DOUBLE PRECISION: SQLite REAL is 8-byte; PostgreSQL REAL is 4-byte
155
+ // and would silently round telemetry and scores.
156
+ s = s.replace(/\bREAL\b/gi, "DOUBLE PRECISION");
143
157
  // BLOB → BYTEA
144
158
  s = s.replace(/\bBLOB\b/gi, "BYTEA");
145
159
  return s;
@@ -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。 */