@sleep2agi/commhub-server 0.8.5 → 0.8.6-preview.1

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/src/rename.ts CHANGED
@@ -16,11 +16,14 @@ import { randomUUID } from "crypto";
16
16
  import { db } from "./db";
17
17
  import { getUserNetworkRole, getNetworkMembers } from "./auth";
18
18
  import { pushEvent } from "./push";
19
+ import { eventBus } from "./event_bus";
19
20
 
20
21
  export interface RenameResult {
21
22
  ok: boolean;
22
23
  txn_id?: string;
23
24
  error?: string;
25
+ code?: string;
26
+ suggested?: string;
24
27
  }
25
28
 
26
29
  export interface CanonicalAlias {
@@ -111,7 +114,14 @@ export function prepareRename(
111
114
  // old-alias must exist as a session in this network
112
115
  const oldSession = db.get<any>(
113
116
  "SELECT 1 FROM sessions WHERE network_id = ?1 AND alias = ?2", networkId, oldAlias);
114
- if (!oldSession) return { ok: false, error: `node "${oldAlias}" not found in this network` };
117
+ if (!oldSession) {
118
+ return {
119
+ ok: false,
120
+ code: "node_local_only",
121
+ error: `node "${oldAlias}" has no server session in this network`,
122
+ suggested: "rename locally",
123
+ };
124
+ }
115
125
 
116
126
  // new-alias must not be taken — in sessions OR reserved by another prepared txn
117
127
  const newInSessions = db.get<any>(
@@ -147,19 +157,32 @@ export function commitRename(userId: string, txnId: string): RenameResult {
147
157
  "SELECT 1 FROM sessions WHERE network_id = ?1 AND alias = ?2", txn.network_id, txn.new_alias);
148
158
  if (conflict) return { ok: false, error: `alias "${txn.new_alias}" was taken since prepare` };
149
159
 
150
- db.run(
151
- "UPDATE sessions SET alias = ?1, updated_at = datetime('now') WHERE network_id = ?2 AND alias = ?3",
152
- [txn.new_alias, txn.network_id, txn.old_alias]);
153
- cleanupRenamedAliasSession(txn.network_id, txn.old_alias, txn.new_alias);
154
- db.run(
155
- "UPDATE nodes SET alias = ?1, updated_at = datetime('now') WHERE network_id = ?2 AND alias = ?3",
156
- [txn.new_alias, txn.network_id, txn.old_alias]);
157
- db.run(
158
- "UPDATE api_tokens SET name = ?1 WHERE network_id = ?2 AND name = ?3",
159
- [`node:${txn.new_alias}`, txn.network_id, `node:${txn.old_alias}`]);
160
- db.run(
161
- "UPDATE rename_txn SET status = 'committed', committed_at = datetime('now') WHERE txn_id = ?1",
162
- [txnId]);
160
+ const renamedNode = db.transaction(() => {
161
+ db.run(
162
+ "UPDATE sessions SET alias = ?1, updated_at = datetime('now') WHERE network_id = ?2 AND alias = ?3",
163
+ [txn.new_alias, txn.network_id, txn.old_alias]);
164
+ cleanupRenamedAliasSession(txn.network_id, txn.old_alias, txn.new_alias);
165
+ db.run(
166
+ "UPDATE nodes SET alias = ?1, updated_at = datetime('now') WHERE network_id = ?2 AND alias = ?3",
167
+ [txn.new_alias, txn.network_id, txn.old_alias]);
168
+ db.run(
169
+ "UPDATE api_tokens SET name = ?1 WHERE network_id = ?2 AND name = ?3",
170
+ [`node:${txn.new_alias}`, txn.network_id, `node:${txn.old_alias}`]);
171
+ db.run(
172
+ "UPDATE rename_txn SET status = 'committed', committed_at = datetime('now') WHERE txn_id = ?1",
173
+ [txnId]);
174
+ const node = db.get<{ node_id: string | null }>(
175
+ "SELECT node_id FROM sessions WHERE network_id = ?1 AND alias = ?2",
176
+ [txn.network_id, txn.new_alias]
177
+ );
178
+ eventBus.emit("rename-committed", {
179
+ networkId: txn.network_id,
180
+ old_alias: txn.old_alias,
181
+ new_alias: txn.new_alias,
182
+ node_id: node?.node_id ?? null,
183
+ });
184
+ return node;
185
+ });
163
186
 
164
187
  // RFC-010 §4.2.1 C4 — broadcast node.renamed SSE. (#84 实施补漏: the Server
165
188
  // surface originally missed C4; N站马's dashboard slice needs this event.)
@@ -174,9 +197,11 @@ export function commitRename(userId: string, txnId: string): RenameResult {
174
197
  txn_id: txnId,
175
198
  alias: txn.new_alias,
176
199
  network_id: txn.network_id,
200
+ node_id: renamedNode?.node_id ?? null,
177
201
  data: {
178
202
  old_alias: txn.old_alias,
179
203
  new_alias: txn.new_alias,
204
+ node_id: renamedNode?.node_id ?? null,
180
205
  surfaces_updated: ["config", "tmux", "commhub", "dashboard", "batch_prefix", "session_resume"],
181
206
  history_policy: "preserve",
182
207
  },
package/src/tools.ts CHANGED
@@ -119,11 +119,18 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
119
119
 
120
120
  const scopedSessionStatus = (alias: string, networkId?: string | null) => {
121
121
  const params: any[] = [alias];
122
- let sql = "SELECT status, updated_at, last_seen_at FROM sessions WHERE alias = ?1";
122
+ let sql = "SELECT status, updated_at, last_seen_at, node_id FROM sessions WHERE alias = ?1";
123
123
  sql = addScope(sql, params, networkId);
124
124
  return db.get<any>(sql, ...params);
125
125
  };
126
126
 
127
+ const resolveNodeIdForAlias = (alias: string, networkId?: string | null): string | null => {
128
+ if (!alias || alias === "hub" || alias === "api") return null;
129
+ const canonical = resolveCanonicalAlias(networkId, alias);
130
+ const session = scopedSessionStatus(canonical.alias, networkId);
131
+ return session?.node_id ?? null;
132
+ };
133
+
127
134
  const resolveDeliveryTarget = (alias: string, networkId?: string | null): DeliveryTarget => {
128
135
  const session = scopedSessionStatus(alias, networkId);
129
136
  if (!session) {
@@ -746,20 +753,22 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
746
753
 
747
754
  console.log(`[${ts()}] ${from_session} → send_task → ${targetAlias}: ${task.slice(0, 60)}${priority === "high" ? " [HIGH]" : ""}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
748
755
  const id = uuidv4();
756
+ const fromNodeId = resolveNodeIdForAlias(from_session, effectiveNetId);
757
+ const targetNodeId = target.session?.node_id ?? null;
749
758
  // 事务:inbox + tasks 双写 + 触碰目标 session 的 task/updated_at(让
750
759
  // dashboard 在派任务一刻就反映出"任务已下发",不再等 agent 的
751
760
  // report_status 心跳;status 字段交给 agent,避免与 working/idle
752
761
  // 报告冲突)。
753
762
  db.transaction(() => {
754
763
  db.run(
755
- `INSERT INTO inbox (id, session_name, type, priority, content, context, from_session, requires_response, network_id, meta_json)
756
- VALUES (?1, ?2, 'task', ?3, ?4, ?5, ?6, 'reply', ?7, ?8)`,
757
- [id, targetAlias, priority, task, context ?? null, from_session, effectiveNetId ?? null, metaJson]
764
+ `INSERT INTO inbox (id, session_name, node_id, type, priority, content, context, from_session, requires_response, network_id, meta_json)
765
+ VALUES (?1, ?2, ?3, 'task', ?4, ?5, ?6, ?7, 'reply', ?8, ?9)`,
766
+ [id, targetAlias, targetNodeId, priority, task, context ?? null, from_session, effectiveNetId ?? null, metaJson]
758
767
  );
759
768
  db.run(
760
- `INSERT INTO tasks (task_id, from_name, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at, network_id, parent_task_id, meta_json)
761
- VALUES (?1, ?2, ?3, ?4, 'delivered', ?5, 'reply', datetime('now'), datetime('now'), datetime('now', ?6), ?7, ?8, ?9)`,
762
- [id, from_session, targetAlias, priority, task, `+${ttl_seconds || 3600} seconds`, effectiveNetId ?? null, parentTaskId, metaJson]
769
+ `INSERT INTO tasks (task_id, from_node_id, from_name, to_node_id, to_name, priority, status, content, requires_response, created_at, delivered_at, expires_at, network_id, parent_task_id, meta_json)
770
+ VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'delivered', ?7, 'reply', datetime('now'), datetime('now'), datetime('now', ?8), ?9, ?10, ?11)`,
771
+ [id, fromNodeId, from_session, targetNodeId, targetAlias, priority, task, `+${ttl_seconds || 3600} seconds`, effectiveNetId ?? null, parentTaskId, metaJson]
763
772
  );
764
773
  const touchParams: any[] = [task.slice(0, 200), targetAlias];
765
774
  let touchSql = "UPDATE sessions SET task = ?1, updated_at = datetime('now') WHERE alias = ?2";
@@ -833,22 +842,31 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
833
842
  async ({ alias, message, from_session: _fromIn }) => { const fromMismatch = fromIdentityMismatchReply(_fromIn); if (fromMismatch) return fromMismatch; const from_session = defaultFrom(_fromIn);
834
843
  const effectiveNetId = getNetworkId(null);
835
844
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
836
- const target = resolveDeliveryTarget(alias, effectiveNetId);
845
+ const canonical = resolveCanonicalAlias(effectiveNetId, alias);
846
+ const targetAlias = canonical.alias;
847
+ const target = resolveDeliveryTarget(targetAlias, effectiveNetId);
837
848
  if (target.state === "not_found") return deliveryTargetReply(target)!;
838
- console.log(`[${ts()}] ${from_session} → send_message → ${alias}: ${message.slice(0, 60)}`);
849
+ console.log(`[${ts()}] ${from_session} → send_message → ${targetAlias}: ${message.slice(0, 60)}${canonical.renamed ? ` [renamed from ${alias}]` : ""}`);
839
850
  const id = uuidv4();
840
851
  db.run(
841
- `INSERT INTO inbox (id, session_name, type, priority, content, from_session, network_id)
842
- VALUES (?1, ?2, 'message', 'normal', ?3, ?4, ?5)`,
843
- [id, alias, message, from_session, effectiveNetId ?? null]
852
+ `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, network_id)
853
+ VALUES (?1, ?2, ?3, 'message', 'normal', ?4, ?5, ?6)`,
854
+ [id, targetAlias, target.session?.node_id ?? null, message, from_session, effectiveNetId ?? null]
844
855
  );
845
856
 
846
857
  if (target.state === "online") {
847
- pushEvent(alias, { type: "new_message", from: from_session, message_id: id }, effectiveNetId);
858
+ pushEvent(targetAlias, { type: "new_message", from: from_session, message_id: id, ...(canonical.renamed ? { renamed_from: alias } : {}) }, effectiveNetId);
848
859
  }
849
860
 
850
861
  const offlineReply = deliveryTargetReply(target, { message_id: id });
851
- if (offlineReply) return offlineReply;
862
+ if (offlineReply) {
863
+ const payload = JSON.parse(offlineReply.content[0].text);
864
+ offlineReply.content[0].text = JSON.stringify({
865
+ ...payload,
866
+ ...(canonical.renamed ? { renamed_from: alias, renamed_to: targetAlias } : {}),
867
+ });
868
+ return offlineReply;
869
+ }
852
870
 
853
871
  return {
854
872
  content: [
@@ -857,6 +875,7 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
857
875
  text: JSON.stringify({
858
876
  ok: true,
859
877
  message_id: id,
878
+ ...(canonical.renamed ? { renamed_from: alias, renamed_to: targetAlias } : {}),
860
879
  session_status: target.session?.status ?? "unknown",
861
880
  }),
862
881
  },
@@ -917,11 +936,12 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
917
936
  };
918
937
  }
919
938
  }
939
+ const replyTargetNodeId = resolveNodeIdForAlias(alias, effectiveNetId);
920
940
  const replyLogged = db.transaction(() => {
921
941
  db.run(
922
- `INSERT INTO inbox (id, session_name, type, priority, content, from_session, in_reply_to, requires_response, network_id)
923
- VALUES (?1, ?2, 'reply', 'normal', ?3, ?4, ?5, 'none', ?6)`,
924
- [id, alias, text, from_session, in_reply_to ?? null, effectiveNetId ?? null]
942
+ `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, in_reply_to, requires_response, network_id)
943
+ VALUES (?1, ?2, ?3, 'reply', 'normal', ?4, ?5, ?6, 'none', ?7)`,
944
+ [id, alias, replyTargetNodeId, text, from_session, in_reply_to ?? null, effectiveNetId ?? null]
925
945
  );
926
946
 
927
947
  // 更新 tasks 表
@@ -1053,9 +1073,9 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1053
1073
  // Re-queue in inbox with new ID (original ID may already exist)
1054
1074
  const retryInboxId = uuidv4();
1055
1075
  db.run(
1056
- `INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response, network_id)
1057
- VALUES (?1, ?2, 'task', ?3, ?4, ?5, 'reply', ?6)`,
1058
- [retryInboxId, task.to_name, task.priority, task.content, from_session, effectiveNetId ?? task.network_id ?? null]
1076
+ `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, requires_response, network_id)
1077
+ VALUES (?1, ?2, ?3, 'task', ?4, ?5, ?6, 'reply', ?7)`,
1078
+ [retryInboxId, task.to_name, task.to_node_id ?? resolveNodeIdForAlias(task.to_name, effectiveNetId ?? task.network_id ?? null), task.priority, task.content, from_session, effectiveNetId ?? task.network_id ?? null]
1059
1079
  );
1060
1080
  });
1061
1081
  logTaskEvent(task_id, task.status, "delivered", from_session, "retry");
@@ -1098,18 +1118,20 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1098
1118
  alias: z.string().max(200).optional().describe("Filter by to_name (target agent)"),
1099
1119
  status: z.string().max(50).optional().describe("Filter by status"),
1100
1120
  from_name: z.string().max(200).optional().describe("Filter by sender"),
1121
+ from_node_id: z.string().max(200).optional().describe("Filter by immutable sender node_id"),
1101
1122
  network_id: z.string().max(200).optional().describe("Filter by network"),
1102
1123
  limit: z.number().min(1).max(100).optional().default(20),
1103
1124
  },
1104
- async ({ alias, status, from_name, network_id: netId, limit }) => {
1125
+ async ({ alias, status, from_name, from_node_id, network_id: netId, limit }) => {
1105
1126
  const readScope = resolveReadScope(netId);
1106
1127
  if (readScope.denied) return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: readScope.denied }) }] };
1107
- let sql = "SELECT task_id, from_name, to_name, priority, status, content, result, created_at, completed_at FROM tasks WHERE 1=1";
1128
+ let sql = "SELECT task_id, from_node_id, from_name, to_node_id, to_name, priority, status, content, result, created_at, completed_at FROM tasks WHERE 1=1";
1108
1129
  const params: any[] = [];
1109
1130
  sql = addReadScope(sql, params, readScope);
1110
1131
  if (alias) { sql += ` AND to_name = ?${params.length + 1}`; params.push(alias); }
1111
1132
  if (status) { sql += ` AND status = ?${params.length + 1}`; params.push(status); }
1112
1133
  if (from_name) { sql += ` AND from_name = ?${params.length + 1}`; params.push(from_name); }
1134
+ if (from_node_id) { sql += ` AND from_node_id = ?${params.length + 1}`; params.push(from_node_id); }
1113
1135
  sql += ` ORDER BY created_at DESC LIMIT ?${params.length + 1}`;
1114
1136
  params.push(limit);
1115
1137
  const tasks = db.all(sql, ...params);
@@ -1184,6 +1206,10 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1184
1206
  return { content: [{ type: "text" as const, text: JSON.stringify({ ok: false, error: `task is terminal (${task.status})` }) }] };
1185
1207
  }
1186
1208
  const oldAlias = task.to_name;
1209
+ const canonical = resolveCanonicalAlias(effectiveNetId ?? task.network_id ?? null, new_alias);
1210
+ const reassignedAlias = canonical.alias;
1211
+ const target = resolveDeliveryTarget(reassignedAlias, effectiveNetId ?? task.network_id ?? null);
1212
+ const newNodeId = target.state === "not_found" ? null : (target.session?.node_id ?? null);
1187
1213
  db.transaction(() => {
1188
1214
  // Ack old inbox to prevent original agent from picking it up
1189
1215
  const inboxParams: any[] = [task_id];
@@ -1191,18 +1217,18 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1191
1217
  inboxSql = addScope(inboxSql, inboxParams, effectiveNetId);
1192
1218
  db.run(inboxSql, inboxParams);
1193
1219
 
1194
- const updateParams: any[] = [new_alias, task_id];
1195
- let updateSql = "UPDATE tasks SET to_name = ?1, status = 'delivered', started_at = NULL, delivered_at = datetime('now') WHERE task_id = ?2";
1220
+ const updateParams: any[] = [reassignedAlias, newNodeId, task_id];
1221
+ let updateSql = "UPDATE tasks SET to_name = ?1, to_node_id = ?2, status = 'delivered', started_at = NULL, delivered_at = datetime('now') WHERE task_id = ?3";
1196
1222
  updateSql = addScope(updateSql, updateParams, effectiveNetId);
1197
1223
  db.run(updateSql, updateParams);
1198
1224
 
1199
1225
  const newInboxId = uuidv4();
1200
- db.run("INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response, network_id) VALUES (?1, ?2, 'task', ?3, ?4, ?5, 'reply', ?6)",
1201
- [newInboxId, new_alias, task.priority, task.content, from_session, effectiveNetId ?? task.network_id ?? null]);
1226
+ db.run("INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, requires_response, network_id) VALUES (?1, ?2, ?3, 'task', ?4, ?5, ?6, 'reply', ?7)",
1227
+ [newInboxId, reassignedAlias, newNodeId, task.priority, task.content, from_session, effectiveNetId ?? task.network_id ?? null]);
1202
1228
  });
1203
- logTaskEvent(task_id, task.status, "delivered", from_session, `reassign: ${oldAlias} → ${new_alias}`);
1204
- pushEvent(new_alias, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session }, effectiveNetId ?? task.network_id ?? null);
1205
- return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, reassigned_from: oldAlias, reassigned_to: new_alias }) }] };
1229
+ logTaskEvent(task_id, task.status, "delivered", from_session, `reassign: ${oldAlias} → ${reassignedAlias}`);
1230
+ pushEvent(reassignedAlias, { type: "new_task", inbox_count: 1, priority: task.priority, from: from_session, ...(canonical.renamed ? { renamed_from: new_alias } : {}) }, effectiveNetId ?? task.network_id ?? null);
1231
+ return { content: [{ type: "text" as const, text: JSON.stringify({ ok: true, task_id, reassigned_from: oldAlias, reassigned_to: reassignedAlias, ...(canonical.renamed ? { renamed_from: new_alias, renamed_to: reassignedAlias } : {}) }) }] };
1206
1232
  }
1207
1233
  );
1208
1234
 
@@ -1219,21 +1245,21 @@ export function registerTools(server: McpServer, clientIP?: string, enforceNetwo
1219
1245
  const effectiveNetId = getNetworkId(netId);
1220
1246
  if (!canWrite(effectiveNetId)) return writeDeniedReply(effectiveNetId);
1221
1247
  console.log(`[${ts()}] hub → broadcast: ${message.slice(0, 60)}${effectiveNetId ? " [net=" + effectiveNetId.slice(0, 12) + "]" : ""}`);
1222
- let sql = "SELECT alias, network_id FROM sessions WHERE alias IS NOT NULL";
1248
+ let sql = "SELECT alias, node_id, network_id FROM sessions WHERE alias IS NOT NULL";
1223
1249
  const params: any[] = [];
1224
1250
  sql = addScope(sql, params, effectiveNetId);
1225
1251
  if (filter_server) { sql += " AND server = ?"; params.push(filter_server); }
1226
1252
  if (filter_status) { sql += " AND status = ?"; params.push(filter_status); }
1227
1253
 
1228
- const targets = db.all<{ alias: string; network_id: string | null }>(sql, ...params);
1254
+ const targets = db.all<{ alias: string; node_id: string | null; network_id: string | null }>(sql, ...params);
1229
1255
  const ids: string[] = [];
1230
1256
 
1231
1257
  for (const t of targets) {
1232
1258
  const id = uuidv4();
1233
1259
  db.run(
1234
- `INSERT INTO inbox (id, session_name, type, priority, content, from_session, network_id)
1235
- VALUES (?1, ?2, 'broadcast', 'normal', ?3, 'hub', ?4)`,
1236
- [id, t.alias, message, effectiveNetId ?? t.network_id ?? null]
1260
+ `INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, network_id)
1261
+ VALUES (?1, ?2, ?3, 'broadcast', 'normal', ?4, 'hub', ?5)`,
1262
+ [id, t.alias, t.node_id ?? null, message, effectiveNetId ?? t.network_id ?? null]
1237
1263
  );
1238
1264
  ids.push(id);
1239
1265
  }
@@ -0,0 +1,208 @@
1
+ // #221 — integration smoke for /api/upload + /api/files/:file_id + /api/task
2
+ // attachments. Spawns the real Bun.serve hub in a child process, hits it
3
+ // with real fetch() calls, asserts on the 6-item security checklist.
4
+ //
5
+ // Runs entirely under bun test — no Docker needed for the in-repo
6
+ // signal (Docker harness is documented in #221 for 测试马's UAT, but
7
+ // this file already exercises the same code path). The hub binds to
8
+ // an ephemeral port so multiple test runs don't collide.
9
+ import { describe, expect, test, beforeAll, afterAll } from "bun:test";
10
+ import { mkdtempSync, rmSync, existsSync } from "fs";
11
+ import { join } from "path";
12
+ import { tmpdir } from "os";
13
+ import { register, login } from "./auth.js";
14
+
15
+ const SERVER_DB = mkdtempSync(join(tmpdir(), "anet-upload-http-db-")) + "/commhub.db";
16
+ const UPLOADS_DIR = mkdtempSync(join(tmpdir(), "anet-upload-http-fs-"));
17
+ const PORT = 19000 + Math.floor(Math.random() * 1000);
18
+ const BASE = `http://127.0.0.1:${PORT}`;
19
+
20
+ let server: any;
21
+ let userToken = "";
22
+ let userNetworkId = "";
23
+
24
+ beforeAll(async () => {
25
+ // Bootstrap a real admin + token on the temp DB so the server can
26
+ // resolve it via requireAuth (we don't want to short-circuit auth —
27
+ // we want to validate the production code path).
28
+ process.env.COMMHUB_DB = SERVER_DB;
29
+ process.env.COMMHUB_UPLOADS_DIR = UPLOADS_DIR;
30
+ process.env.PORT = String(PORT);
31
+ process.env.HOST = "127.0.0.1";
32
+
33
+ // Use a unique username + strong password each run so we don't trip
34
+ // over "already taken" from a prior test in the same DB. Strong
35
+ // password keeps us past validatePasswordStrength even when we're
36
+ // not the first user in the DB.
37
+ const username = `upload_admin_${Date.now()}_${Math.floor(Math.random() * 1000)}`;
38
+ const password = "BootstrapPw123Aa!";
39
+ let r = register(username, password, undefined, "seed");
40
+ if (!r.ok) {
41
+ console.error("[test bootstrap] register failed:", r.error);
42
+ }
43
+ if (r.token) {
44
+ userToken = r.token;
45
+ userNetworkId = r.network_id ?? "";
46
+ } else {
47
+ const lr = login(username, password);
48
+ if (lr.token) userToken = lr.token;
49
+ if (lr.network_id) userNetworkId = lr.network_id;
50
+ }
51
+ expect(userToken).toBeTruthy();
52
+
53
+ // Import the server module — its top-level `Bun.serve` call binds
54
+ // the port immediately. The import side effect IS the server start.
55
+ await import("./index.js");
56
+ // Tiny settle so the listener is ready.
57
+ await new Promise((r) => setTimeout(r, 100));
58
+ });
59
+
60
+ afterAll(() => {
61
+ // Best-effort cleanup. The Bun.serve handle is created at import
62
+ // time so we can't easily stop it — process exit handles that.
63
+ try { rmSync(UPLOADS_DIR, { recursive: true, force: true }); } catch {}
64
+ try { rmSync(SERVER_DB, { recursive: true, force: true }); } catch {}
65
+ });
66
+
67
+ function authHeaders(): Record<string, string> {
68
+ return { "Authorization": `Bearer ${userToken}` };
69
+ }
70
+
71
+ async function uploadFile(content: Uint8Array, filename: string, mime: string, opts: { skipAuth?: boolean; overrideContentLength?: string | null } = {}): Promise<Response> {
72
+ const form = new FormData();
73
+ form.append("file", new Blob([content], { type: mime }), filename);
74
+ const headers: Record<string, string> = opts.skipAuth ? {} : { ...authHeaders() };
75
+ // Note: fetch sets Content-Length automatically; only override when
76
+ // testing the missing-header path.
77
+ const res = await fetch(`${BASE}/api/upload`, { method: "POST", body: form, headers });
78
+ return res;
79
+ }
80
+
81
+ describe("POST /api/upload — 6-item security checklist (HTTP integration)", () => {
82
+ test("(item 5) anonymous request → 401", async () => {
83
+ const res = await uploadFile(new Uint8Array([1, 2, 3]), "tiny.bin", "application/octet-stream", { skipAuth: true });
84
+ expect(res.status).toBe(401);
85
+ });
86
+
87
+ test("(item 5) bad bearer token → 401", async () => {
88
+ const form = new FormData();
89
+ form.append("file", new Blob([new Uint8Array([1, 2, 3])]), "tiny.bin");
90
+ const res = await fetch(`${BASE}/api/upload`, {
91
+ method: "POST",
92
+ body: form,
93
+ headers: { "Authorization": "Bearer this-is-not-a-real-token" },
94
+ });
95
+ expect(res.status).toBe(401);
96
+ });
97
+
98
+ test("(items 1+2) successful upload returns server-generated file_id + url, blob lands inside uploads root", async () => {
99
+ const content = new Uint8Array(1024).fill(0xab);
100
+ const res = await uploadFile(content, "client-supplied-name.bin", "application/octet-stream");
101
+ expect(res.status).toBe(200);
102
+ const body = await res.json() as any;
103
+ expect(body.ok).toBe(true);
104
+ expect(typeof body.file_id).toBe("string");
105
+ expect(body.file_id.length).toBe(32);
106
+ expect(body.url).toBe(`/api/files/${body.file_id}`);
107
+ expect(body.size).toBe(1024);
108
+ // The path MUST live inside the configured uploads root.
109
+ expect(String(body.path).startsWith(UPLOADS_DIR)).toBe(true);
110
+ // The path MUST NOT contain the client-supplied filename.
111
+ expect(String(body.path).includes("client-supplied-name")).toBe(false);
112
+ // Blob really exists on disk.
113
+ expect(existsSync(body.path)).toBe(true);
114
+ });
115
+
116
+ test("(item 4 stage-2) 12 MiB cap — slightly over the limit is rejected with 413", async () => {
117
+ const overLimit = new Uint8Array(12 * 1024 * 1024 + 16).fill(0xcd);
118
+ const res = await uploadFile(overLimit, "over.bin", "application/octet-stream");
119
+ expect(res.status).toBe(413);
120
+ const body = await res.json() as any;
121
+ expect(body.ok).toBe(false);
122
+ expect(body.error).toBe("payload_too_large");
123
+ expect(body.limit_bytes).toBe(12 * 1024 * 1024);
124
+ });
125
+
126
+ test("(item 4 stage-1) missing Content-Length → 411 Length Required", async () => {
127
+ // Forge a raw request with a body but no Content-Length. The
128
+ // simplest way to do this in Bun is to construct a Request with a
129
+ // streaming ReadableStream body — Bun then doesn't set
130
+ // Content-Length on outgoing fetch.
131
+ const stream = new ReadableStream({
132
+ start(controller) {
133
+ controller.enqueue(new TextEncoder().encode("not actually multipart"));
134
+ controller.close();
135
+ },
136
+ });
137
+ const res = await fetch(`${BASE}/api/upload`, {
138
+ method: "POST",
139
+ body: stream,
140
+ headers: { ...authHeaders(), "Content-Type": "multipart/form-data; boundary=xxx", "Transfer-Encoding": "chunked" },
141
+ // @ts-expect-error — Bun-specific to force streaming upload
142
+ duplex: "half",
143
+ });
144
+ expect(res.status).toBe(411);
145
+ });
146
+
147
+ test("(extra defense) non-multipart body is rejected (415 or 400)", async () => {
148
+ // We accept either 415 (Content-Type !multipart) or 400 (body
149
+ // failed to parse as multipart). Both mean "we refused to accept
150
+ // this body" which is the security property we care about; the
151
+ // exact code depends on whether Bun's multipart parser bails before
152
+ // or after our Content-Type pre-check.
153
+ const res = await fetch(`${BASE}/api/upload`, {
154
+ method: "POST",
155
+ body: JSON.stringify({ nope: 1 }),
156
+ headers: { ...authHeaders(), "Content-Type": "application/json" },
157
+ });
158
+ expect([400, 415]).toContain(res.status);
159
+ });
160
+ });
161
+
162
+ describe("GET /api/files/:file_id — download safety", () => {
163
+ let fileId = "";
164
+
165
+ beforeAll(async () => {
166
+ const content = new TextEncoder().encode("hello-world-from-#221-integration-test");
167
+ const res = await uploadFile(content, "greeting.txt", "text/plain");
168
+ const body = await res.json() as any;
169
+ fileId = body.file_id;
170
+ });
171
+
172
+ test("(item 3) download returns the blob with Content-Disposition: attachment + X-Content-Type-Options: nosniff", async () => {
173
+ const res = await fetch(`${BASE}/api/files/${fileId}`, { headers: authHeaders() });
174
+ expect(res.status).toBe(200);
175
+ expect(res.headers.get("Content-Disposition") ?? "").toMatch(/^attachment(;|$)/i);
176
+ expect(res.headers.get("X-Content-Type-Options")).toBe("nosniff");
177
+ expect(res.headers.get("Content-Type")).toBe("application/octet-stream");
178
+ const text = await res.text();
179
+ expect(text).toBe("hello-world-from-#221-integration-test");
180
+ });
181
+
182
+ test("anonymous download is 401", async () => {
183
+ const res = await fetch(`${BASE}/api/files/${fileId}`);
184
+ expect(res.status).toBe(401);
185
+ });
186
+
187
+ test("traversal attempt in path is rejected at the regex layer with 400", async () => {
188
+ const res = await fetch(`${BASE}/api/files/..%2Fetc%2Fpasswd`, { headers: authHeaders() });
189
+ expect(res.status).toBe(400);
190
+ const body = await res.json() as any;
191
+ expect(body.error).toBe("bad_file_id");
192
+ });
193
+
194
+ test("unknown file_id is 404", async () => {
195
+ const res = await fetch(`${BASE}/api/files/0123456789abcdef0123456789abcdef`, { headers: authHeaders() });
196
+ expect(res.status).toBe(404);
197
+ });
198
+ });
199
+
200
+ // NOTE on /api/task attachments coverage: the validation logic
201
+ // (validateAttachments) is exercised end-to-end in
202
+ // uploads.test.ts with 6 cases covering happy path / too-many /
203
+ // bad type / bad file_id / size cap / non-array. The HTTP wire-up
204
+ // (server/src/index.ts inside the /api/task handler) is a one-line
205
+ // merge that surfaces `validateAttachments`'s structured error as a
206
+ // 400 `bad_attachments` to the caller — see the #221 contract in
207
+ // the issue comment for the full request/response shape that N站马
208
+ // APP will consume.