@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/index.ts
CHANGED
|
@@ -7,6 +7,23 @@ import { createSSEStream, pushEvent, getSSEStats } from "./push.js";
|
|
|
7
7
|
import { register, login, resolveToken, getUserNetworks, getUserAllNetworks, createNetwork, deleteNetwork, renameNetwork, changePassword, issueUserToken, listTokens, createToken, revokeToken, getNetworkMembers, getUserNetworkRole, addNetworkMember, updateMemberRole, removeNetworkMember, createInvite, joinByInvite, createNetworkTokenForNode, type AuthUser } from "./auth.js";
|
|
8
8
|
import { abortRename, cleanupCommittedRenameSessions, commitRename, prepareRename, resolveCanonicalAlias } from "./rename.js";
|
|
9
9
|
import { sharedSendDedup, buildDuplicateSendPayload } from "./send_dedup.js";
|
|
10
|
+
import { getLoginClientIp, sharedLoginFailureLockout, sharedLoginIpRateLimiter } from "./auth_login_guard.js";
|
|
11
|
+
import {
|
|
12
|
+
FILE_ID_REGEX,
|
|
13
|
+
MAX_REQUEST_CONTENT_LENGTH,
|
|
14
|
+
MAX_UPLOAD_BYTES,
|
|
15
|
+
buildStoragePath,
|
|
16
|
+
generateFileId,
|
|
17
|
+
getUploadsRoot,
|
|
18
|
+
indexEntryPath,
|
|
19
|
+
isPathInsideUploadsRoot,
|
|
20
|
+
sanitizeExt,
|
|
21
|
+
sharedUploadRateLimiter,
|
|
22
|
+
validateAttachments,
|
|
23
|
+
validateIndexEntry,
|
|
24
|
+
} from "./uploads.js";
|
|
25
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync, statSync } from "fs";
|
|
26
|
+
import { dirname as pathDirname } from "path";
|
|
10
27
|
|
|
11
28
|
const PORT = Number(process.env.PORT) || 9200;
|
|
12
29
|
const HOST = process.env.HOST || "127.0.0.1";
|
|
@@ -348,7 +365,7 @@ type RestDeliveryTarget =
|
|
|
348
365
|
|
|
349
366
|
function resolveRestDeliveryTarget(alias: string, networkId: string | null): RestDeliveryTarget {
|
|
350
367
|
const params: any[] = [alias];
|
|
351
|
-
let sql = "SELECT status, updated_at, last_seen_at FROM sessions WHERE alias = ?1";
|
|
368
|
+
let sql = "SELECT status, updated_at, last_seen_at, node_id FROM sessions WHERE alias = ?1";
|
|
352
369
|
if (networkId) {
|
|
353
370
|
sql += " AND network_id = ?2";
|
|
354
371
|
params.push(networkId);
|
|
@@ -371,6 +388,13 @@ function resolveRestDeliveryTarget(alias: string, networkId: string | null): Res
|
|
|
371
388
|
return { state: "online", alias, session };
|
|
372
389
|
}
|
|
373
390
|
|
|
391
|
+
function resolveRestNodeIdForAlias(alias: string, networkId: string | null): string | null {
|
|
392
|
+
if (!alias || alias === "hub" || alias === "api") return null;
|
|
393
|
+
const canonical = resolveCanonicalAlias(networkId, alias);
|
|
394
|
+
const target = resolveRestDeliveryTarget(canonical.alias, networkId);
|
|
395
|
+
return target.state === "not_found" ? null : (target.session?.node_id ?? null);
|
|
396
|
+
}
|
|
397
|
+
|
|
374
398
|
// ── REST input schema ───────────────────────────────
|
|
375
399
|
const TaskSchema = z.object({
|
|
376
400
|
alias: z.string().min(1).max(200),
|
|
@@ -380,6 +404,10 @@ const TaskSchema = z.object({
|
|
|
380
404
|
network_id: z.string().max(200).optional(),
|
|
381
405
|
parent_task_id: z.string().max(200).optional(),
|
|
382
406
|
ttl_seconds: z.number().min(1).max(86400).optional(),
|
|
407
|
+
// #221 — top-level attachments mirror the MCP send_task tool. They are
|
|
408
|
+
// merged into `meta.attachments` for persistence so the on-disk shape
|
|
409
|
+
// stays unified regardless of transport (REST or MCP).
|
|
410
|
+
attachments: z.any().optional(),
|
|
383
411
|
meta: z.any().optional(),
|
|
384
412
|
});
|
|
385
413
|
|
|
@@ -443,6 +471,13 @@ Bun.serve({
|
|
|
443
471
|
port: PORT,
|
|
444
472
|
hostname: HOST,
|
|
445
473
|
idleTimeout: 255, // max value: keep SSE connections alive (seconds)
|
|
474
|
+
// #221 — defense-in-depth cap on the raw request body. The /api/upload
|
|
475
|
+
// handler also pre-checks Content-Length and post-checks parsed size
|
|
476
|
+
// against the documented 12 MiB cap (MAX_UPLOAD_BYTES); this knob
|
|
477
|
+
// simply guarantees Bun's own buffer won't grow past 13 MiB on any
|
|
478
|
+
// route. Default Bun ceiling is 128 MiB which is too permissive for
|
|
479
|
+
// a hub now exposed to the public internet.
|
|
480
|
+
maxRequestBodySize: MAX_REQUEST_CONTENT_LENGTH,
|
|
446
481
|
|
|
447
482
|
async fetch(req, server) {
|
|
448
483
|
const url = new URL(req.url, `http://localhost:${PORT}`);
|
|
@@ -578,16 +613,37 @@ Bun.serve({
|
|
|
578
613
|
}
|
|
579
614
|
|
|
580
615
|
if (url.pathname === "/api/auth/login" && req.method === "POST") {
|
|
581
|
-
const clientIP = req
|
|
582
|
-
|
|
616
|
+
const clientIP = getLoginClientIp(req);
|
|
617
|
+
const ipRate = sharedLoginIpRateLimiter.check(clientIP || "unknown");
|
|
618
|
+
if (!ipRate.allowed) {
|
|
583
619
|
logAudit(null, null, "login_rate_limited", "auth", undefined, clientIP);
|
|
584
|
-
return withCors(req, Response.json(
|
|
620
|
+
return withCors(req, Response.json(
|
|
621
|
+
{ ok: false, error: "rate_limited", message: "Too many login attempts. Try again later.", retry_after_ms: ipRate.retryAfterMs },
|
|
622
|
+
{ status: 429, headers: { "Retry-After": String(Math.ceil((ipRate.retryAfterMs ?? 1000) / 1000)) } }
|
|
623
|
+
));
|
|
585
624
|
}
|
|
586
625
|
try {
|
|
587
626
|
const body = await req.json() as any;
|
|
627
|
+
const lock = sharedLoginFailureLockout.check(body.username);
|
|
628
|
+
if (lock.locked) {
|
|
629
|
+
logAudit(null, body.username, "login_locked", "auth", undefined, `retry_after_ms=${lock.retryAfterMs ?? 0}`);
|
|
630
|
+
return withCors(req, Response.json(
|
|
631
|
+
{ ok: false, error: "login_locked", message: "Too many failed login attempts. Try again later.", retry_after_ms: lock.retryAfterMs },
|
|
632
|
+
{ status: 429, headers: { "Retry-After": String(Math.ceil((lock.retryAfterMs ?? 1000) / 1000)) } }
|
|
633
|
+
));
|
|
634
|
+
}
|
|
588
635
|
const result = login(body.username, body.password);
|
|
589
|
-
if (result.ok)
|
|
590
|
-
|
|
636
|
+
if (result.ok) {
|
|
637
|
+
sharedLoginFailureLockout.recordSuccess(body.username);
|
|
638
|
+
logAudit(result.user!.user_id, body.username, "login", "user", result.user!.user_id);
|
|
639
|
+
} else {
|
|
640
|
+
const failure = sharedLoginFailureLockout.recordFailure(body.username);
|
|
641
|
+
if (failure.locked) {
|
|
642
|
+
const safeUsername = String(body.username ?? "").replace(/[\r\n\t]/g, " ").slice(0, 80);
|
|
643
|
+
console.warn(`[auth] login locked for username=${safeUsername} failures=${failure.failures} retry_after_ms=${failure.lockMs}`);
|
|
644
|
+
}
|
|
645
|
+
logAudit(null, body.username, "login_failed", "user", undefined, "invalid credentials");
|
|
646
|
+
}
|
|
591
647
|
return withCors(req, Response.json(result, { status: result.ok ? 200 : 401 }));
|
|
592
648
|
} catch (e: any) {
|
|
593
649
|
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
@@ -679,7 +735,7 @@ Bun.serve({
|
|
|
679
735
|
}
|
|
680
736
|
const result = prepareRename(resolved.user.user_id, body.network_id, body.old_alias, body.new_alias);
|
|
681
737
|
if (result.ok) logAudit(resolved.user.user_id, resolved.user.username, "node_rename_prepared", "node", body.old_alias, body.new_alias);
|
|
682
|
-
return withCors(req, Response.json(result, { status: result.ok ? 200 : 400 }));
|
|
738
|
+
return withCors(req, Response.json(result, { status: result.ok || result.code === "node_local_only" ? 200 : 400 }));
|
|
683
739
|
} catch (e: any) {
|
|
684
740
|
return withCors(req, Response.json({ ok: false, error: e.message }, { status: 400 }));
|
|
685
741
|
}
|
|
@@ -957,37 +1013,60 @@ Bun.serve({
|
|
|
957
1013
|
staleSql = addNetworkScope(staleSql, staleParams, restScope);
|
|
958
1014
|
db.run(staleSql, staleParams);
|
|
959
1015
|
cleanupCommittedRenameSessions(restScope.networkId ? [restScope.networkId] : restScope.networkIds ?? null);
|
|
1016
|
+
// `?light=1` returns a narrow projection (alias / status / agent / task /
|
|
1017
|
+
// server / updated_at + runtime + network_id) — used by the mobile APP
|
|
1018
|
+
// list view, where pulling 30+ fields × 150 agents was making cold open
|
|
1019
|
+
// take 12s+ on flaky cellular (Vincent tg, #220 PWA). Default response
|
|
1020
|
+
// is unchanged so the dashboard / scripts that read full telemetry are
|
|
1021
|
+
// unaffected.
|
|
1022
|
+
const isLight = url.searchParams.get("light") === "1";
|
|
960
1023
|
const params: any[] = [];
|
|
961
|
-
let sql =
|
|
1024
|
+
let sql = isLight
|
|
1025
|
+
? "SELECT alias, status, agent, task, server, updated_at, network_id FROM sessions WHERE 1=1"
|
|
1026
|
+
: "SELECT * FROM sessions WHERE 1=1";
|
|
962
1027
|
sql = addNetworkScope(sql, params, restScope);
|
|
963
1028
|
sql += " ORDER BY updated_at DESC";
|
|
964
1029
|
// `model` comes straight from the sessions row (SELECT *); `runtime` is
|
|
965
1030
|
// derived from the raw `agent` field. Both default to null for old nodes
|
|
966
1031
|
// that never reported a model — the dashboard falls back to a placeholder.
|
|
967
|
-
const sessions = db.all(sql, ...params).map((s: any) =>
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
1032
|
+
const sessions = db.all(sql, ...params).map((s: any) => {
|
|
1033
|
+
if (isLight) {
|
|
1034
|
+
return {
|
|
1035
|
+
alias: s.alias,
|
|
1036
|
+
status: s.status,
|
|
1037
|
+
agent: s.agent ?? null,
|
|
1038
|
+
task: s.task ?? null,
|
|
1039
|
+
server: s.server ?? null,
|
|
1040
|
+
updated_at: s.updated_at ?? null,
|
|
1041
|
+
runtime: normalizeRuntime(s.agent),
|
|
1042
|
+
network_id: s.network_id ?? null,
|
|
1043
|
+
};
|
|
1044
|
+
}
|
|
1045
|
+
return {
|
|
1046
|
+
...s,
|
|
1047
|
+
model: s.model ?? null,
|
|
1048
|
+
runtime: normalizeRuntime(s.agent),
|
|
1049
|
+
host: {
|
|
1050
|
+
hostname: s.hostname ?? null,
|
|
1051
|
+
ip: s.ip ?? null,
|
|
1052
|
+
cpu_load_1min: s.cpu_load_1min ?? null,
|
|
1053
|
+
cpu_cores: s.cpu_cores ?? null,
|
|
1054
|
+
mem_total_gb: s.mem_total_gb ?? null,
|
|
1055
|
+
mem_used_gb: s.mem_used_gb ?? null,
|
|
1056
|
+
mem_avail_gb: s.mem_avail_gb ?? null,
|
|
1057
|
+
disk_total_gb: s.disk_total_gb ?? null,
|
|
1058
|
+
disk_used_gb: s.disk_used_gb ?? null,
|
|
1059
|
+
disk_avail_gb: s.disk_avail_gb ?? null,
|
|
1060
|
+
},
|
|
1061
|
+
process_telemetry: {
|
|
1062
|
+
rss_bytes: s.process_rss_bytes ?? null,
|
|
1063
|
+
rss_mb: s.process_rss_mb ?? null,
|
|
1064
|
+
cpu_pct: s.process_cpu_pct ?? null,
|
|
1065
|
+
uptime_seconds: s.process_uptime_seconds ?? null,
|
|
1066
|
+
in_flight_count: s.process_in_flight_count ?? null,
|
|
1067
|
+
},
|
|
1068
|
+
};
|
|
1069
|
+
});
|
|
991
1070
|
const summary = sessions.reduce((acc: any, session: any) => {
|
|
992
1071
|
const raw = String(session.status || "").toLowerCase();
|
|
993
1072
|
if (raw === "offline") acc.offline++;
|
|
@@ -1197,6 +1276,222 @@ Bun.serve({
|
|
|
1197
1276
|
}));
|
|
1198
1277
|
}
|
|
1199
1278
|
|
|
1279
|
+
// ── REST: file upload (#221) ──
|
|
1280
|
+
// POST multipart/form-data with a single `file` field. Bearer auth.
|
|
1281
|
+
// 12 MiB cap (two-stage: Content-Length pre-check + post-parse size
|
|
1282
|
+
// verify). Returns { ok, file_id, path, url, size, mime }. The
|
|
1283
|
+
// file_id is server-generated (crypto.randomUUID with hyphens
|
|
1284
|
+
// stripped) so a client-supplied filename never enters the storage
|
|
1285
|
+
// path. The download URL `/api/files/<file_id>` requires the same
|
|
1286
|
+
// Bearer auth and forces Content-Disposition: attachment +
|
|
1287
|
+
// X-Content-Type-Options: nosniff so uploaded files can never be
|
|
1288
|
+
// executed or served as HTML.
|
|
1289
|
+
if (url.pathname === "/api/upload" && req.method === "POST") {
|
|
1290
|
+
const authErr = requireAuth(req);
|
|
1291
|
+
if (authErr) return withCors(req, authErr);
|
|
1292
|
+
|
|
1293
|
+
// Rate-limit key prefers the token id (so 60/h is per-token, not
|
|
1294
|
+
// per-IP); falls back to IP for legacy or anonymous-but-dev-open.
|
|
1295
|
+
const authCtx = resolveRequestAuth(req);
|
|
1296
|
+
const rateKey = authCtx?.tokenId ?? `ip:${getClientIP(req, server)}`;
|
|
1297
|
+
const rate = sharedUploadRateLimiter.check(rateKey);
|
|
1298
|
+
if (!rate.allowed) {
|
|
1299
|
+
const headers: Record<string, string> = {
|
|
1300
|
+
"X-RateLimit-Limit": String(60),
|
|
1301
|
+
"X-RateLimit-Remaining": "0",
|
|
1302
|
+
"X-RateLimit-Reset": String(Math.floor(rate.resetAt / 1000)),
|
|
1303
|
+
};
|
|
1304
|
+
if (rate.retryAfterMs) headers["Retry-After"] = String(Math.ceil(rate.retryAfterMs / 1000));
|
|
1305
|
+
return withCors(req, new Response(
|
|
1306
|
+
JSON.stringify({ ok: false, error: "rate_limited", message: "Upload rate limit exceeded (60/hour). Try again later.", retry_after_ms: rate.retryAfterMs }),
|
|
1307
|
+
{ status: 429, headers: { ...headers, "Content-Type": "application/json" } },
|
|
1308
|
+
));
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// Two-stage size cap per 通信龙 dispatch c38de7a9:
|
|
1312
|
+
// stage 1 — reject before reading body when Content-Length
|
|
1313
|
+
// exceeds the cap (saves bandwidth, fails fast)
|
|
1314
|
+
// stage 2 — re-verify the parsed File.size after Bun has parsed
|
|
1315
|
+
// the multipart envelope (defends against a missing
|
|
1316
|
+
// or lied Content-Length header)
|
|
1317
|
+
const contentLength = req.headers.get("Content-Length");
|
|
1318
|
+
if (!contentLength) {
|
|
1319
|
+
return withCors(req, Response.json(
|
|
1320
|
+
{ ok: false, error: "length_required", message: "Content-Length header is required for /api/upload" },
|
|
1321
|
+
{ status: 411 },
|
|
1322
|
+
));
|
|
1323
|
+
}
|
|
1324
|
+
const declaredBytes = Number(contentLength);
|
|
1325
|
+
if (!Number.isFinite(declaredBytes) || declaredBytes < 0) {
|
|
1326
|
+
return withCors(req, Response.json({ ok: false, error: "bad_content_length" }, { status: 400 }));
|
|
1327
|
+
}
|
|
1328
|
+
if (declaredBytes > MAX_REQUEST_CONTENT_LENGTH) {
|
|
1329
|
+
return withCors(req, Response.json(
|
|
1330
|
+
{ ok: false, error: "payload_too_large", message: `Upload exceeds the ${MAX_UPLOAD_BYTES} byte limit`, limit_bytes: MAX_UPLOAD_BYTES },
|
|
1331
|
+
{ status: 413 },
|
|
1332
|
+
));
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
const contentType = req.headers.get("Content-Type") ?? "";
|
|
1336
|
+
if (!/^multipart\/form-data/i.test(contentType)) {
|
|
1337
|
+
return withCors(req, Response.json(
|
|
1338
|
+
{ ok: false, error: "unsupported_media_type", message: "Use multipart/form-data with a 'file' field" },
|
|
1339
|
+
{ status: 415 },
|
|
1340
|
+
));
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
let form: FormData;
|
|
1344
|
+
try {
|
|
1345
|
+
form = await req.formData();
|
|
1346
|
+
} catch (e: any) {
|
|
1347
|
+
return withCors(req, Response.json(
|
|
1348
|
+
{ ok: false, error: "multipart_parse_failed", message: e?.message?.slice(0, 200) ?? "multipart parse failed" },
|
|
1349
|
+
{ status: 400 },
|
|
1350
|
+
));
|
|
1351
|
+
}
|
|
1352
|
+
const file = form.get("file");
|
|
1353
|
+
if (!file || typeof file === "string") {
|
|
1354
|
+
return withCors(req, Response.json(
|
|
1355
|
+
{ ok: false, error: "missing_file", message: "Expected a 'file' field with binary content" },
|
|
1356
|
+
{ status: 400 },
|
|
1357
|
+
));
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// Stage 2 — re-verify parsed size against the documented cap.
|
|
1361
|
+
if (file.size > MAX_UPLOAD_BYTES) {
|
|
1362
|
+
return withCors(req, Response.json(
|
|
1363
|
+
{ ok: false, error: "payload_too_large", message: `Upload exceeds the ${MAX_UPLOAD_BYTES} byte limit`, limit_bytes: MAX_UPLOAD_BYTES, observed_bytes: file.size },
|
|
1364
|
+
{ status: 413 },
|
|
1365
|
+
));
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
const fileId = generateFileId();
|
|
1369
|
+
const ext = sanitizeExt(file.name);
|
|
1370
|
+
let storage;
|
|
1371
|
+
try {
|
|
1372
|
+
storage = buildStoragePath(fileId, ext);
|
|
1373
|
+
} catch (e: any) {
|
|
1374
|
+
// Should be unreachable — we generated the id ourselves — but
|
|
1375
|
+
// surface as 500 if our own primitives reject our own input.
|
|
1376
|
+
console.error("[/api/upload] buildStoragePath rejected:", e?.message);
|
|
1377
|
+
return withCors(req, Response.json({ ok: false, error: "internal_path_error" }, { status: 500 }));
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
try {
|
|
1381
|
+
mkdirSync(pathDirname(storage.absolutePath), { recursive: true });
|
|
1382
|
+
const buf = Buffer.from(await file.arrayBuffer());
|
|
1383
|
+
writeFileSync(storage.absolutePath, buf);
|
|
1384
|
+
// Defense-in-depth: after writing, confirm the path still sits
|
|
1385
|
+
// inside the uploads root (catches a symlink swap or root env
|
|
1386
|
+
// poisoning across processes).
|
|
1387
|
+
if (!isPathInsideUploadsRoot(storage.absolutePath)) {
|
|
1388
|
+
// Refuse to acknowledge a write that landed outside the root.
|
|
1389
|
+
// Don't try to clean up — better to keep evidence on disk than
|
|
1390
|
+
// chase a symlink for cleanup.
|
|
1391
|
+
console.error("[/api/upload] write escaped uploads root:", storage.absolutePath);
|
|
1392
|
+
return withCors(req, Response.json({ ok: false, error: "internal_path_escape" }, { status: 500 }));
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
// Persist the index entry so /api/files/<file_id> can do an
|
|
1396
|
+
// O(1) lookup without scanning every date bucket.
|
|
1397
|
+
const idxPath = indexEntryPath(fileId);
|
|
1398
|
+
if (idxPath) {
|
|
1399
|
+
mkdirSync(pathDirname(idxPath), { recursive: true });
|
|
1400
|
+
writeFileSync(idxPath, JSON.stringify({
|
|
1401
|
+
file_id: fileId,
|
|
1402
|
+
date_bucket: storage.dateBucket,
|
|
1403
|
+
ext: storage.ext,
|
|
1404
|
+
name: typeof file.name === "string" ? file.name.slice(0, 255) : "",
|
|
1405
|
+
mime: typeof file.type === "string" ? file.type.slice(0, 100) : "application/octet-stream",
|
|
1406
|
+
size: file.size,
|
|
1407
|
+
owner: authCtx?.username ?? null,
|
|
1408
|
+
owner_id: authCtx?.userId ?? null,
|
|
1409
|
+
uploaded_at: new Date().toISOString(),
|
|
1410
|
+
}, null, 2));
|
|
1411
|
+
}
|
|
1412
|
+
} catch (e: any) {
|
|
1413
|
+
console.error("[/api/upload] write failed:", e?.message);
|
|
1414
|
+
return withCors(req, Response.json({ ok: false, error: "write_failed", message: e?.message?.slice(0, 200) ?? "write failed" }, { status: 500 }));
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
return withCors(req, Response.json({
|
|
1418
|
+
ok: true,
|
|
1419
|
+
file_id: fileId,
|
|
1420
|
+
path: storage.absolutePath,
|
|
1421
|
+
url: `/api/files/${fileId}`,
|
|
1422
|
+
size: file.size,
|
|
1423
|
+
mime: typeof file.type === "string" ? file.type : "application/octet-stream",
|
|
1424
|
+
}));
|
|
1425
|
+
}
|
|
1426
|
+
|
|
1427
|
+
// ── REST: file download (#221) ──
|
|
1428
|
+
// GET /api/files/:file_id with Bearer auth. Always forces
|
|
1429
|
+
// Content-Disposition: attachment + X-Content-Type-Options: nosniff
|
|
1430
|
+
// so the served file can never be executed or rendered as HTML.
|
|
1431
|
+
// The file_id is validated against the same strict regex used at
|
|
1432
|
+
// generation time before any filesystem access.
|
|
1433
|
+
const fileMatch = url.pathname.match(/^\/api\/files\/(.+)$/);
|
|
1434
|
+
if (fileMatch && req.method === "GET") {
|
|
1435
|
+
const authErr = requireAuth(req);
|
|
1436
|
+
if (authErr) return withCors(req, authErr);
|
|
1437
|
+
|
|
1438
|
+
const fileId = decodeURIComponent(fileMatch[1]);
|
|
1439
|
+
if (!FILE_ID_REGEX.test(fileId)) {
|
|
1440
|
+
return withCors(req, Response.json({ ok: false, error: "bad_file_id" }, { status: 400 }));
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
const idxPath = indexEntryPath(fileId);
|
|
1444
|
+
if (!idxPath || !existsSync(idxPath)) {
|
|
1445
|
+
return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
let entry: any;
|
|
1449
|
+
try { entry = JSON.parse(readFileSync(idxPath, "utf-8")); } catch {
|
|
1450
|
+
return withCors(req, Response.json({ ok: false, error: "index_corrupt" }, { status: 500 }));
|
|
1451
|
+
}
|
|
1452
|
+
if (!validateIndexEntry(entry)) {
|
|
1453
|
+
return withCors(req, Response.json({ ok: false, error: "index_invalid" }, { status: 500 }));
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
let storage;
|
|
1457
|
+
try {
|
|
1458
|
+
storage = buildStoragePath(entry.file_id, entry.ext);
|
|
1459
|
+
} catch {
|
|
1460
|
+
return withCors(req, Response.json({ ok: false, error: "index_invalid" }, { status: 500 }));
|
|
1461
|
+
}
|
|
1462
|
+
if (!isPathInsideUploadsRoot(storage.absolutePath)) {
|
|
1463
|
+
// Defence: should be unreachable since buildStoragePath rejected
|
|
1464
|
+
// malformed inputs, but a poisoned index could try to fool us.
|
|
1465
|
+
return withCors(req, Response.json({ ok: false, error: "path_escape" }, { status: 500 }));
|
|
1466
|
+
}
|
|
1467
|
+
if (!existsSync(storage.absolutePath)) {
|
|
1468
|
+
return withCors(req, Response.json({ ok: false, error: "blob_missing" }, { status: 404 }));
|
|
1469
|
+
}
|
|
1470
|
+
|
|
1471
|
+
// Defence: verify the on-disk size matches what the index claims
|
|
1472
|
+
// before serving — a tampered index could otherwise mislead the
|
|
1473
|
+
// caller about file integrity.
|
|
1474
|
+
const st = statSync(storage.absolutePath);
|
|
1475
|
+
if (st.size !== entry.size) {
|
|
1476
|
+
console.error("[/api/files] size mismatch:", { fileId, indexed: entry.size, onDisk: st.size });
|
|
1477
|
+
return withCors(req, Response.json({ ok: false, error: "size_mismatch" }, { status: 500 }));
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
const safeFilename = (entry.name && /^[\x20-\x7e]+$/.test(entry.name))
|
|
1481
|
+
? entry.name
|
|
1482
|
+
: `${fileId}${entry.ext}`;
|
|
1483
|
+
const blob = Bun.file(storage.absolutePath);
|
|
1484
|
+
const responseHeaders: Record<string, string> = {
|
|
1485
|
+
...corsHeaders(req),
|
|
1486
|
+
"Content-Type": "application/octet-stream", // always opaque; nosniff prevents the client from re-deciding
|
|
1487
|
+
"Content-Length": String(entry.size),
|
|
1488
|
+
"Content-Disposition": `attachment; filename="${safeFilename.replace(/"/g, "")}"`,
|
|
1489
|
+
"X-Content-Type-Options": "nosniff",
|
|
1490
|
+
"Cache-Control": "private, no-store",
|
|
1491
|
+
};
|
|
1492
|
+
return new Response(blob, { status: 200, headers: responseHeaders });
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1200
1495
|
// ── REST: send task ──
|
|
1201
1496
|
if (url.pathname === "/api/task" && req.method === "POST") {
|
|
1202
1497
|
let raw: unknown;
|
|
@@ -1243,7 +1538,22 @@ Bun.serve({
|
|
|
1243
1538
|
const id = crypto.randomUUID();
|
|
1244
1539
|
const fromSession = body.from || "api";
|
|
1245
1540
|
const ttlSeconds = (body as any).ttl_seconds || 3600;
|
|
1246
|
-
const
|
|
1541
|
+
const fromNodeId = resolveRestNodeIdForAlias(fromSession, taskNetId);
|
|
1542
|
+
const targetNodeId = target.session?.node_id ?? null;
|
|
1543
|
+
// #221 — fold top-level `attachments` into `meta.attachments` so
|
|
1544
|
+
// the REST and MCP send_task transports produce identical
|
|
1545
|
+
// tasks.meta_json shape downstream. Top-level wins over any
|
|
1546
|
+
// duplicate `meta.attachments` the client supplied. Validation
|
|
1547
|
+
// matches the MCP-side schema (max 20 entries, file_id regex,
|
|
1548
|
+
// size cap, etc.).
|
|
1549
|
+
const attachmentsResult = validateAttachments((body as any).attachments ?? (body as any).meta?.attachments);
|
|
1550
|
+
if (!attachmentsResult.ok) {
|
|
1551
|
+
return withCors(req, Response.json({ ok: false, error: "bad_attachments", message: attachmentsResult.error }, { status: 400 }));
|
|
1552
|
+
}
|
|
1553
|
+
const mergedMeta = attachmentsResult.attachments.length
|
|
1554
|
+
? { ...((body as any).meta && typeof (body as any).meta === "object" ? (body as any).meta : {}), attachments: attachmentsResult.attachments }
|
|
1555
|
+
: (body as any).meta;
|
|
1556
|
+
const metaJson = normalizeMetaJson(mergedMeta);
|
|
1247
1557
|
|
|
1248
1558
|
// #212 dedup guardrail. Mirrors the MCP `send_task` tool: same
|
|
1249
1559
|
// (from_session, target_alias, task) within COMMHUB_SEND_DEDUP_WINDOW_MS
|
|
@@ -1269,14 +1579,14 @@ Bun.serve({
|
|
|
1269
1579
|
// dispatched via REST (anet demo, dashboard Dispatch button, etc.).
|
|
1270
1580
|
db.transaction(() => {
|
|
1271
1581
|
db.run(
|
|
1272
|
-
`INSERT INTO inbox (id, session_name, type, priority, content, from_session, requires_response, network_id, meta_json)
|
|
1273
|
-
VALUES (?1, ?2, 'task', ?
|
|
1274
|
-
[id, targetAlias, body.priority, body.task, fromSession, taskNetId, metaJson]
|
|
1582
|
+
`INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, requires_response, network_id, meta_json)
|
|
1583
|
+
VALUES (?1, ?2, ?3, 'task', ?4, ?5, ?6, 'reply', ?7, ?8)`,
|
|
1584
|
+
[id, targetAlias, targetNodeId, body.priority, body.task, fromSession, taskNetId, metaJson]
|
|
1275
1585
|
);
|
|
1276
1586
|
db.run(
|
|
1277
|
-
`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)
|
|
1278
|
-
VALUES (?1, ?2, ?3, ?4, 'delivered', ?
|
|
1279
|
-
[id, fromSession, targetAlias, body.priority, body.task, `+${ttlSeconds} seconds`, taskNetId, body.parent_task_id ?? null, metaJson]
|
|
1587
|
+
`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)
|
|
1588
|
+
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'delivered', ?7, 'reply', datetime('now'), datetime('now'), datetime('now', ?8), ?9, ?10, ?11)`,
|
|
1589
|
+
[id, fromNodeId, fromSession, targetNodeId, targetAlias, body.priority, body.task, `+${ttlSeconds} seconds`, taskNetId, body.parent_task_id ?? null, metaJson]
|
|
1280
1590
|
);
|
|
1281
1591
|
// Touch session row so the dashboard reflects "task in flight"
|
|
1282
1592
|
// immediately, without waiting for the agent's report_status to
|
|
@@ -1334,19 +1644,19 @@ Bun.serve({
|
|
|
1334
1644
|
if (!canRestWriteNetwork(restAuth, restScope.networkId, isAdmin)) {
|
|
1335
1645
|
return withCors(req, Response.json({ ok: false, error: "permission_denied" }, { status: 403 }));
|
|
1336
1646
|
}
|
|
1337
|
-
let sql = "SELECT alias, network_id FROM sessions WHERE alias IS NOT NULL";
|
|
1647
|
+
let sql = "SELECT alias, node_id, network_id FROM sessions WHERE alias IS NOT NULL";
|
|
1338
1648
|
const params: any[] = [];
|
|
1339
1649
|
sql = addNetworkScope(sql, params, restScope);
|
|
1340
1650
|
if (body.filter_server) { sql += " AND server = ?"; params.push(body.filter_server); }
|
|
1341
1651
|
if (body.filter_status) { sql += " AND status = ?"; params.push(body.filter_status); }
|
|
1342
|
-
const targets = db.all<{ alias: string; network_id: string | null }>(sql, ...params);
|
|
1652
|
+
const targets = db.all<{ alias: string; node_id: string | null; network_id: string | null }>(sql, ...params);
|
|
1343
1653
|
const ids: string[] = [];
|
|
1344
1654
|
for (const t of targets) {
|
|
1345
1655
|
const id = crypto.randomUUID();
|
|
1346
1656
|
db.run(
|
|
1347
|
-
`INSERT INTO inbox (id, session_name, type, priority, content, from_session, network_id)
|
|
1348
|
-
VALUES (?1, ?2, 'broadcast', 'normal', ?
|
|
1349
|
-
[id, t.alias, body.message, t.network_id]
|
|
1657
|
+
`INSERT INTO inbox (id, session_name, node_id, type, priority, content, from_session, network_id)
|
|
1658
|
+
VALUES (?1, ?2, ?3, 'broadcast', 'normal', ?4, 'api', ?5)`,
|
|
1659
|
+
[id, t.alias, t.node_id ?? null, body.message, t.network_id]
|
|
1350
1660
|
);
|
|
1351
1661
|
ids.push(id);
|
|
1352
1662
|
}
|
package/src/push.test.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { afterEach, expect, test } from "bun:test";
|
|
2
|
+
import { eventBus } from "./event_bus";
|
|
3
|
+
import { __resetSSEClientsForTest, createSSEStream, getSSEStats } from "./push";
|
|
4
|
+
|
|
5
|
+
afterEach(() => {
|
|
6
|
+
__resetSSEClientsForTest();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("rename-committed rekeys existing SSE clients from old alias to new alias (case 8)", async () => {
|
|
10
|
+
const res = createSSEStream("old-agent", "net-rekey-8");
|
|
11
|
+
const reader = res.body!.getReader();
|
|
12
|
+
|
|
13
|
+
expect(getSSEStats().sessions["net-rekey-8:old-agent"]).toBe(1);
|
|
14
|
+
eventBus.emit("rename-committed", {
|
|
15
|
+
networkId: "net-rekey-8",
|
|
16
|
+
old_alias: "old-agent",
|
|
17
|
+
new_alias: "new-agent",
|
|
18
|
+
node_id: "node-8",
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const stats = getSSEStats().sessions;
|
|
22
|
+
expect(stats["net-rekey-8:old-agent"]).toBeUndefined();
|
|
23
|
+
expect(stats["net-rekey-8:new-agent"]).toBe(1);
|
|
24
|
+
await reader.cancel();
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("rename-committed merges old and new SSE client buckets without dropping clients (case 11)", async () => {
|
|
28
|
+
const oldRes = createSSEStream("old-agent", "net-rekey-11");
|
|
29
|
+
const newRes = createSSEStream("new-agent", "net-rekey-11");
|
|
30
|
+
const oldReader = oldRes.body!.getReader();
|
|
31
|
+
const newReader = newRes.body!.getReader();
|
|
32
|
+
|
|
33
|
+
expect(getSSEStats().sessions["net-rekey-11:old-agent"]).toBe(1);
|
|
34
|
+
expect(getSSEStats().sessions["net-rekey-11:new-agent"]).toBe(1);
|
|
35
|
+
eventBus.emit("rename-committed", {
|
|
36
|
+
networkId: "net-rekey-11",
|
|
37
|
+
old_alias: "old-agent",
|
|
38
|
+
new_alias: "new-agent",
|
|
39
|
+
node_id: "node-11",
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const stats = getSSEStats().sessions;
|
|
43
|
+
expect(stats["net-rekey-11:old-agent"]).toBeUndefined();
|
|
44
|
+
expect(stats["net-rekey-11:new-agent"]).toBe(2);
|
|
45
|
+
await oldReader.cancel();
|
|
46
|
+
await newReader.cancel();
|
|
47
|
+
});
|
package/src/push.ts
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// Agent 连 GET /events/:session → 保持 SSE 长连接
|
|
3
3
|
// send_task 写 inbox 后 → pushEvent() → 秒达
|
|
4
4
|
|
|
5
|
+
import { eventBus, type RenameCommittedEvent } from "./event_bus";
|
|
6
|
+
|
|
5
7
|
type SSEClient = {
|
|
6
8
|
controller: ReadableStreamDefaultController;
|
|
7
9
|
encoder: TextEncoder;
|
|
@@ -72,6 +74,28 @@ function clientKey(sessionName: string, networkId?: string | null): string {
|
|
|
72
74
|
return `${networkId || "global"}:${sessionName}`;
|
|
73
75
|
}
|
|
74
76
|
|
|
77
|
+
function rekeyClient(oldSessionName: string, newSessionName: string, networkId?: string | null): number {
|
|
78
|
+
if (!oldSessionName || !newSessionName || oldSessionName === newSessionName) return 0;
|
|
79
|
+
const oldKey = clientKey(oldSessionName, networkId);
|
|
80
|
+
const newKey = clientKey(newSessionName, networkId);
|
|
81
|
+
const oldClients = clients.get(oldKey);
|
|
82
|
+
if (!oldClients || oldClients.length === 0) return 0;
|
|
83
|
+
|
|
84
|
+
const existing = clients.get(newKey) || [];
|
|
85
|
+
clients.set(newKey, existing.concat(oldClients));
|
|
86
|
+
clients.delete(oldKey);
|
|
87
|
+
console.log(`[${ts()}] SSE ↔ rekey ${oldKey} → ${newKey} (${oldClients.length} clients)`);
|
|
88
|
+
return oldClients.length;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
eventBus.on("rename-committed", (event: RenameCommittedEvent) => {
|
|
92
|
+
try {
|
|
93
|
+
rekeyClient(event.old_alias, event.new_alias, event.networkId);
|
|
94
|
+
} catch (e: any) {
|
|
95
|
+
console.log(`[${ts()}] SSE rekey failed: ${e?.message || e}`);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
75
99
|
/** 推送事件给指定 session 的所有 SSE 连接 */
|
|
76
100
|
export function pushEvent(sessionName: string, event: Record<string, unknown>, networkId?: string | null): void {
|
|
77
101
|
const arr = clients.get(clientKey(sessionName, networkId ?? (event.network_id as string | null | undefined)));
|
|
@@ -95,6 +119,10 @@ export function pushEvent(sessionName: string, event: Record<string, unknown>, n
|
|
|
95
119
|
if (arr.length === 0) clients.delete(clientKey(sessionName, networkId ?? (event.network_id as string | null | undefined)));
|
|
96
120
|
}
|
|
97
121
|
|
|
122
|
+
export function __resetSSEClientsForTest(): void {
|
|
123
|
+
clients.clear();
|
|
124
|
+
}
|
|
125
|
+
|
|
98
126
|
/** 获取当前 SSE 连接统计 */
|
|
99
127
|
export function getSSEStats(): { total: number; sessions: Record<string, number> } {
|
|
100
128
|
let total = 0;
|