@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.
@@ -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
+ }