@sleep2agi/commhub-server 0.9.0-preview.144 → 0.9.0-preview.146

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.144",
3
+ "version": "0.9.0-preview.146",
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",
package/src/db.ts CHANGED
@@ -322,6 +322,21 @@ db.exec(`
322
322
  CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
323
323
  `);
324
324
 
325
+ // 账号头像上传(二进制 PNG,Hub 自己发 URL)。与 users.avatar_url 并存:
326
+ // avatar_url 仍可指向内建池/外链;这张表只存「上传到 Hub 本人的那张」。
327
+ // 单行/人:新版本覆盖旧版本,旧 URL 立刻 404(version 变了),不做历史留存。
328
+ db.exec(`
329
+ CREATE TABLE IF NOT EXISTS user_avatar_uploads (
330
+ user_id TEXT PRIMARY KEY,
331
+ version TEXT NOT NULL,
332
+ png BLOB NOT NULL,
333
+ bytes INTEGER NOT NULL,
334
+ width INTEGER NOT NULL,
335
+ height INTEGER NOT NULL,
336
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
337
+ );
338
+ `);
339
+
325
340
  // Human profile avatar. Additive for existing databases; old binaries ignore it.
326
341
  try {
327
342
  db.exec("ALTER TABLE users ADD COLUMN avatar_url TEXT");
@@ -0,0 +1,128 @@
1
+ // #1894 —— 组织架构的 MCP 面:Agent 用 API 就能建部门、把 Agent 归进去,
2
+ // 不用人手动点。这里跑的是真实的 in-process MCP transport(不是直接调函数),
3
+ // 因为要验的正是「工具真的注册上、参数真的能过 schema、权限真的按 caller 判」。
4
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
5
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
6
+ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
7
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
+ import { db } from "./db.js";
9
+ import { registerTools } from "./tools.js";
10
+
11
+ const NET = "net_dept_mcp";
12
+ const OWNER = "u_dept_mcp_owner";
13
+ const OTHER = "u_dept_mcp_other";
14
+ const AGENT_A = "n_dept_mcp_a";
15
+ const AGENT_B = "n_dept_mcp_b";
16
+
17
+ function cleanup() {
18
+ for (const [table, col] of [
19
+ ["audit_log", "network_id"], ["nodes", "network_id"], ["network_departments", "network_id"],
20
+ ["network_members", "network_id"], ["networks", "network_id"],
21
+ ] as const) {
22
+ try { db.run(`DELETE FROM ${table} WHERE ${col} = ?1`, [NET]); } catch {}
23
+ }
24
+ try { db.run("DELETE FROM users WHERE user_id IN (?1, ?2)", [OWNER, OTHER]); } catch {}
25
+ }
26
+
27
+ function seed() {
28
+ cleanup();
29
+ db.run("INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))", [OWNER, OWNER]);
30
+ db.run("INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?2, 'x', 'user', datetime('now'))", [OTHER, OTHER]);
31
+ db.run("INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?2, ?3, datetime('now'))", [NET, NET, OWNER]);
32
+ db.run("INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))", [OWNER, NET]);
33
+ db.run("INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'member', datetime('now'))", [OTHER, NET]);
34
+ for (const [id, alias] of [[AGENT_A, "dept-agent-a"], [AGENT_B, "dept-agent-b"]] as const) {
35
+ db.run("INSERT INTO nodes (node_id, node_name, alias, network_id, created_at, updated_at) VALUES (?1, ?2, ?2, ?3, datetime('now'), datetime('now'))", [id, alias, NET]);
36
+ }
37
+ }
38
+
39
+ async function connect(callerUserId: string, alias = "dept-agent-a") {
40
+ const server = new McpServer({ name: "dept-mcp-test", version: "1" });
41
+ registerTools(server, undefined, NET, callerUserId, alias, true, null);
42
+ const client = new Client({ name: "dept-mcp-client", version: "1" });
43
+ const [ct, st] = InMemoryTransport.createLinkedPair();
44
+ await server.connect(st);
45
+ await client.connect(ct);
46
+ return { client, server };
47
+ }
48
+
49
+ type Result = { content: Array<{ type: string; text?: string }>; isError?: boolean };
50
+ const json = (r: Result) => JSON.parse(String(r.content[0]?.text ?? "{}"));
51
+
52
+ beforeEach(seed);
53
+ afterAll(cleanup);
54
+
55
+ describe("#1894 departments over MCP", () => {
56
+ test("owner can list, create, assign and read back the counts", async () => {
57
+ const { client, server } = await connect(OWNER);
58
+ try {
59
+ const before = json(await client.callTool({ name: "departments", arguments: { action: "list" } }) as Result);
60
+ expect(before.ok).toBe(true);
61
+ expect(before.unassigned_agents).toBe(2);
62
+
63
+ const created = json(await client.callTool({
64
+ name: "departments", arguments: { action: "create", name: "TMAI", department_id: "dept_test_tmai" },
65
+ }) as Result);
66
+ expect(created.ok).toBe(true);
67
+ expect(created.department.id).toBe("dept_test_tmai");
68
+
69
+ const child = json(await client.callTool({
70
+ name: "departments", arguments: { action: "create", name: "TMAI-内容", parent_id: "dept_test_tmai" },
71
+ }) as Result);
72
+ expect(child.ok).toBe(true);
73
+ expect(child.department.parent_id).toBe("dept_test_tmai");
74
+
75
+ const assigned = json(await client.callTool({
76
+ name: "departments", arguments: { action: "assign_agent", alias: "dept-agent-a", department_id: "dept_test_tmai" },
77
+ }) as Result);
78
+ expect(assigned.ok).toBe(true);
79
+ expect(assigned.node_id).toBe(AGENT_A);
80
+
81
+ const after = json(await client.callTool({ name: "departments", arguments: { action: "list" } }) as Result);
82
+ expect(after.unassigned_agents).toBe(1);
83
+ const tmai = after.departments.find((d: any) => d.id === "dept_test_tmai");
84
+ expect(tmai.agent_count).toBe(1);
85
+ const agent = after.agents.find((a: any) => a.node_id === AGENT_A);
86
+ expect(agent.department_id).toBe("dept_test_tmai");
87
+
88
+ // 清空归属同样要能通过 API 做
89
+ const cleared = json(await client.callTool({
90
+ name: "departments", arguments: { action: "assign_agent", node_id: AGENT_A, department_id: null },
91
+ }) as Result);
92
+ expect(cleared.ok).toBe(true);
93
+ expect(cleared.department_id).toBeNull();
94
+ } finally {
95
+ await server.close();
96
+ }
97
+ });
98
+
99
+ test("a plain member cannot write departments", async () => {
100
+ const { client, server } = await connect(OTHER, "dept-agent-b");
101
+ try {
102
+ const read = json(await client.callTool({ name: "departments", arguments: { action: "list" } }) as Result);
103
+ expect(read.ok).toBe(true);
104
+ const created = json(await client.callTool({
105
+ name: "departments", arguments: { action: "create", name: "不该被建出来" },
106
+ }) as Result);
107
+ expect(created.ok).toBe(false);
108
+ const assigned = json(await client.callTool({
109
+ name: "departments", arguments: { action: "assign_agent", node_id: AGENT_B, department_id: null },
110
+ }) as Result);
111
+ expect(assigned.ok).toBe(false);
112
+ } finally {
113
+ await server.close();
114
+ }
115
+ });
116
+
117
+ test("deleting a department that still has agents is refused", async () => {
118
+ const { client, server } = await connect(OWNER);
119
+ try {
120
+ json(await client.callTool({ name: "departments", arguments: { action: "create", name: "B 站", department_id: "dept_test_b" } }) as Result);
121
+ json(await client.callTool({ name: "departments", arguments: { action: "assign_agent", node_id: AGENT_B, department_id: "dept_test_b" } }) as Result);
122
+ const refused = json(await client.callTool({ name: "departments", arguments: { action: "delete", department_id: "dept_test_b" } }) as Result);
123
+ expect(refused.ok).toBe(false);
124
+ } finally {
125
+ await server.close();
126
+ }
127
+ });
128
+ });
@@ -218,11 +218,14 @@ export function updateDepartment(networkId: string, id: string, body: Record<str
218
218
  }
219
219
 
220
220
  /** 只删空部门:有子部门 / 有成员 → 409(带数字,界面照着说)。 */
221
- export function deleteDepartment(networkId: string, id: string): Ok<{ deleted: string }> | (Fail & { children?: number; members?: number }) {
221
+ export function deleteDepartment(networkId: string, id: string): Ok<{ deleted: string }> | (Fail & { children?: number; members?: number; agents?: number }) {
222
222
  if (!one(networkId, id)) return fail(404, "department_not_found");
223
223
  const children = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM network_departments WHERE network_id = ?1 AND parent_id = ?2", networkId, id)?.n ?? 0;
224
224
  const members = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM network_members WHERE network_id = ?1 AND department_id = ?2", networkId, id)?.n ?? 0;
225
- if (children || members) return { ...fail(409, "department_not_empty"), children, members };
225
+ // #1894 —— Agent 的部门归属是 nodes.department_id(显式归属线)。以前只数人类成员,
226
+ // 于是「部门里还有 300 个 Agent」也能被删掉,留下指向不存在部门的悬空归属。
227
+ const agents = db.get<{ n: number }>("SELECT COUNT(*) AS n FROM nodes WHERE network_id = ?1 AND department_id = ?2", networkId, id)?.n ?? 0;
228
+ if (children || members || agents) return { ...fail(409, "department_not_empty"), children, members, agents };
226
229
  // RFC-040:部门的项目授权(network_department_project_grants)和部门同一事务删掉。
227
230
  groupTx(() => {
228
231
  db.run("DELETE FROM network_departments WHERE network_id = ?1 AND department_id = ?2", [networkId, id]);
@@ -29,7 +29,11 @@ if (!activeDbPath) throw new Error("mcp-context-cost requires COMMHUB_DB (or COM
29
29
  // Hub #938 clone_node is audience both (60 tools / 58,169 B). OpenCode V2
30
30
  // catalog adds read_opencode_catalog and get_opencode_catalog on that user
31
31
  // list. Measured together: 62 tools / 59,409 B. Ceiling keeps ~401 B.
32
- const TOOLS_LIST_MAX_BYTES = 59_810;
32
+ // #1894(2026-10-23):新增 `departments` 一个工具(组织架构的 list/create/update/
33
+ // delete/assign_agent 合并成一个,避免 5 个工具把上下文顶穿)。实测 63 个工具
34
+ // 60,089 B,比原上限高 279 B —— 这是**有意的功能增长**,不是描述悄悄变长,
35
+ // 所以显式抬到 60_500(留 411 B 余量)并在同一行写清理由。
36
+ const TOOLS_LIST_MAX_BYTES = 60_500;
33
37
  /** 单个工具(名字 + 描述 + 输入 schema)的上限;最大的是 report_status(节点心跳,~8.4 KB 的遥测结构)。 */
34
38
  const TOOL_MAX_BYTES = 9_000;
35
39
  /** 单条工具描述的上限(说明写进参数的 describe,或者拆短)。 */
@@ -66,8 +66,13 @@ test("public errors redact free text for granted viewers on every projection", a
66
66
  "adopt_codex_session_conflict", "adopt_codex_port_unavailable", "adopt_codex_stage_still_running",
67
67
  "adopt_codex_identity_unproven",
68
68
  "TUI session mismatch", "opencode_launch_health:TUI session mismatch", "opencode_tui_session_mismatch",
69
+ // #2012:daemon 明确拒绝的固定安全码,必须原样投影给用户,而不是退化成 lifecycle_error。
70
+ "copresence_adopt_v2",
71
+ // 负控:带后缀/路径/空白的形状仍必须清成 lifecycle_error(只精确放行这一串)。
72
+ "copresence_adopt_v2: /private/fixture", "copresence_adopt_v2x", " copresence_adopt_v2",
69
73
  ]) {
70
74
  const publicCodes = ["adopt_start_timeout", "adopted_node_delete_unsupported", "adopt_codex_readopt_required", "adopt_stop_receipt_changed", "opencode_tui_session_mismatch",
75
+ "copresence_adopt_v2",
71
76
  "adopt_codex_external_start_unproven", "adopt_codex_launch_failed", "adopt_codex_launch_timeout",
72
77
  "adopt_codex_port_unproven", "adopt_codex_start_superseded", "adopt_codex_binding_generation_unproven",
73
78
  "adopt_codex_start_evidence_missing", "adopt_codex_start_evidence_changed",
@@ -33,6 +33,9 @@ const publicLifecycleErrors = new Set([
33
33
  "adopt_pane_process_mismatch", "adopt_socket_directory_unsafe", "adopt_socket_unsafe",
34
34
  "adopt_pid_changed", "adopt_pid_invalid", "adopt_pidfile_unsafe",
35
35
  "opencode_tui_session_mismatch",
36
+ // #2012 —— daemon 的 copresence_adopt_v2 拒绝是固定安全码(无路径/无异常文本),
37
+ // 不放行会让用户投影退化成 lifecycle_error,看不到「为什么被拒绝」。
38
+ "copresence_adopt_v2",
36
39
  "adopt_proc_invalid", "adopt_proc_unreadable", "adopt_platform_unsupported",
37
40
  "adopt_registry_conflict", "adopt_registry_entry_invalid", "adopt_registry_invalid",
38
41
  "adopt_registry_unsafe", "adopt_config_env_invalid", "adopt_env_file_invalid",
@@ -269,6 +269,8 @@ export const NODE_TOOL_CLASS: Readonly<Record<string, ToolClass>> = {
269
269
  broadcast: "broadcast",
270
270
  // 任务看板:在 requirements.ts 里按卡判(REST 与 MCP 同一处)
271
271
  requirements_list: "requirements", requirements_people: "requirements", requirements_get: "requirements", requirements_create: "requirements",
272
+ // #1894 —— 组织架构:读是常规读,写由工具内部再判网络 owner/admin(普通成员/节点写会被拒并 audit)。
273
+ departments: "requirements",
272
274
  requirements_update: "requirements", requirements_checklist_toggle: "requirements", requirements_upsert_by_external_ref: "requirements",
273
275
  requirements_events: "requirements", requirements_comment: "requirements", projects_list: "requirements",
274
276
  // 定时任务(#733):在 schedule-agent.ts 里按排程判(范围 / 目标 = send_task 的 dispatchVerdict / 配额)
@@ -0,0 +1,308 @@
1
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, setSystemTime, test } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+
6
+ const dir = mkdtempSync(join(tmpdir(), "anet-session-control-"));
7
+ process.env.COMMHUB_DB ||= join(dir, "hub.db");
8
+ const root = "/api/node-session-control";
9
+ let server: any, base: string, db: any, net: string, otherNet: string;
10
+ let owner: any, admin: any, member: any, outsider: any, ntok: any, peer: any;
11
+ let legacyOwner: any, legacyAdmin: any, legacyMember: any, legacyOutsider: any, legacyFull: any, legacyNetwork: any;
12
+ let binding = { session_id: "session_1", binding_id: "binding_1" }, serial = 0;
13
+ const nodeId = "n_session_control", peerId = "n_session_control_peer";
14
+ async function api(token: string, path: string, value?: unknown, method?: string) {
15
+ const res = await fetch(`${base}${path}`, { method: method ?? (value === undefined ? "GET" : "POST"),
16
+ headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
17
+ ...(value === undefined ? {} : { body: JSON.stringify(value) }) });
18
+ return { status: res.status, body: await res.json() as any };
19
+ }
20
+ const registerBridge = (v = binding, token = ntok.token!) => api(token, `${root}/bridge`,
21
+ { ...v, expected: { node_id: nodeId, network_id: net } });
22
+ const enqueue = (operation = "background", extra = {}) => api(owner.token!, root,
23
+ { network_id: net, node_id: nodeId, operation, expected: binding, ...extra });
24
+ const pull = (token = ntok.token!, v = binding) => api(token, `${root}/pull`, v);
25
+ const read = (request: string, token = owner.token!, network = net) => api(token, `${root}/${request}?network_id=${network}`);
26
+ const state = () => ({ ...binding, supported: true, active: true, can_request_background: true, observed_at: Date.now() });
27
+ const ack = (request: string, result: unknown, extra = {}) => api(ntok.token!, `${root}/${request}/ack`,
28
+ { ...binding, status: "done", result, ...extra });
29
+
30
+ beforeAll(async () => {
31
+ ({ db } = await import("./db.js"));
32
+ const { register, addNetworkMember, createNetworkTokenForNode, createToken } = await import("./auth.js");
33
+ const stamp = Date.now();
34
+ admin = register(`sc_admin_${stamp}`, "SessionControl-Test-1!");
35
+ owner = register(`sc_owner_${stamp}`, "SessionControl-Test-2!");
36
+ member = register(`sc_member_${stamp}`, "SessionControl-Test-3!");
37
+ outsider = register(`sc_other_${stamp}`, "SessionControl-Test-4!");
38
+ expect(admin.ok && owner.ok && member.ok && outsider.ok).toBe(true);
39
+ net = admin.network_id!; otherNet = outsider.network_id!;
40
+ for (const u of [owner, member]) expect(addNetworkMember(net, u.user.user_id, "member", admin.user.user_id, { agentAccess: "all" }).ok).toBe(true);
41
+ // Real atok hashes via the legacy issuer; only fixture rows are converted
42
+ // to the historical global scope=user shape. No prefix-based authority.
43
+ const legacyUser = (u: any) => {
44
+ const t = createToken(u.user.user_id, "sc-legacy-user");
45
+ expect(t.ok).toBe(true);
46
+ expect(t.token).toMatch(/^atok_/);
47
+ db.run("UPDATE api_tokens SET scope='user' WHERE token_id=?1", t.token_id);
48
+ return t;
49
+ };
50
+ legacyOwner = legacyUser(owner); legacyAdmin = legacyUser(admin);
51
+ legacyMember = legacyUser(member); legacyOutsider = legacyUser(outsider);
52
+ legacyFull = createToken(owner.user.user_id, "sc-legacy-global-full");
53
+ legacyNetwork = createToken(owner.user.user_id, "sc-legacy-network", net);
54
+ expect(legacyFull.ok && legacyNetwork.ok).toBe(true);
55
+ ntok = createNetworkTokenForNode(owner.user.user_id, net, "sc-target", nodeId);
56
+ peer = createNetworkTokenForNode(owner.user.user_id, net, "sc-peer", peerId);
57
+ expect(ntok.ok && peer.ok).toBe(true);
58
+ db.run("INSERT INTO sessions(resume_id,alias,node_id,network_id,status) VALUES (?1,?2,?3,?4,'idle')", ["r_sc_target", "sc-target", nodeId, net]);
59
+ const { bootServer } = await import("./server.js");
60
+ server = bootServer({ port: 0, hostname: "127.0.0.1" });
61
+ base = `http://127.0.0.1:${server.port}`;
62
+ }, 30_000);
63
+ beforeEach(async () => {
64
+ binding = { session_id: `session_${++serial}`, binding_id: `binding_${serial}` };
65
+ expect((await registerBridge()).status).toBe(200);
66
+ });
67
+ afterEach(() => setSystemTime());
68
+ afterAll(() => { server?.stop(true); rmSync(dir, { recursive: true, force: true }); });
69
+
70
+ describe("#2017 human-to-exact-Bridge HTTP relay", () => {
71
+ test("[2017 identity] missing or malformed expected is refused without fallback", async () => {
72
+ for (const expected of [undefined, {}, { node_id: nodeId }, { node_id: nodeId, network_id: net, token_id: ntok.token_id }]) {
73
+ const rejected = await api(ntok.token!, `${root}/bridge`, { ...binding, expected });
74
+ expect(rejected.status).toBe(400);
75
+ expect(rejected.body.error).toBe("session_control_request_invalid");
76
+ }
77
+ expect((await registerBridge()).status).toBe(200);
78
+ });
79
+ test("[2017 identity] A credential expected B cannot sweep, replace or retire the valid binding", async () => {
80
+ const made = await enqueue("state"), clock = Date.now();
81
+ const tokenBefore = db.get("SELECT token_id,bound_node_id,network_id FROM api_tokens WHERE token_id=?1", ntok.token_id);
82
+ const nodeBefore = db.get("SELECT node_id,network_id,owner_user_id FROM nodes WHERE node_id=?1", nodeId);
83
+ // Advancing beyond the request deadline exposes an accidental sweep before
84
+ // rejecting expected. Restore only the clock, never the authoritative queue.
85
+ setSystemTime(new Date(clock + 16_000));
86
+ for (const expected of [{ node_id: peerId, network_id: net }, { node_id: nodeId, network_id: otherNet }]) {
87
+ const rejected = await api(ntok.token!, `${root}/bridge`, {
88
+ session_id: "would_replace_session", binding_id: "would_retire_binding", expected,
89
+ });
90
+ expect(rejected.status).toBe(409);
91
+ expect(rejected.body.error).toBe("session_control_changed");
92
+ }
93
+ setSystemTime(new Date(clock));
94
+ expect((await read(made.body.request_id)).body.status).toBe("pending");
95
+ expect((await registerBridge()).status).toBe(200); // Original generation was not retired.
96
+ expect((await pull()).body.request).toMatchObject({ request_id: made.body.request_id, ...binding });
97
+ expect(db.get("SELECT token_id,bound_node_id,network_id FROM api_tokens WHERE token_id=?1", ntok.token_id)).toEqual(tokenBefore);
98
+ expect(db.get("SELECT node_id,network_id,owner_user_id FROM nodes WHERE node_id=?1", nodeId)).toEqual(nodeBefore);
99
+ });
100
+ test("[2017 identity] success carries the authenticated token row identity, never credential material", async () => {
101
+ const response = await registerBridge();
102
+ const row = db.get("SELECT token_id,bound_node_id,network_id FROM api_tokens WHERE token_id=?1", ntok.token_id);
103
+ expect(response.status).toBe(200);
104
+ expect(response.body.identity).toEqual({ node_id: row.bound_node_id, network_id: row.network_id, token_id: row.token_id });
105
+ expect(Object.keys(response.body).sort()).toEqual(["expires_at", "identity", "ok"]);
106
+ expect(JSON.stringify(response.body)).not.toContain(ntok.token);
107
+ });
108
+ test("normal state and background receipts are separate from actual tool completion", async () => {
109
+ const s = await enqueue("state");
110
+ expect(s.body).toMatchObject({ ok: true, status: "pending" });
111
+ expect(s.body.request_id).toMatch(/^sc_\d{13}_[A-Za-z0-9_-]{8,64}$/);
112
+ expect((await pull()).body.request).toMatchObject({ request_id: s.body.request_id, operation: "state", ...binding });
113
+ const observed = state();
114
+ expect((await ack(s.body.request_id, observed)).body.status).toBe("done");
115
+ expect((await read(s.body.request_id)).body.result).toEqual(observed);
116
+ const b = await enqueue();
117
+ expect(b.body.request_id).toMatch(/^bg_\d{13}_[A-Za-z0-9_-]{8,64}$/);
118
+ expect(b.body.expires_at).toBeGreaterThan(Date.now());
119
+ await pull();
120
+ const result = { outcome: "request_processed", state: state() };
121
+ expect((await ack(b.body.request_id, result)).body.status).toBe("done");
122
+ expect((await read(b.body.request_id)).body).toMatchObject({ ok: true, status: "done", result });
123
+ });
124
+ test("[2017 review] already_idle cannot confirm an active or background-capable state", async () => {
125
+ for (const flags of [
126
+ { active: true, can_request_background: true },
127
+ { active: true, can_request_background: false },
128
+ { active: false, can_request_background: true },
129
+ ]) {
130
+ const made = await enqueue(); await pull();
131
+ const reply = await ack(made.body.request_id, { outcome: "already_idle", state: { ...state(), ...flags } });
132
+ expect(reply.status).toBe(400);
133
+ expect(reply.body.error).toBe("session_control_invalid_response");
134
+ expect((await read(made.body.request_id)).body).toMatchObject({ status: "unknown", error: "session_control_invalid_response" });
135
+ }
136
+ });
137
+ test("[2017 review] legacy user atok owner/admin and global full can create and read control", async () => {
138
+ for (const t of [legacyOwner, legacyAdmin, legacyFull]) {
139
+ for (const operation of ["state", "background"]) {
140
+ const made = await api(t.token, root, { network_id: net, node_id: nodeId, operation, expected: binding });
141
+ expect(made.status).toBe(200);
142
+ expect((await read(made.body.request_id, t.token)).body.status).toBe("pending");
143
+ expect((await pull()).body.request.request_id).toBe(made.body.request_id);
144
+ const result = operation === "state" ? state() : { outcome: "request_processed", state: state() };
145
+ expect((await ack(made.body.request_id, result)).body.status).toBe("done");
146
+ expect((await read(made.body.request_id, t.token)).body.result).toEqual(result);
147
+ }
148
+ }
149
+ });
150
+ test("[2017 review] legacy atok still requires current target ownership and exact network", async () => {
151
+ for (const t of [legacyMember, legacyOutsider]) {
152
+ expect((await api(t.token, root, { network_id: net, node_id: nodeId, operation: "state" })).status).toBe(403);
153
+ }
154
+ expect((await api(legacyOwner.token, root, { network_id: otherNet, node_id: nodeId, operation: "state" })).status).toBe(403);
155
+ const made = await enqueue("state");
156
+ for (const t of [legacyMember, legacyOutsider]) expect((await read(made.body.request_id, t.token)).status).toBe(403);
157
+ expect((await read(made.body.request_id, legacyOwner.token, otherNet)).status).toBe(403);
158
+ });
159
+ test("[2017 review] legacy atok user and unbound network credentials cannot impersonate Bridge", async () => {
160
+ const made = await enqueue("state");
161
+ for (const t of [legacyOwner, legacyAdmin, legacyFull, legacyNetwork]) {
162
+ expect((await registerBridge(binding, t.token)).status).toBe(403);
163
+ expect((await pull(t.token)).status).toBe(403);
164
+ expect((await api(t.token, `${root}/${made.body.request_id}/ack`, { ...binding, status: "done", result: state() })).status).toBe(403);
165
+ }
166
+ expect((await read(made.body.request_id)).body.status).toBe("pending");
167
+ expect((await pull()).body.request.request_id).toBe(made.body.request_id);
168
+ });
169
+ test("[2017 review] unknown, revoked, expired or unsupported-scope credentials stay refused", async () => {
170
+ const request = () => api(legacyOwner.token, root, { network_id: net, node_id: nodeId, operation: "state" });
171
+ expect((await api("atok_00000000000000000000000000000000", root, {})).status).toBe(401);
172
+ for (const patch of ["revoked_at=datetime('now')", "expires_at=datetime('now','-1 day')", "scope='unsupported'"]) {
173
+ db.run(`UPDATE api_tokens SET ${patch} WHERE token_id=?1`, legacyOwner.token_id);
174
+ try { expect((await request()).status).toBeGreaterThanOrEqual(400); }
175
+ finally { db.run("UPDATE api_tokens SET revoked_at=NULL,expires_at=NULL,scope='user' WHERE token_id=?1", legacyOwner.token_id); }
176
+ }
177
+ expect((await request()).status).toBe(200);
178
+ });
179
+ test("SSE is a scoped doorbell only; the request lives in pull", async () => {
180
+ const abort = new AbortController();
181
+ const response = await fetch(`${base}/events/sc-target`, { headers: { authorization: `Bearer ${ntok.token}` }, signal: abort.signal });
182
+ expect(response.status).toBe(200);
183
+ const reader = response.body!.getReader();
184
+ try {
185
+ const made = await enqueue();
186
+ let data = "";
187
+ for (let i = 0; i < 4 && !data.includes('"type":"session_control"'); i++) {
188
+ const next = await reader.read(); data += new TextDecoder().decode(next.value);
189
+ }
190
+ const event = data.split("\n").filter(s => s.startsWith("data: ")).map(s => JSON.parse(s.slice(6))).find(s => s.type === "session_control");
191
+ expect(event).toEqual({ type: "session_control", request_id: made.body.request_id });
192
+ expect((await pull()).body.request.request_id).toBe(made.body.request_id);
193
+ } finally { abort.abort(); await reader.cancel().catch(() => {}); }
194
+ });
195
+ test("node, peer, anonymous, master and member cannot impersonate human owner/admin", async () => {
196
+ for (const token of [ntok.token, peer.token, "", "legacy-test-only", member.token, outsider.token]) {
197
+ expect((await api(token, root, { network_id: net, node_id: nodeId, operation: "state" })).status).toBeGreaterThanOrEqual(400);
198
+ }
199
+ const adminMade = await api(admin.token!, root, { network_id: net, node_id: nodeId, operation: "state" });
200
+ expect(adminMade.status).toBe(200);
201
+ expect((await read(adminMade.body.request_id, member.token!)).status).toBe(403);
202
+ expect((await read(adminMade.body.request_id, ntok.token!)).status).toBe(403);
203
+ expect((await read(adminMade.body.request_id, owner.token!, otherNet)).status).toBe(403);
204
+ });
205
+ test("cross-network, wrong expected, absent expected and command/path keys are refused", async () => {
206
+ expect((await enqueue("background", { network_id: otherNet })).status).toBe(403);
207
+ expect((await enqueue("background", { expected: { ...binding, session_id: "other_session" } })).body.error).toBe("session_control_changed");
208
+ expect((await api(owner.token!, root, { network_id: net, node_id: nodeId, operation: "background" })).status).toBe(400);
209
+ for (const key of ["url", "path", "keys", "shell"]) expect((await enqueue("state", { [key]: "unsafe" })).status).toBe(400);
210
+ expect((await registerBridge({ ...binding, node_id: peerId } as any)).status).toBe(400);
211
+ });
212
+ test("peer token and legacy unbound token never receive target requests", async () => {
213
+ const { createNetworkTokenForNode } = await import("./auth.js");
214
+ const legacy = createNetworkTokenForNode(owner.user.user_id, net, "sc-unbound");
215
+ expect(legacy.ok).toBe(true);
216
+ // Same-holder refreshes now bind existing rows. Construct an actual old
217
+ // unbound credential whose name only hints at this target instead.
218
+ db.run("UPDATE api_tokens SET name='node:sc-target' WHERE token_id=?1", legacy.token_id);
219
+ expect(db.get("SELECT bound_node_id FROM api_tokens WHERE token_id=?1", legacy.token_id).bound_node_id).toBeNull();
220
+ expect((await registerBridge(binding, legacy.token!)).status).toBe(403);
221
+ expect((await registerBridge(binding, owner.token!)).status).toBe(403);
222
+ const made = await enqueue();
223
+ expect((await pull(peer.token!)).body.request).toBeUndefined();
224
+ expect((await pull()).body.request.request_id).toBe(made.body.request_id);
225
+ expect((await api(peer.token!, `${root}/${made.body.request_id}/ack`, { ...binding, status: "done", result: state() })).status).toBe(409);
226
+ });
227
+ test("concurrent claims yield exactly one delivery and do not replay", async () => {
228
+ const made = await enqueue();
229
+ expect((await enqueue()).status).toBe(429);
230
+ const claims = await Promise.all([pull(), pull(), pull()]);
231
+ expect(claims.filter(r => r.body.request?.request_id === made.body.request_id)).toHaveLength(1);
232
+ expect((await read(made.body.request_id)).body.status).toBe("in_progress");
233
+ expect((await pull()).body.request).toBeNull();
234
+ });
235
+ test("expired pending request is never delivered", async () => {
236
+ const made = await enqueue();
237
+ setSystemTime(new Date(Date.now() + 16_000));
238
+ expect((await pull()).body.request).toBeNull();
239
+ expect((await read(made.body.request_id)).body.status).toBe("expired");
240
+ });
241
+ test("lost ACK becomes unknown, never success; late ACK and repeated pull cannot replay", async () => {
242
+ const made = await enqueue(); await pull();
243
+ setSystemTime(new Date(Date.now() + 16_000));
244
+ expect((await read(made.body.request_id)).body).toMatchObject({ status: "unknown", error: "session_background_outcome_unknown" });
245
+ expect((await ack(made.body.request_id, { outcome: "request_processed", state: state() })).status).toBe(409);
246
+ expect((await pull()).body.request).toBeNull();
247
+ });
248
+ test("new generation invalidates pending and cannot be overwritten by delayed old Bridge", async () => {
249
+ const old = { ...binding }, made = await enqueue();
250
+ binding = { session_id: "new_session", binding_id: `new_binding_${serial}` };
251
+ expect((await registerBridge()).status).toBe(200);
252
+ expect((await registerBridge(old)).body.error).toBe("session_control_changed");
253
+ expect((await pull(ntok.token!, old)).body.error).toBe("session_control_changed");
254
+ expect((await read(made.body.request_id)).body).toMatchObject({ status: "failed", error: "session_control_changed" });
255
+ expect((await pull()).body.request).toBeNull();
256
+ });
257
+ test("handle change after claim is unknown and old-session ACK cannot complete", async () => {
258
+ const old = { ...binding }, made = await enqueue(); await pull();
259
+ binding = { ...binding, binding_id: `changed_${serial}` }; await registerBridge();
260
+ expect((await api(ntok.token!, `${root}/${made.body.request_id}/ack`, { ...old, status: "done", result: { outcome: "already_idle", state: { ...state(), ...old } } })).status).toBe(409);
261
+ expect((await read(made.body.request_id)).body.status).toBe("unknown");
262
+ });
263
+ test("requester revocation before pull prevents delivery and current read permission is rechecked", async () => {
264
+ const made = await enqueue();
265
+ db.run("UPDATE network_members SET role='member' WHERE network_id=?1 AND user_id=?2", [net, owner.user.user_id]);
266
+ db.run("UPDATE nodes SET owner_user_id=?1 WHERE node_id=?2", [member.user.user_id, nodeId]);
267
+ try {
268
+ expect((await pull()).body.request).toBeNull();
269
+ expect((await read(made.body.request_id)).status).toBe(403);
270
+ expect((await read(made.body.request_id, admin.token!)).body.status).toBe("failed");
271
+ } finally { db.run("UPDATE nodes SET owner_user_id=?1 WHERE node_id=?2", [owner.user.user_id, nodeId]); }
272
+ });
273
+ test("target token revocation is checked at pull, not only registration", async () => {
274
+ const made = await enqueue();
275
+ db.run("UPDATE api_tokens SET revoked_at=datetime('now') WHERE token_id=?1", ntok.token_id);
276
+ try {
277
+ expect((await pull()).status).toBeGreaterThanOrEqual(400);
278
+ expect((await read(made.body.request_id)).body.status).toBe("failed");
279
+ } finally { db.run("UPDATE api_tokens SET revoked_at=NULL WHERE token_id=?1", ntok.token_id); }
280
+ });
281
+ test("malformed runtime results and raw exception strings never become success or leak", async () => {
282
+ const made = await enqueue(); await pull();
283
+ const reply = await ack(made.body.request_id, { outcome: "request_processed", state: { ...state(), binding_id: "wrong" } });
284
+ expect(reply.status).toBe(400);
285
+ expect((await read(made.body.request_id)).body).toMatchObject({ status: "unknown", error: "session_control_invalid_response" });
286
+ const next = await enqueue(); await pull();
287
+ const secret = "http://secret-host/?token=ntok_secret";
288
+ expect((await api(ntok.token!, `${root}/${next.body.request_id}/ack`, { ...binding, status: "failed", error: secret })).body.error).toBe("session_control_invalid_response");
289
+ expect(JSON.stringify((await read(next.body.request_id)).body)).not.toContain(secret);
290
+ });
291
+ test("known runtime refusal is failed; already_idle receipt is accepted", async () => {
292
+ const made = await enqueue(); await pull();
293
+ expect((await api(ntok.token!, `${root}/${made.body.request_id}/ack`, { ...binding, status: "failed", error: "session_control_unsupported" })).body.status).toBe("failed");
294
+ const next = await enqueue(); await pull();
295
+ expect((await ack(next.body.request_id, { outcome: "already_idle", state: { ...state(), active: false, can_request_background: false } })).body.status).toBe("done");
296
+ });
297
+ test("lease expiry refuses new work and Hub restart loses IDs without replay", async () => {
298
+ const made = await enqueue();
299
+ setSystemTime(new Date(Date.now() + 76_000));
300
+ expect((await enqueue()).body.error).toBe("session_control_unavailable");
301
+ setSystemTime();
302
+ server.stop(true);
303
+ const { bootServer } = await import("./server.js");
304
+ server = bootServer({ port: 0, hostname: "127.0.0.1" }); base = `http://127.0.0.1:${server.port}`;
305
+ expect((await read(made.body.request_id)).status).toBe(404);
306
+ expect((await pull()).body.error).toBe("session_control_changed");
307
+ });
308
+ });