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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sleep2agi/commhub-server",
3
- "version": "0.8.5",
3
+ "version": "0.8.6-preview.0",
4
4
  "description": "CommHub Server — AI Agent communication hub with MCP protocol, multi-network isolation, user auth, and 17 MCP tools.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
package/src/index.ts CHANGED
@@ -7,6 +7,22 @@ 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 {
11
+ FILE_ID_REGEX,
12
+ MAX_REQUEST_CONTENT_LENGTH,
13
+ MAX_UPLOAD_BYTES,
14
+ buildStoragePath,
15
+ generateFileId,
16
+ getUploadsRoot,
17
+ indexEntryPath,
18
+ isPathInsideUploadsRoot,
19
+ sanitizeExt,
20
+ sharedUploadRateLimiter,
21
+ validateAttachments,
22
+ validateIndexEntry,
23
+ } from "./uploads.js";
24
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, statSync } from "fs";
25
+ import { dirname as pathDirname } from "path";
10
26
 
11
27
  const PORT = Number(process.env.PORT) || 9200;
12
28
  const HOST = process.env.HOST || "127.0.0.1";
@@ -380,6 +396,10 @@ const TaskSchema = z.object({
380
396
  network_id: z.string().max(200).optional(),
381
397
  parent_task_id: z.string().max(200).optional(),
382
398
  ttl_seconds: z.number().min(1).max(86400).optional(),
399
+ // #221 — top-level attachments mirror the MCP send_task tool. They are
400
+ // merged into `meta.attachments` for persistence so the on-disk shape
401
+ // stays unified regardless of transport (REST or MCP).
402
+ attachments: z.any().optional(),
383
403
  meta: z.any().optional(),
384
404
  });
385
405
 
@@ -443,6 +463,13 @@ Bun.serve({
443
463
  port: PORT,
444
464
  hostname: HOST,
445
465
  idleTimeout: 255, // max value: keep SSE connections alive (seconds)
466
+ // #221 — defense-in-depth cap on the raw request body. The /api/upload
467
+ // handler also pre-checks Content-Length and post-checks parsed size
468
+ // against the documented 12 MiB cap (MAX_UPLOAD_BYTES); this knob
469
+ // simply guarantees Bun's own buffer won't grow past 13 MiB on any
470
+ // route. Default Bun ceiling is 128 MiB which is too permissive for
471
+ // a hub now exposed to the public internet.
472
+ maxRequestBodySize: MAX_REQUEST_CONTENT_LENGTH,
446
473
 
447
474
  async fetch(req, server) {
448
475
  const url = new URL(req.url, `http://localhost:${PORT}`);
@@ -1197,6 +1224,222 @@ Bun.serve({
1197
1224
  }));
1198
1225
  }
1199
1226
 
1227
+ // ── REST: file upload (#221) ──
1228
+ // POST multipart/form-data with a single `file` field. Bearer auth.
1229
+ // 12 MiB cap (two-stage: Content-Length pre-check + post-parse size
1230
+ // verify). Returns { ok, file_id, path, url, size, mime }. The
1231
+ // file_id is server-generated (crypto.randomUUID with hyphens
1232
+ // stripped) so a client-supplied filename never enters the storage
1233
+ // path. The download URL `/api/files/<file_id>` requires the same
1234
+ // Bearer auth and forces Content-Disposition: attachment +
1235
+ // X-Content-Type-Options: nosniff so uploaded files can never be
1236
+ // executed or served as HTML.
1237
+ if (url.pathname === "/api/upload" && req.method === "POST") {
1238
+ const authErr = requireAuth(req);
1239
+ if (authErr) return withCors(req, authErr);
1240
+
1241
+ // Rate-limit key prefers the token id (so 60/h is per-token, not
1242
+ // per-IP); falls back to IP for legacy or anonymous-but-dev-open.
1243
+ const authCtx = resolveRequestAuth(req);
1244
+ const rateKey = authCtx?.tokenId ?? `ip:${getClientIP(req, server)}`;
1245
+ const rate = sharedUploadRateLimiter.check(rateKey);
1246
+ if (!rate.allowed) {
1247
+ const headers: Record<string, string> = {
1248
+ "X-RateLimit-Limit": String(60),
1249
+ "X-RateLimit-Remaining": "0",
1250
+ "X-RateLimit-Reset": String(Math.floor(rate.resetAt / 1000)),
1251
+ };
1252
+ if (rate.retryAfterMs) headers["Retry-After"] = String(Math.ceil(rate.retryAfterMs / 1000));
1253
+ return withCors(req, new Response(
1254
+ JSON.stringify({ ok: false, error: "rate_limited", message: "Upload rate limit exceeded (60/hour). Try again later.", retry_after_ms: rate.retryAfterMs }),
1255
+ { status: 429, headers: { ...headers, "Content-Type": "application/json" } },
1256
+ ));
1257
+ }
1258
+
1259
+ // Two-stage size cap per 通信龙 dispatch c38de7a9:
1260
+ // stage 1 — reject before reading body when Content-Length
1261
+ // exceeds the cap (saves bandwidth, fails fast)
1262
+ // stage 2 — re-verify the parsed File.size after Bun has parsed
1263
+ // the multipart envelope (defends against a missing
1264
+ // or lied Content-Length header)
1265
+ const contentLength = req.headers.get("Content-Length");
1266
+ if (!contentLength) {
1267
+ return withCors(req, Response.json(
1268
+ { ok: false, error: "length_required", message: "Content-Length header is required for /api/upload" },
1269
+ { status: 411 },
1270
+ ));
1271
+ }
1272
+ const declaredBytes = Number(contentLength);
1273
+ if (!Number.isFinite(declaredBytes) || declaredBytes < 0) {
1274
+ return withCors(req, Response.json({ ok: false, error: "bad_content_length" }, { status: 400 }));
1275
+ }
1276
+ if (declaredBytes > MAX_REQUEST_CONTENT_LENGTH) {
1277
+ return withCors(req, Response.json(
1278
+ { ok: false, error: "payload_too_large", message: `Upload exceeds the ${MAX_UPLOAD_BYTES} byte limit`, limit_bytes: MAX_UPLOAD_BYTES },
1279
+ { status: 413 },
1280
+ ));
1281
+ }
1282
+
1283
+ const contentType = req.headers.get("Content-Type") ?? "";
1284
+ if (!/^multipart\/form-data/i.test(contentType)) {
1285
+ return withCors(req, Response.json(
1286
+ { ok: false, error: "unsupported_media_type", message: "Use multipart/form-data with a 'file' field" },
1287
+ { status: 415 },
1288
+ ));
1289
+ }
1290
+
1291
+ let form: FormData;
1292
+ try {
1293
+ form = await req.formData();
1294
+ } catch (e: any) {
1295
+ return withCors(req, Response.json(
1296
+ { ok: false, error: "multipart_parse_failed", message: e?.message?.slice(0, 200) ?? "multipart parse failed" },
1297
+ { status: 400 },
1298
+ ));
1299
+ }
1300
+ const file = form.get("file");
1301
+ if (!file || typeof file === "string") {
1302
+ return withCors(req, Response.json(
1303
+ { ok: false, error: "missing_file", message: "Expected a 'file' field with binary content" },
1304
+ { status: 400 },
1305
+ ));
1306
+ }
1307
+
1308
+ // Stage 2 — re-verify parsed size against the documented cap.
1309
+ if (file.size > MAX_UPLOAD_BYTES) {
1310
+ return withCors(req, Response.json(
1311
+ { ok: false, error: "payload_too_large", message: `Upload exceeds the ${MAX_UPLOAD_BYTES} byte limit`, limit_bytes: MAX_UPLOAD_BYTES, observed_bytes: file.size },
1312
+ { status: 413 },
1313
+ ));
1314
+ }
1315
+
1316
+ const fileId = generateFileId();
1317
+ const ext = sanitizeExt(file.name);
1318
+ let storage;
1319
+ try {
1320
+ storage = buildStoragePath(fileId, ext);
1321
+ } catch (e: any) {
1322
+ // Should be unreachable — we generated the id ourselves — but
1323
+ // surface as 500 if our own primitives reject our own input.
1324
+ console.error("[/api/upload] buildStoragePath rejected:", e?.message);
1325
+ return withCors(req, Response.json({ ok: false, error: "internal_path_error" }, { status: 500 }));
1326
+ }
1327
+
1328
+ try {
1329
+ mkdirSync(pathDirname(storage.absolutePath), { recursive: true });
1330
+ const buf = Buffer.from(await file.arrayBuffer());
1331
+ writeFileSync(storage.absolutePath, buf);
1332
+ // Defense-in-depth: after writing, confirm the path still sits
1333
+ // inside the uploads root (catches a symlink swap or root env
1334
+ // poisoning across processes).
1335
+ if (!isPathInsideUploadsRoot(storage.absolutePath)) {
1336
+ // Refuse to acknowledge a write that landed outside the root.
1337
+ // Don't try to clean up — better to keep evidence on disk than
1338
+ // chase a symlink for cleanup.
1339
+ console.error("[/api/upload] write escaped uploads root:", storage.absolutePath);
1340
+ return withCors(req, Response.json({ ok: false, error: "internal_path_escape" }, { status: 500 }));
1341
+ }
1342
+
1343
+ // Persist the index entry so /api/files/<file_id> can do an
1344
+ // O(1) lookup without scanning every date bucket.
1345
+ const idxPath = indexEntryPath(fileId);
1346
+ if (idxPath) {
1347
+ mkdirSync(pathDirname(idxPath), { recursive: true });
1348
+ writeFileSync(idxPath, JSON.stringify({
1349
+ file_id: fileId,
1350
+ date_bucket: storage.dateBucket,
1351
+ ext: storage.ext,
1352
+ name: typeof file.name === "string" ? file.name.slice(0, 255) : "",
1353
+ mime: typeof file.type === "string" ? file.type.slice(0, 100) : "application/octet-stream",
1354
+ size: file.size,
1355
+ owner: authCtx?.username ?? null,
1356
+ owner_id: authCtx?.userId ?? null,
1357
+ uploaded_at: new Date().toISOString(),
1358
+ }, null, 2));
1359
+ }
1360
+ } catch (e: any) {
1361
+ console.error("[/api/upload] write failed:", e?.message);
1362
+ return withCors(req, Response.json({ ok: false, error: "write_failed", message: e?.message?.slice(0, 200) ?? "write failed" }, { status: 500 }));
1363
+ }
1364
+
1365
+ return withCors(req, Response.json({
1366
+ ok: true,
1367
+ file_id: fileId,
1368
+ path: storage.absolutePath,
1369
+ url: `/api/files/${fileId}`,
1370
+ size: file.size,
1371
+ mime: typeof file.type === "string" ? file.type : "application/octet-stream",
1372
+ }));
1373
+ }
1374
+
1375
+ // ── REST: file download (#221) ──
1376
+ // GET /api/files/:file_id with Bearer auth. Always forces
1377
+ // Content-Disposition: attachment + X-Content-Type-Options: nosniff
1378
+ // so the served file can never be executed or rendered as HTML.
1379
+ // The file_id is validated against the same strict regex used at
1380
+ // generation time before any filesystem access.
1381
+ const fileMatch = url.pathname.match(/^\/api\/files\/(.+)$/);
1382
+ if (fileMatch && req.method === "GET") {
1383
+ const authErr = requireAuth(req);
1384
+ if (authErr) return withCors(req, authErr);
1385
+
1386
+ const fileId = decodeURIComponent(fileMatch[1]);
1387
+ if (!FILE_ID_REGEX.test(fileId)) {
1388
+ return withCors(req, Response.json({ ok: false, error: "bad_file_id" }, { status: 400 }));
1389
+ }
1390
+
1391
+ const idxPath = indexEntryPath(fileId);
1392
+ if (!idxPath || !existsSync(idxPath)) {
1393
+ return withCors(req, Response.json({ ok: false, error: "not_found" }, { status: 404 }));
1394
+ }
1395
+
1396
+ let entry: any;
1397
+ try { entry = JSON.parse(readFileSync(idxPath, "utf-8")); } catch {
1398
+ return withCors(req, Response.json({ ok: false, error: "index_corrupt" }, { status: 500 }));
1399
+ }
1400
+ if (!validateIndexEntry(entry)) {
1401
+ return withCors(req, Response.json({ ok: false, error: "index_invalid" }, { status: 500 }));
1402
+ }
1403
+
1404
+ let storage;
1405
+ try {
1406
+ storage = buildStoragePath(entry.file_id, entry.ext);
1407
+ } catch {
1408
+ return withCors(req, Response.json({ ok: false, error: "index_invalid" }, { status: 500 }));
1409
+ }
1410
+ if (!isPathInsideUploadsRoot(storage.absolutePath)) {
1411
+ // Defence: should be unreachable since buildStoragePath rejected
1412
+ // malformed inputs, but a poisoned index could try to fool us.
1413
+ return withCors(req, Response.json({ ok: false, error: "path_escape" }, { status: 500 }));
1414
+ }
1415
+ if (!existsSync(storage.absolutePath)) {
1416
+ return withCors(req, Response.json({ ok: false, error: "blob_missing" }, { status: 404 }));
1417
+ }
1418
+
1419
+ // Defence: verify the on-disk size matches what the index claims
1420
+ // before serving — a tampered index could otherwise mislead the
1421
+ // caller about file integrity.
1422
+ const st = statSync(storage.absolutePath);
1423
+ if (st.size !== entry.size) {
1424
+ console.error("[/api/files] size mismatch:", { fileId, indexed: entry.size, onDisk: st.size });
1425
+ return withCors(req, Response.json({ ok: false, error: "size_mismatch" }, { status: 500 }));
1426
+ }
1427
+
1428
+ const safeFilename = (entry.name && /^[\x20-\x7e]+$/.test(entry.name))
1429
+ ? entry.name
1430
+ : `${fileId}${entry.ext}`;
1431
+ const blob = Bun.file(storage.absolutePath);
1432
+ const responseHeaders: Record<string, string> = {
1433
+ ...corsHeaders(req),
1434
+ "Content-Type": "application/octet-stream", // always opaque; nosniff prevents the client from re-deciding
1435
+ "Content-Length": String(entry.size),
1436
+ "Content-Disposition": `attachment; filename="${safeFilename.replace(/"/g, "")}"`,
1437
+ "X-Content-Type-Options": "nosniff",
1438
+ "Cache-Control": "private, no-store",
1439
+ };
1440
+ return new Response(blob, { status: 200, headers: responseHeaders });
1441
+ }
1442
+
1200
1443
  // ── REST: send task ──
1201
1444
  if (url.pathname === "/api/task" && req.method === "POST") {
1202
1445
  let raw: unknown;
@@ -1243,7 +1486,20 @@ Bun.serve({
1243
1486
  const id = crypto.randomUUID();
1244
1487
  const fromSession = body.from || "api";
1245
1488
  const ttlSeconds = (body as any).ttl_seconds || 3600;
1246
- const metaJson = normalizeMetaJson((body as any).meta);
1489
+ // #221 — fold top-level `attachments` into `meta.attachments` so
1490
+ // the REST and MCP send_task transports produce identical
1491
+ // tasks.meta_json shape downstream. Top-level wins over any
1492
+ // duplicate `meta.attachments` the client supplied. Validation
1493
+ // matches the MCP-side schema (max 20 entries, file_id regex,
1494
+ // size cap, etc.).
1495
+ const attachmentsResult = validateAttachments((body as any).attachments ?? (body as any).meta?.attachments);
1496
+ if (!attachmentsResult.ok) {
1497
+ return withCors(req, Response.json({ ok: false, error: "bad_attachments", message: attachmentsResult.error }, { status: 400 }));
1498
+ }
1499
+ const mergedMeta = attachmentsResult.attachments.length
1500
+ ? { ...((body as any).meta && typeof (body as any).meta === "object" ? (body as any).meta : {}), attachments: attachmentsResult.attachments }
1501
+ : (body as any).meta;
1502
+ const metaJson = normalizeMetaJson(mergedMeta);
1247
1503
 
1248
1504
  // #212 dedup guardrail. Mirrors the MCP `send_task` tool: same
1249
1505
  // (from_session, target_alias, task) within COMMHUB_SEND_DEDUP_WINDOW_MS
@@ -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.
@@ -0,0 +1,294 @@
1
+ // #221 — unit tests for the upload primitives. The HTTP wire-up + Bun
2
+ // multipart parsing lives in server/src/index.ts; this file pins the
3
+ // path / regex / rate-limit / attachment-schema invariants that the
4
+ // 6-item security checklist depends on.
5
+ //
6
+ // Coverage matrix:
7
+ // 1. file_id regex accepts server-generated ids + rejects traversal attempts
8
+ // 2. sanitizeExt strips client-side filename paths, lowercases ext
9
+ // 3. buildStoragePath rejects malformed id / ext, places file under date bucket
10
+ // 4. isPathInsideUploadsRoot defends against symlink / traversal escape
11
+ // 5. UploadRateLimiter — first call allowed, exhausting bucket blocks, window expiry resets
12
+ // 6. validateAttachments accepts MCP-compatible shape, rejects too-many / bad type / bad file_id
13
+ // 7. validateIndexEntry refuses tampered shapes
14
+ // 8. indexEntryPath rejects malformed file_id
15
+ import { describe, expect, test } from "bun:test";
16
+ import {
17
+ FILE_ID_REGEX,
18
+ MAX_UPLOAD_BYTES,
19
+ UploadRateLimiter,
20
+ UPLOAD_RATE_MAX_PER_WINDOW,
21
+ UPLOAD_RATE_WINDOW_MS,
22
+ buildStoragePath,
23
+ generateFileId,
24
+ getDateBucket,
25
+ indexEntryPath,
26
+ isPathInsideUploadsRoot,
27
+ sanitizeExt,
28
+ validateAttachments,
29
+ validateIndexEntry,
30
+ } from "./uploads";
31
+
32
+ describe("FILE_ID_REGEX", () => {
33
+ test("accepts the server-generated uuidv4-without-hyphens shape (32 hex chars)", () => {
34
+ for (let i = 0; i < 20; i++) {
35
+ const id = generateFileId();
36
+ expect(FILE_ID_REGEX.test(id)).toBe(true);
37
+ expect(id.length).toBe(32);
38
+ }
39
+ });
40
+
41
+ test("rejects every directory-traversal attempt a client might try", () => {
42
+ const traversals = [
43
+ "../foo",
44
+ "..%2Ffoo",
45
+ "foo/bar",
46
+ "foo\\bar",
47
+ "foo.bar",
48
+ ".bashrc",
49
+ "/etc/passwd",
50
+ "foo bar",
51
+ "",
52
+ "abc", // too short
53
+ "x".repeat(65), // too long
54
+ ];
55
+ for (const t of traversals) {
56
+ expect(FILE_ID_REGEX.test(t)).toBe(false);
57
+ }
58
+ });
59
+ });
60
+
61
+ describe("sanitizeExt", () => {
62
+ test("returns a leading-dot lowercase extension for a normal filename", () => {
63
+ expect(sanitizeExt("photo.PNG")).toBe(".png");
64
+ expect(sanitizeExt("report.pdf")).toBe(".pdf");
65
+ expect(sanitizeExt("archive.tar.gz")).toBe(".gz");
66
+ });
67
+
68
+ test("strips any client-supplied directory prefix before reading the ext", () => {
69
+ // The handler must NEVER use the client filename for storage paths,
70
+ // but the ext is allowed through for nicer URLs. We still strip any
71
+ // path separator the client tries to sneak in.
72
+ expect(sanitizeExt("../../etc/passwd.jpg")).toBe(".jpg");
73
+ expect(sanitizeExt("..\\..\\windows\\boot.ini")).toBe(".ini");
74
+ expect(sanitizeExt("foo/bar/baz.html")).toBe(".html");
75
+ });
76
+
77
+ test("returns empty string for missing / weird inputs", () => {
78
+ expect(sanitizeExt(undefined)).toBe("");
79
+ expect(sanitizeExt(null)).toBe("");
80
+ expect(sanitizeExt("")).toBe("");
81
+ expect(sanitizeExt("noext")).toBe("");
82
+ // ".bashrc" → ".bashrc" is acceptable: storage uses the server-generated
83
+ // file_id, so the file lands as "<file_id>.bashrc". The download handler
84
+ // forces Content-Disposition: attachment + X-Content-Type-Options: nosniff,
85
+ // so the ext can't be leveraged to execute / mis-render the file.
86
+ expect(sanitizeExt(".bashrc")).toBe(".bashrc");
87
+ // ext > 16 chars is rejected; "extensiontoolong1" is 17 chars.
88
+ expect(sanitizeExt("foo.extensiontoolong1")).toBe("");
89
+ });
90
+
91
+ test("rejects exts with non-alphanumeric characters", () => {
92
+ expect(sanitizeExt("payload.exe;.png")).toBe(".png");
93
+ expect(sanitizeExt("foo.<script>")).toBe("");
94
+ });
95
+ });
96
+
97
+ describe("buildStoragePath", () => {
98
+ test("places the file under a YYYY-MM-DD bucket with file_id+ext as the name", () => {
99
+ const now = new Date("2026-06-11T03:00:00Z");
100
+ const r = buildStoragePath("abcdef1234567890abcdef1234567890", ".png", {
101
+ uploadsRoot: "/srv/uploads",
102
+ now,
103
+ });
104
+ expect(r.dateBucket).toBe("2026-06-11");
105
+ expect(r.relativePath).toBe("2026-06-11/abcdef1234567890abcdef1234567890.png");
106
+ expect(r.absolutePath).toBe("/srv/uploads/2026-06-11/abcdef1234567890abcdef1234567890.png");
107
+ });
108
+
109
+ test("rejects malformed file_id", () => {
110
+ expect(() => buildStoragePath("../etc/passwd", ".png", { uploadsRoot: "/srv" })).toThrow(/invalid/);
111
+ expect(() => buildStoragePath("ok", ".png", { uploadsRoot: "/srv" })).toThrow(/invalid/);
112
+ });
113
+
114
+ test("rejects malformed ext", () => {
115
+ expect(() => buildStoragePath(generateFileId(), "../passwd", { uploadsRoot: "/srv" })).toThrow(/invalid/);
116
+ expect(() => buildStoragePath(generateFileId(), ".x".repeat(40), { uploadsRoot: "/srv" })).toThrow(/invalid/);
117
+ });
118
+
119
+ test("allows empty ext", () => {
120
+ const r = buildStoragePath(generateFileId(), "", { uploadsRoot: "/srv" });
121
+ expect(r.relativePath.endsWith("/")).toBe(false);
122
+ expect(r.relativePath.includes(".")).toBe(false);
123
+ });
124
+ });
125
+
126
+ describe("getDateBucket", () => {
127
+ test("uses UTC so the bucket is consistent across hub timezones", () => {
128
+ expect(getDateBucket(new Date("2026-06-11T23:59:59Z"))).toBe("2026-06-11");
129
+ expect(getDateBucket(new Date("2026-06-12T00:00:01Z"))).toBe("2026-06-12");
130
+ });
131
+ });
132
+
133
+ describe("isPathInsideUploadsRoot", () => {
134
+ test("true for a normal nested path", () => {
135
+ expect(isPathInsideUploadsRoot("/srv/uploads/2026-06-11/abc.png", "/srv/uploads")).toBe(true);
136
+ });
137
+
138
+ test("false for an upward traversal that lands outside the root", () => {
139
+ expect(isPathInsideUploadsRoot("/srv/uploads/../etc/passwd", "/srv/uploads")).toBe(false);
140
+ expect(isPathInsideUploadsRoot("/etc/passwd", "/srv/uploads")).toBe(false);
141
+ });
142
+
143
+ test("false for the root itself (must be a file underneath, not the root dir)", () => {
144
+ expect(isPathInsideUploadsRoot("/srv/uploads", "/srv/uploads")).toBe(false);
145
+ });
146
+ });
147
+
148
+ describe("UploadRateLimiter", () => {
149
+ test("first N calls within window are allowed; the (N+1)th is blocked", () => {
150
+ const lim = new UploadRateLimiter(60_000, 3); // 3 per minute
151
+ const t0 = 1_700_000_000_000;
152
+ const a1 = lim.check("token-x", t0);
153
+ const a2 = lim.check("token-x", t0 + 100);
154
+ const a3 = lim.check("token-x", t0 + 200);
155
+ const a4 = lim.check("token-x", t0 + 300);
156
+ expect(a1.allowed).toBe(true);
157
+ expect(a2.allowed).toBe(true);
158
+ expect(a3.allowed).toBe(true);
159
+ expect(a4.allowed).toBe(false);
160
+ if (!a4.allowed) {
161
+ expect(a4.retryAfterMs).toBeGreaterThan(0);
162
+ expect(a4.retryAfterMs).toBeLessThanOrEqual(60_000);
163
+ }
164
+ });
165
+
166
+ test("different keys are isolated", () => {
167
+ const lim = new UploadRateLimiter(60_000, 1);
168
+ expect(lim.check("alice", 1).allowed).toBe(true);
169
+ expect(lim.check("alice", 2).allowed).toBe(false);
170
+ expect(lim.check("bob", 3).allowed).toBe(true);
171
+ });
172
+
173
+ test("window expiry resets the bucket", () => {
174
+ const lim = new UploadRateLimiter(60_000, 1);
175
+ const t0 = 1_700_000_000_000;
176
+ expect(lim.check("token-x", t0).allowed).toBe(true);
177
+ expect(lim.check("token-x", t0 + 30_000).allowed).toBe(false);
178
+ expect(lim.check("token-x", t0 + 60_001).allowed).toBe(true);
179
+ });
180
+
181
+ test("default constructor matches documented public defaults", () => {
182
+ const lim = new UploadRateLimiter();
183
+ expect(UPLOAD_RATE_WINDOW_MS).toBe(60 * 60 * 1000);
184
+ expect(UPLOAD_RATE_MAX_PER_WINDOW).toBe(60);
185
+ for (let i = 0; i < UPLOAD_RATE_MAX_PER_WINDOW; i++) {
186
+ expect(lim.check("token-y", 1 + i).allowed).toBe(true);
187
+ }
188
+ expect(lim.check("token-y", 1000).allowed).toBe(false);
189
+ });
190
+ });
191
+
192
+ describe("validateAttachments", () => {
193
+ test("undefined / null input → empty array", () => {
194
+ const a = validateAttachments(undefined);
195
+ expect(a.ok).toBe(true);
196
+ if (a.ok) expect(a.attachments).toEqual([]);
197
+ const b = validateAttachments(null);
198
+ expect(b.ok).toBe(true);
199
+ if (b.ok) expect(b.attachments).toEqual([]);
200
+ });
201
+
202
+ test("rejects non-array input", () => {
203
+ expect(validateAttachments("nope").ok).toBe(false);
204
+ expect(validateAttachments({}).ok).toBe(false);
205
+ });
206
+
207
+ test("accepts a well-formed attachment matching MCP send_task shape", () => {
208
+ const fileId = generateFileId();
209
+ const res = validateAttachments([
210
+ { type: "file", file_id: fileId, name: "photo.png", mime: "image/png", size: 12345 },
211
+ ]);
212
+ expect(res.ok).toBe(true);
213
+ if (res.ok) {
214
+ expect(res.attachments[0].file_id).toBe(fileId);
215
+ expect(res.attachments[0].mime).toBe("image/png");
216
+ }
217
+ });
218
+
219
+ test("rejects too many attachments (>20)", () => {
220
+ const fileId = generateFileId();
221
+ const many = Array.from({ length: 21 }, () => ({ type: "file" as const, file_id: fileId }));
222
+ const res = validateAttachments(many);
223
+ expect(res.ok).toBe(false);
224
+ });
225
+
226
+ test("rejects invalid file_id (path traversal)", () => {
227
+ const res = validateAttachments([{ type: "file", file_id: "../etc/passwd" }]);
228
+ expect(res.ok).toBe(false);
229
+ });
230
+
231
+ test("rejects size larger than MAX_UPLOAD_BYTES", () => {
232
+ const fileId = generateFileId();
233
+ const res = validateAttachments([{ type: "file", file_id: fileId, size: MAX_UPLOAD_BYTES + 1 }]);
234
+ expect(res.ok).toBe(false);
235
+ });
236
+
237
+ test("rejects unexpected type", () => {
238
+ const res = validateAttachments([{ type: "image", file_id: generateFileId() }]);
239
+ expect(res.ok).toBe(false);
240
+ });
241
+ });
242
+
243
+ describe("validateIndexEntry", () => {
244
+ test("accepts a well-formed entry", () => {
245
+ const id = generateFileId();
246
+ expect(
247
+ validateIndexEntry({
248
+ file_id: id,
249
+ date_bucket: "2026-06-11",
250
+ ext: ".png",
251
+ name: "anything",
252
+ mime: "image/png",
253
+ size: 12345,
254
+ uploaded_at: "2026-06-11T03:00:00Z",
255
+ }),
256
+ ).toBe(true);
257
+ });
258
+
259
+ test("rejects bad date_bucket (anti-tamper)", () => {
260
+ expect(
261
+ validateIndexEntry({ file_id: generateFileId(), date_bucket: "../etc", ext: ".png", size: 1 }),
262
+ ).toBe(false);
263
+ });
264
+
265
+ test("rejects bad ext", () => {
266
+ expect(
267
+ validateIndexEntry({ file_id: generateFileId(), date_bucket: "2026-06-11", ext: "/etc/passwd", size: 1 }),
268
+ ).toBe(false);
269
+ });
270
+
271
+ test("rejects size beyond MAX_UPLOAD_BYTES (anti-tamper)", () => {
272
+ expect(
273
+ validateIndexEntry({
274
+ file_id: generateFileId(),
275
+ date_bucket: "2026-06-11",
276
+ ext: "",
277
+ size: MAX_UPLOAD_BYTES + 1,
278
+ }),
279
+ ).toBe(false);
280
+ });
281
+ });
282
+
283
+ describe("indexEntryPath", () => {
284
+ test("returns null on malformed file_id", () => {
285
+ expect(indexEntryPath("../etc/passwd", { uploadsRoot: "/srv" })).toBeNull();
286
+ expect(indexEntryPath("", { uploadsRoot: "/srv" })).toBeNull();
287
+ });
288
+
289
+ test("places the index file under <uploadsRoot>/.index/", () => {
290
+ const id = generateFileId();
291
+ const p = indexEntryPath(id, { uploadsRoot: "/srv/up" });
292
+ expect(p).toBe(`/srv/up/.index/${id}.json`);
293
+ });
294
+ });
package/src/uploads.ts ADDED
@@ -0,0 +1,240 @@
1
+ // #221 — commhub-server file upload primitives.
2
+ //
3
+ // Hub upload surface for the mobile APP (#220). The hub started reverse-
4
+ // proxying over HTTPS to the public internet last night, so the upload
5
+ // endpoint is a brand-new public attack surface. The six security items
6
+ // in the issue are HARD verification items, encoded here:
7
+ //
8
+ // 1. file_id is server-generated (uuidv4, alphanumeric only), client-
9
+ // supplied filenames never enter the storage path.
10
+ // 2. Storage root sits in $HOME/.anet/server/uploads (outside any
11
+ // web-served directory); downloads go through an explicit handler.
12
+ // 3. Download handler forces Content-Disposition: attachment +
13
+ // X-Content-Type-Options: nosniff — uploaded files are never
14
+ // executed or served as HTML.
15
+ // 4. 12 MB cap enforced two-stage per 通信龙: Content-Length header
16
+ // pre-check (reject before reading body) + post-parse size verify.
17
+ // 5. Auth is the existing requireAuth — anonymous + bad tokens 401.
18
+ // 6. Naive per-token rate limit: 60 uploads/hour per token (or IP for
19
+ // legacy/dev mode).
20
+ //
21
+ // This module stays pure-ish: file I/O lives in the HTTP handlers, the
22
+ // path / id / rate-limit primitives are unit-testable.
23
+
24
+ import { homedir } from "os";
25
+ import { join, relative, isAbsolute } from "path";
26
+
27
+ export const MAX_UPLOAD_BYTES = 12 * 1024 * 1024;
28
+
29
+ // Allow ~1 MiB of multipart envelope overhead on top of the 12 MiB
30
+ // payload limit when checking Content-Length. The post-parse check is
31
+ // still done against MAX_UPLOAD_BYTES so the actual binary payload
32
+ // never exceeds the documented cap.
33
+ export const MAX_REQUEST_CONTENT_LENGTH = MAX_UPLOAD_BYTES + 1024 * 1024;
34
+
35
+ // Per-token rate-limit constants. 60 uploads/hour matches the issue
36
+ // "如 60 req/h" suggestion and is enforced in-memory (one process holds
37
+ // the source of truth; resets on restart).
38
+ export const UPLOAD_RATE_WINDOW_MS = 60 * 60 * 1000;
39
+ export const UPLOAD_RATE_MAX_PER_WINDOW = 60;
40
+
41
+ // Strict file_id regex. Generated server-side via crypto.randomUUID()
42
+ // with hyphens stripped, so the natural output is 32 hex chars. The
43
+ // regex is intentionally permissive on the upper bound (64 chars) to
44
+ // allow future scheme changes (e.g. prefix), but it rejects ANY of `.`
45
+ // `/` `\` `..` so the same regex used for path generation can also be
46
+ // used to validate a /api/files/:file_id lookup against directory
47
+ // traversal.
48
+ export const FILE_ID_REGEX = /^[A-Za-z0-9_-]{8,64}$/;
49
+
50
+ export type GeneratedFile = {
51
+ fileId: string;
52
+ ext: string;
53
+ dateBucket: string;
54
+ relativePath: string;
55
+ absolutePath: string;
56
+ };
57
+
58
+ export function getUploadsRoot(env: NodeJS.ProcessEnv = process.env): string {
59
+ return env.COMMHUB_UPLOADS_DIR || join(homedir(), ".anet/server/uploads");
60
+ }
61
+
62
+ export function getDateBucket(now: Date = new Date()): string {
63
+ // YYYY-MM-DD partitioning per issue spec — keeps a single directory
64
+ // from accumulating millions of entries while leaving the lookup
65
+ // path predictable from the disk index (file_id → date bucket).
66
+ return now.toISOString().slice(0, 10);
67
+ }
68
+
69
+ export function generateFileId(): string {
70
+ return crypto.randomUUID().replace(/-/g, "");
71
+ }
72
+
73
+ /**
74
+ * Extract a safe file extension from a client-supplied filename.
75
+ * Server NEVER uses the rest of the filename. Returns "" if no
76
+ * acceptable extension is present.
77
+ *
78
+ * Accepts: alphanumeric extensions of 1–16 chars (`.png`, `.tar`,
79
+ * `.docx`, `.tar.gz` returns only `.gz`). Rejects: anything containing
80
+ * path separators, dots in unusual positions, or non-ASCII characters.
81
+ */
82
+ export function sanitizeExt(filename: string | undefined | null): string {
83
+ if (!filename || typeof filename !== "string") return "";
84
+ // Defense: strip any leading directory the client might have shoved
85
+ // into "name", then take only the last extension.
86
+ const base = filename.split(/[\\/]/).pop() ?? "";
87
+ const match = base.match(/\.([A-Za-z0-9]{1,16})$/);
88
+ return match ? "." + match[1].toLowerCase() : "";
89
+ }
90
+
91
+ /**
92
+ * Build the per-upload storage path. The file_id and ext are validated
93
+ * against strict regexes; any path-separator or `..` segment from a
94
+ * malicious client gets rejected at the regex layer before any I/O.
95
+ */
96
+ export function buildStoragePath(fileId: string, ext: string, opts: { uploadsRoot?: string; now?: Date } = {}): GeneratedFile {
97
+ if (!FILE_ID_REGEX.test(fileId)) throw new Error(`file_id "${fileId}" is invalid`);
98
+ if (ext && !/^\.[A-Za-z0-9]{1,16}$/.test(ext)) throw new Error(`ext "${ext}" is invalid`);
99
+ const dateBucket = getDateBucket(opts.now);
100
+ const root = opts.uploadsRoot ?? getUploadsRoot();
101
+ const relativePath = join(dateBucket, fileId + ext);
102
+ const absolutePath = join(root, relativePath);
103
+ return { fileId, ext, dateBucket, relativePath, absolutePath };
104
+ }
105
+
106
+ export type UploadIndexEntry = {
107
+ file_id: string;
108
+ date_bucket: string; // YYYY-MM-DD
109
+ ext: string; // ".png" / ".pdf" / etc., including the leading dot, lowercase
110
+ name: string; // original filename — stored as metadata only, NEVER used in paths
111
+ mime: string; // best-effort mime from the multipart Content-Type
112
+ size: number; // byte length of the binary
113
+ owner?: string; // upload token id / username, for traceability
114
+ uploaded_at: string; // ISO 8601
115
+ };
116
+
117
+ /** Path to the index entry file for a given file_id. Lives in
118
+ * `<uploadsRoot>/.index/<file_id>.json` so /api/files/:file_id can do
119
+ * an O(1) lookup without scanning every date bucket. */
120
+ export function indexEntryPath(fileId: string, opts: { uploadsRoot?: string } = {}): string | null {
121
+ if (!FILE_ID_REGEX.test(fileId)) return null;
122
+ const root = opts.uploadsRoot ?? getUploadsRoot();
123
+ return join(root, ".index", fileId + ".json");
124
+ }
125
+
126
+ /** Validate an index entry shape before serving a download. Defends
127
+ * against a malformed/tampered .index file by re-checking the same
128
+ * invariants the upload handler enforces. */
129
+ export function validateIndexEntry(entry: unknown): entry is UploadIndexEntry {
130
+ if (!entry || typeof entry !== "object") return false;
131
+ const e: any = entry;
132
+ return (
133
+ typeof e.file_id === "string" && FILE_ID_REGEX.test(e.file_id) &&
134
+ typeof e.date_bucket === "string" && /^\d{4}-\d{2}-\d{2}$/.test(e.date_bucket) &&
135
+ typeof e.ext === "string" && (e.ext === "" || /^\.[A-Za-z0-9]{1,16}$/.test(e.ext)) &&
136
+ typeof e.size === "number" && e.size >= 0 && e.size <= MAX_UPLOAD_BYTES
137
+ );
138
+ }
139
+
140
+ /**
141
+ * Defence-in-depth: confirm a resolved absolute path falls within
142
+ * the uploads root. The HTTP handler calls this after constructing
143
+ * the download path; if it ever reports `false` we hard-fail the
144
+ * request rather than serve whatever the resolved path points at.
145
+ */
146
+ export function isPathInsideUploadsRoot(absPath: string, uploadsRoot?: string): boolean {
147
+ const root = uploadsRoot ?? getUploadsRoot();
148
+ const rel = relative(root, absPath);
149
+ return rel !== "" && !rel.startsWith("..") && !isAbsolute(rel);
150
+ }
151
+
152
+ // ── Rate limit (in-memory, per token/IP) ────────────────────────────
153
+
154
+ type UploadRateState = { count: number; resetAt: number };
155
+
156
+ export class UploadRateLimiter {
157
+ private state = new Map<string, UploadRateState>();
158
+
159
+ constructor(
160
+ private readonly windowMs: number = UPLOAD_RATE_WINDOW_MS,
161
+ private readonly maxPerWindow: number = UPLOAD_RATE_MAX_PER_WINDOW,
162
+ ) {}
163
+
164
+ /**
165
+ * Attempt to consume one upload quota slot for `key` (typically the
166
+ * caller's token id, falling back to IP for legacy auth). Returns
167
+ * `{ allowed: true }` when the upload may proceed and
168
+ * `{ allowed: false, retryAfterMs }` when the caller is rate-limited.
169
+ * Window is rolling on first touch (not aligned to wall-clock hours).
170
+ */
171
+ check(key: string, nowMs: number = Date.now()): { allowed: boolean; remaining: number; resetAt: number; retryAfterMs?: number } {
172
+ const entry = this.state.get(key);
173
+ if (!entry || nowMs >= entry.resetAt) {
174
+ const resetAt = nowMs + this.windowMs;
175
+ this.state.set(key, { count: 1, resetAt });
176
+ return { allowed: true, remaining: this.maxPerWindow - 1, resetAt };
177
+ }
178
+ if (entry.count >= this.maxPerWindow) {
179
+ return { allowed: false, remaining: 0, resetAt: entry.resetAt, retryAfterMs: entry.resetAt - nowMs };
180
+ }
181
+ entry.count++;
182
+ return { allowed: true, remaining: this.maxPerWindow - entry.count, resetAt: entry.resetAt };
183
+ }
184
+
185
+ /** Visible for tests — clear all rate-limit state. */
186
+ reset(): void {
187
+ this.state.clear();
188
+ }
189
+
190
+ /** Visible for tests — current bucket count. */
191
+ size(): number {
192
+ return this.state.size;
193
+ }
194
+ }
195
+
196
+ /** Module-level singleton shared by the HTTP /api/upload handler. */
197
+ export const sharedUploadRateLimiter = new UploadRateLimiter();
198
+
199
+ // ── Attachment schema (shared between REST /api/task and MCP send_task) ───
200
+
201
+ /**
202
+ * Canonical shape for the `attachments` field on tasks. Both the REST
203
+ * /api/task body and the MCP send_task tool accept this same array;
204
+ * server persistence routes through `tasks.meta_json` so the storage
205
+ * layout is identical regardless of transport.
206
+ */
207
+ export type TaskAttachment = {
208
+ type: "file";
209
+ file_id: string;
210
+ name?: string;
211
+ mime?: string;
212
+ size?: number;
213
+ };
214
+
215
+ /** Light validator usable from the REST handler. Returns null on success or an error message string. */
216
+ export function validateAttachments(input: unknown): { ok: true; attachments: TaskAttachment[] } | { ok: false; error: string } {
217
+ if (input === undefined || input === null) return { ok: true, attachments: [] };
218
+ if (!Array.isArray(input)) return { ok: false, error: "attachments must be an array" };
219
+ if (input.length > 20) return { ok: false, error: "too many attachments (max 20)" };
220
+ const out: TaskAttachment[] = [];
221
+ for (let i = 0; i < input.length; i++) {
222
+ const a: any = input[i];
223
+ if (!a || typeof a !== "object") return { ok: false, error: `attachments[${i}] is not an object` };
224
+ if (a.type !== "file") return { ok: false, error: `attachments[${i}].type must be "file"` };
225
+ if (typeof a.file_id !== "string" || !FILE_ID_REGEX.test(a.file_id)) {
226
+ return { ok: false, error: `attachments[${i}].file_id is invalid` };
227
+ }
228
+ if (a.name !== undefined && (typeof a.name !== "string" || a.name.length > 255)) {
229
+ return { ok: false, error: `attachments[${i}].name must be a string ≤ 255 chars` };
230
+ }
231
+ if (a.mime !== undefined && (typeof a.mime !== "string" || a.mime.length > 100)) {
232
+ return { ok: false, error: `attachments[${i}].mime must be a string ≤ 100 chars` };
233
+ }
234
+ if (a.size !== undefined && (typeof a.size !== "number" || a.size < 0 || a.size > MAX_UPLOAD_BYTES)) {
235
+ return { ok: false, error: `attachments[${i}].size must be a non-negative number ≤ ${MAX_UPLOAD_BYTES}` };
236
+ }
237
+ out.push({ type: "file", file_id: a.file_id, name: a.name, mime: a.mime, size: a.size });
238
+ }
239
+ return { ok: true, attachments: out };
240
+ }