@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/package.json +3 -2
- package/src/auth_login_guard.test.ts +130 -0
- package/src/auth_login_guard.ts +142 -0
- package/src/db.ts +189 -4
- package/src/event_bus.ts +10 -0
- package/src/index.ts +354 -44
- package/src/push.test.ts +47 -0
- package/src/push.ts +28 -0
- package/src/rename.ts +39 -14
- package/src/tools.ts +60 -34
- package/src/uploads-http.test.ts +208 -0
- package/src/uploads.test.ts +294 -0
- package/src/uploads.ts +240 -0
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)
|
|
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.
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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', ?
|
|
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', ?
|
|
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
|
|
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 → ${
|
|
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', ?
|
|
843
|
-
[id,
|
|
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(
|
|
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)
|
|
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', ?
|
|
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', ?
|
|
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[] = [
|
|
1195
|
-
let updateSql = "UPDATE tasks SET to_name = ?1, status = 'delivered', started_at = NULL, delivered_at = datetime('now') WHERE task_id = ?
|
|
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', ?
|
|
1201
|
-
[newInboxId,
|
|
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} → ${
|
|
1204
|
-
pushEvent(
|
|
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', ?
|
|
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.
|