@sleep2agi/commhub-server 0.9.0-preview.4 → 0.9.0-preview.40

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.
Files changed (117) hide show
  1. package/README.md +125 -7
  2. package/bin/commhub.ts +10 -2
  3. package/package.json +5 -5
  4. package/src/ack-create-request-transport.test.ts +159 -0
  5. package/src/ack-create-request.test.ts +331 -0
  6. package/src/admin-networks-http.test.ts +84 -0
  7. package/src/alias-filter.test.ts +72 -0
  8. package/src/alias-filter.ts +32 -0
  9. package/src/api-host-supervisors-fallback.test.ts +267 -0
  10. package/src/api-nodes-shape.test.ts +107 -13
  11. package/src/auth.ts +93 -14
  12. package/src/avatar-validate.test.ts +163 -0
  13. package/src/avatar-validate.ts +103 -0
  14. package/src/boot-side-effect.test.ts +124 -0
  15. package/src/config-apply-sec1.test.ts +107 -0
  16. package/src/config-apply-validate.test.ts +92 -0
  17. package/src/config-apply-validate.ts +81 -3
  18. package/src/create-node-tool-schema.test.ts +39 -0
  19. package/src/create-node-validate.test.ts +80 -1
  20. package/src/create-node-validate.ts +49 -5
  21. package/src/create-node.ts +51 -1
  22. package/src/dashboard-slash-routing-http.test.ts +103 -0
  23. package/src/db-adapter-guard.test.ts +85 -0
  24. package/src/db-adapter.ts +64 -23
  25. package/src/db-timestamp.test.ts +38 -0
  26. package/src/db-timestamp.ts +15 -0
  27. package/src/db.ts +622 -33
  28. package/src/delivered-without-push.test.ts +140 -0
  29. package/src/desktop-message.test.ts +286 -0
  30. package/src/ext-token-shared.test.ts +60 -0
  31. package/src/ext-validation-regression.test.ts +131 -0
  32. package/src/external-schedule-edits-http.test.ts +316 -0
  33. package/src/external-schedule-edits.ts +314 -0
  34. package/src/file-download-authz-dev-open.test.ts +135 -0
  35. package/src/file-download-authz.test.ts +403 -0
  36. package/src/file-download-date-bucket.test.ts +436 -0
  37. package/src/file-network-scope-dev-open.test.ts +97 -0
  38. package/src/file-network-scope.test.ts +731 -0
  39. package/src/health-redaction.test.ts +637 -0
  40. package/src/inbox-count-sse.test.ts +310 -0
  41. package/src/inbox-count.ts +29 -0
  42. package/src/index.ts +15 -2187
  43. package/src/lifecycle-guard.ts +50 -0
  44. package/src/list-host-supervisors.test.ts +314 -0
  45. package/src/mcp-write-network-resolution.test.ts +429 -0
  46. package/src/network-name-http.test.ts +71 -0
  47. package/src/network-scope.ts +101 -0
  48. package/src/node-attrs-validate.ts +88 -0
  49. package/src/node-attrs.test.ts +213 -0
  50. package/src/node-id-alias.test.ts +147 -0
  51. package/src/node-lifecycle-controllable.test.ts +64 -0
  52. package/src/node-lifecycle-controllable.ts +51 -0
  53. package/src/observer-avatar-http.test.ts +424 -0
  54. package/src/observer-push.test.ts +252 -0
  55. package/src/package-version-consistency.test.ts +25 -0
  56. package/src/peer-reply-atomic.test.ts +455 -0
  57. package/src/peer-reply-capability.test.ts +70 -0
  58. package/src/probe-validate.test.ts +221 -0
  59. package/src/probe-validate.ts +104 -0
  60. package/src/probe.test.ts +80 -0
  61. package/src/probe.ts +200 -0
  62. package/src/push.ts +163 -7
  63. package/src/reply-target-derivation.test.ts +96 -0
  64. package/src/require-explicit-test-db.ts +9 -0
  65. package/src/resolve-port.test.ts +57 -0
  66. package/src/resolve-port.ts +44 -0
  67. package/src/response-charset.test.ts +130 -0
  68. package/src/rest-explicit-columns-http.test.ts +212 -0
  69. package/src/rest-identity.test.ts +129 -0
  70. package/src/rest-identity.ts +100 -0
  71. package/src/rest-projections.ts +67 -0
  72. package/src/rest-write-network-resolution.test.ts +133 -0
  73. package/src/runtime-label.test.ts +22 -0
  74. package/src/runtime-label.ts +21 -0
  75. package/src/scheduled-run-terminal.test.ts +268 -0
  76. package/src/scheduled-tasks-http.test.ts +479 -0
  77. package/src/scheduled-tasks.ts +549 -0
  78. package/src/send-reply-agent-warning.test.ts +202 -0
  79. package/src/send-reply-alias-optional.test.ts +131 -0
  80. package/src/send-reply-attachments.test.ts +437 -0
  81. package/src/server.ts +3591 -0
  82. package/src/shared/external-schedule-contract.ts +60 -0
  83. package/src/shared/probe-host-allowlist-drift.test.ts +54 -0
  84. package/src/shared/probe-host-allowlist.ts +128 -0
  85. package/src/side-thread-command-transport.test.ts +302 -0
  86. package/src/side-thread-command-transport.ts +318 -0
  87. package/src/side-thread-http-integration.test.ts +229 -0
  88. package/src/side-thread-http.ts +504 -0
  89. package/src/side-thread-production.test.ts +72 -0
  90. package/src/side-thread-production.ts +118 -0
  91. package/src/side-thread.test.ts +1067 -0
  92. package/src/side-thread.ts +2495 -0
  93. package/src/skillhub-http.test.ts +88 -0
  94. package/src/start-node.test.ts +109 -0
  95. package/src/stop-delete-node.test.ts +952 -0
  96. package/src/task-auth-origin-http.test.ts +87 -0
  97. package/src/task-auth-origin.test.ts +24 -0
  98. package/src/task-auth-origin.ts +7 -0
  99. package/src/task-consumption.test.ts +471 -0
  100. package/src/task-diagnostic-http.test.ts +145 -0
  101. package/src/task-diagnostic.test.ts +115 -0
  102. package/src/task-diagnostic.ts +118 -0
  103. package/src/task-idempotency-mcp.test.ts +142 -0
  104. package/src/task-idempotency.test.ts +60 -0
  105. package/src/task-idempotency.ts +75 -0
  106. package/src/task-lifecycle-watcher.test.ts +200 -0
  107. package/src/task-lifecycle-watcher.ts +49 -0
  108. package/src/task-network-resolution-http.test.ts +137 -0
  109. package/src/tasks-pagination-http.test.ts +137 -0
  110. package/src/tools.ts +2952 -191
  111. package/src/update-provider.test.ts +234 -0
  112. package/src/upload-early-reject-guard.test.ts +155 -0
  113. package/src/uploads-http.test.ts +92 -14
  114. package/src/uploads.test.ts +66 -0
  115. package/src/uploads.ts +130 -5
  116. package/src/vault.test.ts +200 -0
  117. package/src/vault.ts +156 -0
@@ -0,0 +1,140 @@
1
+ // f015d9d6 症状①:tasks.status 被置为 'delivered',而 SSE 推送根本没发出。
2
+ //
3
+ // send_task 无条件以字面量 'delivered' 插入 tasks 行,推送却另外被
4
+ // `if (target.state === "online")` 挡着(tools.ts:1432)。同一段代码里给
5
+ // network observer 的事件把同一件事标成 "queued" —— 也就是说代码**知道**
6
+ // 它没投出去,却仍然把 delivered 写进了 tasks.status。
7
+ //
8
+ // 而 "online" 的判据是 resolveDeliveryTarget 里的
9
+ // stale = now - (last_seen_at || updated_at) > 5 分钟
10
+ // 且 last_seen_at 只在 report_status 时刷新(tools.ts:674/702)。
11
+ // 全网「停发心跳/状态类消息」的省额度规则之下,一个安静的 MCP 会话必然变 stale。
12
+ import { afterAll, beforeEach, describe, expect, test } from "bun:test";
13
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
14
+ import { db } from "./db.js";
15
+ import { registerTools } from "./tools.js";
16
+ import { __resetSSEClientsForTest, createSSEStream } from "./push.js";
17
+
18
+ const NET = "net_delivered_push_f015";
19
+ const USER = "user_delivered_push_f015";
20
+ const SENDER = "sender-f015b";
21
+ const TARGET = "quiet-mcp-f015b";
22
+
23
+ type ToolHandler = (args: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
24
+
25
+ function cleanup() {
26
+ __resetSSEClientsForTest();
27
+ for (const t of ["tasks", "inbox", "task_events", "sessions", "api_tokens", "nodes", "rename_txn"]) {
28
+ try { db.run(`DELETE FROM ${t} WHERE network_id = ?1`, [NET]); } catch {}
29
+ }
30
+ try { db.run("DELETE FROM network_members WHERE network_id = ?1", [NET]); } catch {}
31
+ try { db.run("DELETE FROM networks WHERE network_id = ?1", [NET]); } catch {}
32
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [USER]); } catch {}
33
+ }
34
+
35
+ /** minutesAgo 控制目标会话的 last_seen_at —— 这是本文件唯一要动的变量。 */
36
+ function seed(minutesAgo: number) {
37
+ db.run("INSERT INTO users (user_id, username, password_hash, role, created_at) VALUES (?1, ?1, 'x', 'admin', datetime('now'))", [USER]);
38
+ db.run("INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))", [NET, USER]);
39
+ db.run("INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, 'owner', datetime('now'))", [USER, NET]);
40
+ for (const alias of [SENDER, TARGET]) {
41
+ db.run(
42
+ "INSERT INTO api_tokens (token_id, token_hash, user_id, network_id, name, scope) VALUES (?1, ?2, ?3, ?4, ?5, 'network')",
43
+ [`token-${alias}`, `hash-${alias}`, USER, NET, `node:${alias}`],
44
+ );
45
+ }
46
+ db.run(
47
+ "INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at) VALUES (?1, ?1, 'idle', NULL, ?2, datetime('now'), datetime('now', ?3))",
48
+ [SENDER, NET, "-1 minutes"],
49
+ );
50
+ // 🔴 目标:MCP 挂载的 claude-code 会话形状(node_id NULL),
51
+ // status 是 'idle'(不是 offline),只是安静了 minutesAgo 分钟。
52
+ db.run(
53
+ "INSERT INTO sessions (resume_id, alias, status, node_id, network_id, updated_at, last_seen_at) VALUES (?1, ?2, 'idle', NULL, ?3, datetime('now', ?4), datetime('now', ?4))",
54
+ [`cc-n_${TARGET}`, TARGET, NET, `-${minutesAgo} minutes`],
55
+ );
56
+ }
57
+
58
+ function toolsFor(alias: string): Record<string, ToolHandler> {
59
+ const server = new McpServer({ name: "delivered-push-f015", version: "0" }) as any;
60
+ const tools: Record<string, ToolHandler> = {};
61
+ const original = server.tool.bind(server);
62
+ server.tool = (name: string, description: string, schema: any, handler: ToolHandler) => {
63
+ tools[name] = handler; return original(name, description, schema, handler);
64
+ };
65
+ registerTools(server, undefined, NET, USER, alias, true, `token-${alias}`);
66
+ return tools;
67
+ }
68
+
69
+ /** 从订阅者那一侧真正读出字节 —— 地面真相,不是连接计数。 */
70
+ async function readUntil(reader: ReadableStreamDefaultReader<Uint8Array>, needle: string, ms = 1500): Promise<string | null> {
71
+ const dec = new TextDecoder();
72
+ let buf = "";
73
+ const deadline = Date.now() + ms;
74
+ while (Date.now() < deadline) {
75
+ const chunk = await Promise.race([
76
+ reader.read(),
77
+ new Promise<{ done: true; value: undefined }>((r) => setTimeout(() => r({ done: true, value: undefined }), 200)),
78
+ ]);
79
+ if (chunk.value) buf += dec.decode(chunk.value, { stream: true });
80
+ if (buf.includes(needle)) return buf;
81
+ }
82
+ return null;
83
+ }
84
+
85
+ let sendSeq = 0;
86
+ /** minutesAgo = 目标安静了多久;withSubscriber = 是否真的有人连着。 */
87
+ async function send(minutesAgo: number, withSubscriber = true) {
88
+ cleanup(); seed(minutesAgo);
89
+ let reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
90
+ if (withSubscriber) {
91
+ const res = createSSEStream(TARGET, NET);
92
+ reader = res.body!.getReader();
93
+ }
94
+ const r = await toolsFor(SENDER).send_task({ alias: TARGET, task: `ping-${++sendSeq}`, priority: "normal" });
95
+ const out = JSON.parse(r.content[0].text);
96
+ const row = db.get<{ status: string }>("SELECT status FROM tasks WHERE to_name = ?1 AND network_id = ?2", TARGET, NET);
97
+ const received = reader ? await readUntil(reader, "new_task") : null;
98
+ return { out, taskStatus: row?.status ?? null, received };
99
+ }
100
+
101
+ describe("f015d9d6 症状① — delivered 置位与实际推送不同步", () => {
102
+ beforeEach(() => cleanup());
103
+ afterAll(() => cleanup());
104
+
105
+ test("对照:目标 1 分钟前还活跃 ⇒ 订阅者收到 new_task", async () => {
106
+ const { taskStatus, received } = await send(1);
107
+ expect(taskStatus).toBe("delivered");
108
+ expect(received).toContain("new_task");
109
+ });
110
+
111
+ test("🔴 核心:目标安静 10 分钟但订阅者连着 ⇒ 仍然收得到(修复前这里收到 null)", async () => {
112
+ const { taskStatus, received } = await send(10);
113
+ expect(received).toContain("new_task");
114
+ expect(taskStatus).toBe("delivered"); // 现在这个 delivered 是真话了
115
+ });
116
+
117
+ test("🔴 可达性判据必须来自订阅者注册表,不是 last_seen_at 的时间戳猜测", async () => {
118
+ // 安静 10 分钟 = 时间戳判据会说 offline;而实际上有人在听。
119
+ const quiet = await send(10);
120
+ expect(quiet.received).toContain("new_task");
121
+ // 真没人听的时候,pushEvent 本来就是 no-op,不需要额外的闸来保护。
122
+ const nobody = await send(10, false);
123
+ expect(nobody.received).toBeNull();
124
+ });
125
+
126
+ test("真的没有订阅者时,tasks 行仍写 delivered —— 本 PR 刻意不改,记录在案", async () => {
127
+ const { taskStatus, received } = await send(10, false);
128
+ expect(received).toBeNull();
129
+ // 这一条钉的是**当前行为**,不是我认为对的行为:没人收到,行仍自称 delivered。
130
+ expect(taskStatus).toBe("delivered");
131
+ });
132
+
133
+ test("活性判据的输入 last_seen_at 只由 report_status 刷新 —— 发送方不刷新它", async () => {
134
+ cleanup(); seed(4);
135
+ const before = db.get<{ last_seen_at: string }>("SELECT last_seen_at FROM sessions WHERE alias = ?1 AND network_id = ?2", TARGET, NET);
136
+ await toolsFor(SENDER).send_task({ alias: TARGET, task: `ping-${++sendSeq}`, priority: "normal" });
137
+ const after = db.get<{ last_seen_at: string }>("SELECT last_seen_at FROM sessions WHERE alias = ?1 AND network_id = ?2", TARGET, NET);
138
+ expect(after?.last_seen_at).toBe(before!.last_seen_at);
139
+ });
140
+ });
@@ -0,0 +1,286 @@
1
+ // Desktop user push contract.
2
+ //
3
+ // Pins the new user-keyed SSE path and send_desktop_message MCP tool:
4
+ // - user streams are keyed by networkId:userId, never alias
5
+ // - ntok callers cannot cross network by supplying a foreign to_user_id/network_id
6
+ // - target ambiguity/miss is fail-closed before audit/push
7
+
8
+ import { afterAll, afterEach, beforeEach, describe, expect, test } from "bun:test";
9
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
10
+ import { db } from "./db.js";
11
+ import { registerTools } from "./tools.js";
12
+ import {
13
+ __resetSSEClientsForTest,
14
+ createUserEventStream,
15
+ pushUserEvent,
16
+ } from "./push.js";
17
+
18
+ const NET_A = "net_desktop_a";
19
+ const NET_B = "net_desktop_b";
20
+ const NODE_USER = "u_desktop_node_owner";
21
+ const SENDER_USER = "u_desktop_sender";
22
+ const TARGET_A = "u_desktop_target_a";
23
+ const TARGET_B = "u_desktop_target_b";
24
+ const TARGET_BOTH = "u_desktop_target_both";
25
+ const ALL_USERS = [NODE_USER, SENDER_USER, TARGET_A, TARGET_B, TARGET_BOTH];
26
+
27
+ type ToolHandler = (args: any) => Promise<{ content: Array<{ type: "text"; text: string }> }>;
28
+
29
+ function cleanup() {
30
+ try { db.run("DELETE FROM audit_log WHERE action = 'send_desktop_message' OR network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {}
31
+ try { db.run("DELETE FROM network_members WHERE network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {}
32
+ try { db.run("DELETE FROM networks WHERE network_id IN (?1, ?2)", [NET_A, NET_B]); } catch {}
33
+ for (const u of ALL_USERS) {
34
+ try { db.run("DELETE FROM users WHERE user_id = ?1", [u]); } catch {}
35
+ }
36
+ }
37
+
38
+ function seed() {
39
+ for (const u of ALL_USERS) {
40
+ db.run(
41
+ `INSERT INTO users (user_id, username, password_hash, role, created_at)
42
+ VALUES (?1, ?2, 'x', 'user', datetime('now'))`,
43
+ [u, `${u}_name`],
44
+ );
45
+ }
46
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET_A, SENDER_USER]);
47
+ db.run(`INSERT INTO networks (network_id, network_name, owner_id, created_at) VALUES (?1, ?1, ?2, datetime('now'))`, [NET_B, TARGET_B]);
48
+ const member = (userId: string, networkId: string, role: string) =>
49
+ db.run(`INSERT INTO network_members (user_id, network_id, role, joined_at) VALUES (?1, ?2, ?3, datetime('now'))`, [userId, networkId, role]);
50
+ member(NODE_USER, NET_A, "member");
51
+ member(SENDER_USER, NET_A, "owner");
52
+ member(TARGET_A, NET_A, "member");
53
+ member(TARGET_B, NET_B, "member");
54
+ member(TARGET_BOTH, NET_A, "member");
55
+ member(TARGET_BOTH, NET_B, "member");
56
+ }
57
+
58
+ beforeEach(() => { cleanup(); seed(); });
59
+ afterEach(() => { __resetSSEClientsForTest(); });
60
+ afterAll(cleanup);
61
+
62
+ function buildHandlers(opts: { netId?: string | null; userId?: string | null; alias?: string | null; isNetwork?: boolean }) {
63
+ const server = new McpServer({ name: "test", version: "0" }) as any;
64
+ const tools: Record<string, ToolHandler> = {};
65
+ const origTool = server.tool.bind(server);
66
+ server.tool = (name: string, _desc: string, schema: any, handler: ToolHandler) => {
67
+ tools[name] = handler;
68
+ return origTool(name, _desc, schema, handler);
69
+ };
70
+ registerTools(
71
+ server,
72
+ undefined,
73
+ opts.netId ?? null,
74
+ opts.userId ?? null,
75
+ opts.alias ?? opts.userId ?? null,
76
+ opts.isNetwork ?? false,
77
+ "tok_desktop_test",
78
+ );
79
+ return tools;
80
+ }
81
+
82
+ async function call(handler: ToolHandler, args: any): Promise<Record<string, any>> {
83
+ const r = await handler(args);
84
+ return JSON.parse(r.content[0].text);
85
+ }
86
+
87
+ async function readFrame(
88
+ reader: ReadableStreamDefaultReader<Uint8Array>,
89
+ timeoutMs = 2_000,
90
+ ): Promise<Record<string, any>> {
91
+ const decoder = new TextDecoder();
92
+ let buf = "";
93
+ const deadline = Date.now() + timeoutMs;
94
+ while (Date.now() < deadline) {
95
+ const result = await Promise.race([
96
+ reader.read(),
97
+ new Promise<never>((_, rej) =>
98
+ setTimeout(() => rej(new Error(`no SSE frame within ${timeoutMs}ms`)), deadline - Date.now()),
99
+ ),
100
+ ]);
101
+ if (result.done) throw new Error("SSE stream ended unexpectedly");
102
+ buf += decoder.decode(result.value, { stream: true });
103
+ const sep = buf.indexOf("\n\n");
104
+ if (sep === -1) continue;
105
+ const rawFrame = buf.slice(0, sep);
106
+ buf = buf.slice(sep + 2);
107
+ const dataLine = rawFrame.split("\n").find((l) => l.startsWith("data: "));
108
+ if (!dataLine) continue;
109
+ return JSON.parse(dataLine.slice(6));
110
+ }
111
+ throw new Error(`no SSE frame within ${timeoutMs}ms`);
112
+ }
113
+
114
+ function auditCount() {
115
+ return db.get<{ n: number }>("SELECT COUNT(*) AS n FROM audit_log WHERE action = 'send_desktop_message'")?.n ?? 0;
116
+ }
117
+
118
+ describe("send_desktop_message", () => {
119
+ test("ntok caller can push to a user in the same bound network", async () => {
120
+ const stream = createUserEventStream(NET_A, TARGET_A);
121
+ const reader = stream.body!.getReader();
122
+ await readFrame(reader);
123
+
124
+ const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
125
+ const result = await call(tools.send_desktop_message, {
126
+ to_user_id: TARGET_A,
127
+ message: "desktop hello",
128
+ severity: "success",
129
+ });
130
+
131
+ expect(result.ok).toBe(true);
132
+ expect(result.message_id).toStartWith("dm_");
133
+ expect(auditCount()).toBe(1);
134
+ const evt = await readFrame(reader);
135
+ expect(evt.type).toBe("desktop_message");
136
+ expect(evt.message_id).toBe(result.message_id);
137
+ expect(evt.message).toBe("desktop hello");
138
+ expect(evt.from).toBe("node-a");
139
+ expect(evt.network_id).toBe(NET_A);
140
+ expect(evt.user_id).toBe(TARGET_A);
141
+ expect(evt.scope).toBe("user");
142
+ await reader.cancel();
143
+ });
144
+
145
+ test("utok caller can push with explicit network_id to a member user", async () => {
146
+ const stream = createUserEventStream(NET_A, TARGET_A);
147
+ const reader = stream.body!.getReader();
148
+ await readFrame(reader);
149
+
150
+ const tools = buildHandlers({ userId: SENDER_USER, alias: "sender-user" });
151
+ const result = await call(tools.send_desktop_message, {
152
+ to_username: `${TARGET_A}_name`,
153
+ network_id: NET_A,
154
+ title: "Heads up",
155
+ message: "from user token",
156
+ });
157
+
158
+ expect(result.ok).toBe(true);
159
+ const evt = await readFrame(reader);
160
+ expect(evt.title).toBe("Heads up");
161
+ expect(evt.message).toBe("from user token");
162
+ expect(evt.network_id).toBe(NET_A);
163
+ await reader.cancel();
164
+ });
165
+
166
+ test("ntok caller cannot cross network with foreign network_id and to_user_id; no audit or push happens first", async () => {
167
+ const stream = createUserEventStream(NET_B, TARGET_B);
168
+ const reader = stream.body!.getReader();
169
+ await readFrame(reader);
170
+
171
+ const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
172
+ const result = await call(tools.send_desktop_message, {
173
+ to_user_id: TARGET_B,
174
+ network_id: NET_B,
175
+ message: "must not cross",
176
+ });
177
+
178
+ expect(result).toEqual({ ok: false, error: "desktop_target_not_in_network", network_id: NET_A });
179
+ expect(auditCount()).toBe(0);
180
+
181
+ pushUserEvent(NET_B, TARGET_B, { type: "desktop_message", message_id: "marker_after_denied" });
182
+ expect((await readFrame(reader)).message_id).toBe("marker_after_denied");
183
+ await reader.cancel();
184
+ });
185
+
186
+ test("missing target is fail-closed before audit", async () => {
187
+ const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
188
+ const result = await call(tools.send_desktop_message, { message: "nobody" });
189
+ expect(result).toEqual({
190
+ ok: false,
191
+ error: "desktop_target_required",
192
+ message: "to_user_id or to_username is required",
193
+ });
194
+ expect(auditCount()).toBe(0);
195
+ });
196
+
197
+ test("conflicting to_user_id and to_username is fail-closed before audit", async () => {
198
+ const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
199
+ const result = await call(tools.send_desktop_message, {
200
+ to_user_id: TARGET_A,
201
+ to_username: `${TARGET_BOTH}_name`,
202
+ message: "conflict",
203
+ });
204
+ expect(result).toEqual({
205
+ ok: false,
206
+ error: "desktop_target_mismatch",
207
+ to_user_id: TARGET_A,
208
+ to_username: `${TARGET_BOTH}_name`,
209
+ });
210
+ expect(auditCount()).toBe(0);
211
+ });
212
+
213
+ test("unknown target is fail-closed before audit", async () => {
214
+ const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
215
+ const result = await call(tools.send_desktop_message, {
216
+ to_username: "not_a_real_user",
217
+ message: "lost",
218
+ });
219
+ expect(result).toEqual({ ok: false, error: "desktop_target_not_found", field: "to_username" });
220
+ expect(auditCount()).toBe(0);
221
+ });
222
+ });
223
+
224
+ // #1459 —— 返回值必须反映真实投递态。
225
+ //
226
+ // send_desktop_message 是 fire-and-forget:只 INSERT audit_log(不写 inbox),
227
+ // 再 pushUserEvent 扇出;而 pushUserEvent 在没有订阅者时静默 return。
228
+ // 用户 dashboard 离线/重连时消息永久丢(无 DB 行 ⇒ 无从 reconcile,
229
+ // /api/messages 只 SELECT ... FROM inbox),而调用方拿到的是 ok:true。
230
+ //
231
+ // 本 PR 只做「让丢失可见」这一半:不碰 schema,返回值说实话。
232
+ // 持久化 + 重连补投(让丢失不发生)是另一条,方向已定为按 user_id 寻址的新表。
233
+ describe("#1459 send_desktop_message 返回值反映真实投递态", () => {
234
+ test("🔴 无活跃订阅者 ⇒ delivered:false + 可判读的 reason,而不是笼统 ok:true", async () => {
235
+ // 故意不建 SSE 流:这正是用户 dashboard 关着的情形
236
+ const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
237
+ const before = auditCount();
238
+ const result = await call(tools.send_desktop_message, {
239
+ to_user_id: TARGET_A,
240
+ message: "nobody is listening",
241
+ severity: "info",
242
+ });
243
+
244
+ // ok 仍为 true:调用被接受、审计已落库 —— 这一格没变
245
+ expect(result.ok).toBe(true);
246
+ expect(result.message_id).toStartWith("dm_");
247
+ expect(auditCount()).toBe(before + 1);
248
+
249
+ // 新增的那一格:投递态
250
+ expect(result.delivered).toBe(false);
251
+ expect(result.reason).toBe("no_live_subscriber");
252
+ });
253
+
254
+ test("有活跃订阅者 ⇒ delivered:true 且不带 reason", async () => {
255
+ const stream = createUserEventStream(NET_A, TARGET_A);
256
+ const reader = stream.body!.getReader();
257
+ await readFrame(reader);
258
+
259
+ const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
260
+ const result = await call(tools.send_desktop_message, {
261
+ to_user_id: TARGET_A,
262
+ message: "someone is listening",
263
+ severity: "info",
264
+ });
265
+
266
+ expect(result.delivered).toBe(true);
267
+ expect(result.reason).toBeUndefined();
268
+ // 前提断言:这一条真的送到了订阅者手上,否则 delivered:true 就是空话
269
+ const evt = await readFrame(reader);
270
+ expect(evt.message).toBe("someone is listening");
271
+ await reader.cancel();
272
+ });
273
+
274
+ test("🔴 两种情形必须给出不同读数 —— 防止判据退化成恒真", async () => {
275
+ const tools = buildHandlers({ netId: NET_A, userId: NODE_USER, alias: "node-a", isNetwork: true });
276
+ const offline = await call(tools.send_desktop_message, { to_user_id: TARGET_A, message: "off", severity: "info" });
277
+
278
+ const stream = createUserEventStream(NET_A, TARGET_A);
279
+ const reader = stream.body!.getReader();
280
+ await readFrame(reader);
281
+ const online = await call(tools.send_desktop_message, { to_user_id: TARGET_A, message: "on", severity: "info" });
282
+ await reader.cancel();
283
+
284
+ expect(offline.delivered).not.toBe(online.delivered);
285
+ });
286
+ });
@@ -0,0 +1,60 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ buildStoragePath,
4
+ pathForExistingBlob,
5
+ validateIndexEntry,
6
+ } from "./uploads.js";
7
+
8
+ const fileId = "0123456789abcdef0123456789abcdef";
9
+ const dateBucket = "2026-08-09";
10
+ const uploadsRoot = "/srv/uploads";
11
+
12
+ const malicious = [
13
+ "../x",
14
+ "/abs/x",
15
+ ".png/../../y",
16
+ ".p ng",
17
+ ".p\0ng",
18
+ ".\\..\\x",
19
+ "." + "a".repeat(17),
20
+ ".tar.gz",
21
+ ];
22
+
23
+ const legal = ["", ".png", ".PNG", ".mp4", "." + "a".repeat(16)];
24
+
25
+ describe("#527 shared extension-token invariant", () => {
26
+ test("new-upload path rejects every malformed token and accepts the shared legal set", () => {
27
+ for (const ext of malicious) {
28
+ expect(() => buildStoragePath(fileId, ext, { uploadsRoot })).toThrow(/ext .* is invalid/);
29
+ }
30
+ for (const ext of legal) {
31
+ expect(buildStoragePath(fileId, ext, { uploadsRoot }).ext).toBe(ext);
32
+ }
33
+ });
34
+
35
+ test("existing-blob path rejects every malformed token and accepts the shared legal set", () => {
36
+ for (const ext of malicious) {
37
+ expect(() => pathForExistingBlob(dateBucket, fileId, ext, { uploadsRoot })).toThrow(/ext .* is invalid/);
38
+ }
39
+ for (const ext of legal) {
40
+ expect(pathForExistingBlob(dateBucket, fileId, ext, { uploadsRoot }).ext).toBe(ext);
41
+ }
42
+ });
43
+
44
+ test("stored-index gate rejects every malformed token and accepts the shared legal set", () => {
45
+ for (const ext of malicious) {
46
+ expect(validateIndexEntry({ file_id: fileId, date_bucket: dateBucket, ext, size: 0 })).toBe(false);
47
+ }
48
+ for (const ext of legal) {
49
+ expect(validateIndexEntry({ file_id: fileId, date_bucket: dateBucket, ext, size: 0 })).toBe(true);
50
+ }
51
+ });
52
+
53
+ test("all three boundaries fail closed on non-string runtime values", () => {
54
+ for (const ext of [undefined, null, 7, {}, []]) {
55
+ expect(() => buildStoragePath(fileId, ext as never, { uploadsRoot })).toThrow(/ext .* is invalid/);
56
+ expect(() => pathForExistingBlob(dateBucket, fileId, ext as never, { uploadsRoot })).toThrow(/ext .* is invalid/);
57
+ expect(validateIndexEntry({ file_id: fileId, date_bucket: dateBucket, ext, size: 0 })).toBe(false);
58
+ }
59
+ });
60
+ });
@@ -0,0 +1,131 @@
1
+ // Task #25 — regression pins for ext validation in validateIndexEntry.
2
+ //
3
+ // 🔴 SCOPE: this suite exercises the stored-index boundary through
4
+ // `validateIndexEntry`. Issue #527 removed the three independent copies
5
+ // of the stored extension-token grammar: buildStoragePath,
6
+ // pathForExistingBlob and validateIndexEntry now use one shared private
7
+ // validator. `ext-token-shared.test.ts` separately pins all three call
8
+ // sites. sanitizeExt remains intentionally independent because it parses
9
+ // a client filename rather than validating an already-extracted token.
10
+ //
11
+ // Background (通信龙 d01bb8ce): the stored extension-token grammar
12
+ // `/^\.[A-Za-z0-9]{1,16}$/` correctly rejects 11 known malicious ext
13
+ // values (path-escape, traversal, absolute paths, doubles, NUL bytes,
14
+ // backslash forms, over-length, whitespace). Verified via real-run on
15
+ // origin/main by lead — 11/11 malicious values → false.
16
+ //
17
+ // But NO test locked that behavior. Anyone who widens the regex in the
18
+ // future (accidentally or on purpose) would not hit a red test — the
19
+ // live protection would degrade silently. This suite pins current
20
+ // behavior in two directions:
21
+ //
22
+ // 1. SAFETY (欠修 direction) — 11 malicious values → false, one row
23
+ // per value with the exact literal. If regex is widened to accept
24
+ // any of them, that row turns red.
25
+ // 2. NON-REGRESSION (过修 direction) — a set of common legitimate
26
+ // extensions → true. If regex is tightened to reject any of them
27
+ // (e.g. someone locks down to a whitelist), that row turns red.
28
+ //
29
+ // Witnessed-red for the SAFETY rows: temporarily widen the regex to
30
+ // `/./` (or comment it out) in validateIndexEntry, run this suite,
31
+ // confirm all 11 malicious rows turn red, then revert. The mutation
32
+ // itself is not committed; the assertion IS the mutation-red proof
33
+ // for the reader. Recording the mutation output in the followup PR
34
+ // body (per Constraint 3 discipline established in #503).
35
+ //
36
+ // Pure unit test — no server setup, no DB, no filesystem. The regex
37
+ // lives in a pure function, so the guard is testable at that level.
38
+
39
+ import { describe, expect, test } from "bun:test";
40
+ import { validateIndexEntry } from "./uploads.js";
41
+
42
+ // A structurally-valid base entry — file_id / date_bucket / size all
43
+ // pass their own checks — so `ext` is the only field a test row varies.
44
+ // This isolates the ext regex as the deciding factor.
45
+ const validBase = {
46
+ file_id: "0123456789abcdef0123456789abcdef",
47
+ date_bucket: "2026-07-30",
48
+ size: 0,
49
+ };
50
+
51
+ describe("Task #25 (#503 followup) — ext validation regression pins", () => {
52
+ describe("SAFETY: the 11 malicious ext values from lead d01bb8ce all return false", () => {
53
+ // Each row uses the exact literal from lead's verification list so
54
+ // a widening regex catches on the same value the manual audit did.
55
+ const malicious: Array<{ label: string; ext: string }> = [
56
+ { label: "path-escape prefix", ext: "../x" },
57
+ { label: "deep traversal to /etc", ext: "../../etc/passwd" },
58
+ { label: "absolute path", ext: "/abs/x" },
59
+ { label: "double-dot alone", ext: ".." },
60
+ { label: "single-dot alone", ext: "." },
61
+ { label: "chained after legit prefix", ext: ".png/../../y" },
62
+ { label: "embedded whitespace", ext: ".p ng" },
63
+ { label: "over-length (>16 chars)", ext: "." + "a".repeat(17) },
64
+ { label: "embedded NUL byte", ext: ".p\x00ng" },
65
+ { label: "backslash traversal", ext: ".\\..\\x" },
66
+ { label: "double-backslash prefix", ext: "..\\x" },
67
+ ];
68
+ for (const { label, ext } of malicious) {
69
+ test(`rejects: ${label} — ext=${JSON.stringify(ext)}`, () => {
70
+ expect(validateIndexEntry({ ...validBase, ext })).toBe(false);
71
+ });
72
+ }
73
+
74
+ test("precondition: 11 malicious cases enumerated (defends against silent test-shrink)", () => {
75
+ // If someone deletes rows from the malicious list, this fails
76
+ // instead of the deletion silently narrowing the guard. Number
77
+ // matches lead's verification list — bump only when lead adds a
78
+ // case and confirms it via real-run.
79
+ expect(malicious.length).toBe(11);
80
+ });
81
+ });
82
+
83
+ describe("NON-REGRESSION: common legitimate extensions all return true (防过修)", () => {
84
+ // These are the extensions the live upload path actually produces
85
+ // via sanitizeExt / mime-typed multipart uploads. If someone
86
+ // tightens the regex to a stricter whitelist, any of these turning
87
+ // red flags a break in a normal user path.
88
+ const legal = [".png", ".pdf", ".pptx", ".jpg", ".jpeg", ".txt", ".mp4", ".zip", ".json", ".md"];
89
+ for (const ext of legal) {
90
+ test(`accepts: ext=${JSON.stringify(ext)}`, () => {
91
+ expect(validateIndexEntry({ ...validBase, ext })).toBe(true);
92
+ });
93
+ }
94
+
95
+ test("empty ext is accepted (file with no extension is a legitimate upload)", () => {
96
+ expect(validateIndexEntry({ ...validBase, ext: "" })).toBe(true);
97
+ });
98
+
99
+ test("precondition: at least 10 legitimate ext values pinned", () => {
100
+ // Same discipline as the malicious side — reject silent shrink.
101
+ expect(legal.length).toBeGreaterThanOrEqual(10);
102
+ });
103
+ });
104
+
105
+ describe("regex shape assumptions (documented, not enforced by the function)", () => {
106
+ // These rows document facts the current regex enforces beyond the
107
+ // 11 malicious literals. They are close to the boundary and would
108
+ // be the first things to move if someone reworked the regex.
109
+ test("upper-case extensions accepted (regex is case-insensitive by [A-Za-z])", () => {
110
+ expect(validateIndexEntry({ ...validBase, ext: ".PNG" })).toBe(true);
111
+ });
112
+ test("mixed-case extensions accepted", () => {
113
+ expect(validateIndexEntry({ ...validBase, ext: ".PdF" })).toBe(true);
114
+ });
115
+ test("digits after dot accepted (e.g. .mp4, .mp3)", () => {
116
+ expect(validateIndexEntry({ ...validBase, ext: ".mp4" })).toBe(true);
117
+ });
118
+ test("exactly 16 chars after dot is accepted (upper bound of {1,16})", () => {
119
+ expect(validateIndexEntry({ ...validBase, ext: "." + "a".repeat(16) })).toBe(true);
120
+ });
121
+ test("exactly 17 chars after dot is rejected (immediately past upper bound)", () => {
122
+ expect(validateIndexEntry({ ...validBase, ext: "." + "a".repeat(17) })).toBe(false);
123
+ });
124
+ test("compound extensions like .tar.gz are rejected (contains inner dot)", () => {
125
+ // Documented: the current regex does not support compound
126
+ // extensions. If we ever want .tar.gz support, it needs its
127
+ // own explicit design + this row updated with lead sign-off.
128
+ expect(validateIndexEntry({ ...validBase, ext: ".tar.gz" })).toBe(false);
129
+ });
130
+ });
131
+ });